feat(web): 搭建 Web 壳层与 UI 包基线

React 19 + Vite 6 + Router 7 + TanStack Query + Zustand;Semi 二次封装与 Remix 图标统一出口;顶部模块 + 左侧功能菜单布局、Cloud Blue 主题、登录页与 401/403/404/500 页面;请求层含 token 注入、单飞刷新与统一错误提示。
This commit is contained in:
Stev_Wang committed 2026-11-02 17:21:16 +08:00
1 parent 23067edb8f
commit 073d210a92
51 files changed
+4452

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
{
"name": "@lingxi/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./icons": {
"types": "./dist/icons.d.ts",
"import": "./dist/icons.js",
"require": "./dist/icons.cjs"
},
"./theme/tokens.css": "./dist/theme/tokens.css",
"./styles.css": "./dist/styles.css",
"./semi.css": "./dist/semi.css"
},
"typesVersions": {
"*": {
"icons": [
"dist/icons.d.ts"
],
"*": [
"dist/index.d.ts"
]
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.ts src/icons.ts --format esm,cjs --dts --clean --sourcemap && node scripts/copy-assets.mjs",
"dev": "tsup src/index.ts src/icons.ts --format esm,cjs --dts --watch",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"dependencies": {
"@douyinfe/semi-ui-19": "2.104.0",
"@remixicon/react": "4.9.0"
},
"devDependencies": {
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"tsup": "8.5.1",
"typescript": "5.9.3"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { cp, mkdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
await mkdir(resolve(root, 'dist/theme'), { recursive: true });
await cp(resolve(root, 'src/theme/tokens.css'), resolve(root, 'dist/theme/tokens.css'));
await cp(resolve(root, 'src/styles/components.css'), resolve(root, 'dist/styles.css'));
await cp(
resolve(root, 'node_modules/@douyinfe/semi-ui-19/dist/css/semi.min.css'),
resolve(root, 'dist/semi.css'),
);
+50
View File
@@ -0,0 +1,50 @@
import { Table } from '@douyinfe/semi-ui-19';
import type { TableProps } from '@douyinfe/semi-ui-19/lib/es/table/interface';
import type { ReactNode } from 'react';
import { PageEmpty } from './PageEmpty.js';
export interface DataTableProps<T extends Record<string, unknown>> {
columns: NonNullable<TableProps<T>['columns']>;
dataSource: T[];
loading?: boolean;
rowKey?: TableProps<T>['rowKey'];
pagination?: TableProps<T>['pagination'];
empty?: ReactNode;
size?: TableProps<T>['size'];
scroll?: TableProps<T>['scroll'];
className?: string;
onRow?: TableProps<T>['onRow'];
}
/**
* 列表表格统一封装:加载态 + 空态 + 行 key 约定。
* 业务页只负责列定义与数据,不重复写 Empty / loading。
*/
export function DataTable<T extends Record<string, unknown>>({
columns,
dataSource,
loading = false,
rowKey = 'id',
pagination = false,
empty,
size = 'middle',
scroll,
className,
onRow,
}: DataTableProps<T>): ReactNode {
return (
<Table<T>
className={`lx-data-table${className === undefined ? '' : ` ${className}`}`}
columns={columns}
dataSource={dataSource}
loading={loading}
rowKey={rowKey}
pagination={pagination}
size={size}
scroll={scroll}
onRow={onRow}
empty={empty ?? <PageEmpty />}
/>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { Button } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { IconError, IconRefresh } from '../icons.js';
export interface ErrorStateProps {
title?: string;
message?: string;
/** 服务端返回的 trace_id;500 类错误必须展示(工程规范 04) */
traceId?: string;
onRetry?: () => void;
retryText?: string;
className?: string;
}
/** 统一错误态:说明 + trace_id + 重试 */
export function ErrorState({
title = '加载失败',
message = '请稍后重试;若持续失败,请把 trace_id 提供给管理员。',
traceId,
onRetry,
retryText = '重新加载',
className,
}: ErrorStateProps): ReactNode {
return (
<div className={`lx-error-state${className === undefined ? '' : ` ${className}`}`}>
<span className="lx-error-state__icon" aria-hidden="true">
<IconError size={32} />
</span>
<h3 className="lx-error-state__title">{title}</h3>
<p className="lx-error-state__message">{message}</p>
{traceId === undefined || traceId.length === 0 ? null : (
<p className="lx-error-state__trace">trace_id: {traceId}</p>
)}
{onRetry === undefined ? null : (
<Button icon={<IconRefresh />} onClick={onRetry} className="lx-error-state__retry">
{retryText}
</Button>
)}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { Modal } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
export interface FormModalProps {
visible: boolean;
title: ReactNode;
children: ReactNode;
onCancel: () => void;
onOk: () => void;
confirmLoading?: boolean;
okText?: string;
cancelText?: string;
width?: number | string;
className?: string;
}
/** 表单弹窗统一封装:确定 / 取消 / 提交中状态 */
export function FormModal({
visible,
title,
children,
onCancel,
onOk,
confirmLoading = false,
okText = '确定',
cancelText = '取消',
width = 560,
className,
}: FormModalProps): ReactNode {
return (
<Modal
visible={visible}
title={title}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText={okText}
cancelText={cancelText}
width={width}
className={`lx-form-modal${className === undefined ? '' : ` ${className}`}`}
closeOnEsc
maskClosable={false}
>
{children}
</Modal>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Empty } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { IconAsset } from '../icons.js';
export interface PageEmptyProps {
title?: ReactNode;
description?: ReactNode;
action?: ReactNode;
className?: string;
}
/** 统一空态:标题 + 说明 + 可选主操作 */
export function PageEmpty({
title = '暂无数据',
description,
action,
className,
}: PageEmptyProps): ReactNode {
return (
<div className={`lx-empty${className === undefined ? '' : ` ${className}`}`}>
<Empty image={<IconAsset size={40} />} title={title} description={description}>
{action === undefined ? null : <div className="lx-empty__action">{action}</div>}
</Empty>
</div>
);
}
@@ -0,0 +1,27 @@
import { Skeleton } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
export interface PageSkeletonProps {
loading?: boolean;
rows?: number;
children?: ReactNode;
className?: string;
}
/** 统一加载骨架:页面级加载用段落占位,避免布局跳动 */
export function PageSkeleton({
loading = true,
rows = 6,
children,
className,
}: PageSkeletonProps): ReactNode {
return (
<Skeleton
loading={loading}
placeholder={<Skeleton.Paragraph rows={rows} />}
className={`lx-page-skeleton${className === undefined ? '' : ` ${className}`}`}
>
{children}
</Skeleton>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { Button, Form, Space } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { IconRefresh, IconSearch } from '../icons.js';
export interface QueryFormProps {
children: ReactNode;
/** 查询动作;由页面自行发起请求 */
onSubmit: () => void;
onReset?: () => void;
loading?: boolean;
/** 额外的右侧操作(如「新建」) */
extra?: ReactNode;
className?: string;
}
/** 列表页查询表单:字段一行内排列,查询 / 重置固位;筛选行不超过两行(设计约束) */
export function QueryForm({
children,
onSubmit,
onReset,
loading = false,
extra,
className,
}: QueryFormProps): ReactNode {
return (
<Form
className={`lx-query-form${className === undefined ? '' : ` ${className}`}`}
layout="horizontal"
onSubmit={() => {
onSubmit();
}}
>
<div className="lx-query-form__fields">{children}</div>
<Space className="lx-query-form__actions" spacing={8}>
<Button
htmlType="submit"
theme="solid"
type="primary"
icon={<IconSearch />}
loading={loading}
>
查询
</Button>
<Button
icon={<IconRefresh />}
onClick={() => {
onReset?.();
}}
>
重置
</Button>
{extra}
</Space>
</Form>
);
}
+6
View File
@@ -0,0 +1,6 @@
export { DataTable } from './DataTable.js';
export { ErrorState } from './ErrorState.js';
export { FormModal } from './FormModal.js';
export { PageEmpty } from './PageEmpty.js';
export { PageSkeleton } from './PageSkeleton.js';
export { QueryForm } from './QueryForm.js';
+63
View File
@@ -0,0 +1,63 @@
/**
* Remix Icon 统一出口(AGENTS §3.3:业务代码只能从这里按语义名导入)
* 约定:对外只暴露 Icon* 语义名;新增图标时在这里做一次映射,不要在业务代码里直接引图标库。
*/
export {
RiAddLine as IconAdd,
RiAlertLine as IconAlert,
RiAppsLine as IconApps,
RiFolderOpenLine as IconAsset,
RiArrowLeftLine as IconBack,
RiCalendarLine as IconCalendar,
RiBarChartLine as IconChart,
RiCheckLine as IconCheck,
RiArrowDownSLine as IconChevronDown,
RiArrowRightSLine as IconChevronRight,
RiTimeLine as IconClock,
RiCloseLine as IconClose,
RiFileCopyLine as IconCopy,
RiDashboardLine as IconDashboard,
RiDeleteBinLine as IconDelete,
RiSendPlaneLine as IconDelivery,
RiBookOpenLine as IconDocs,
RiDownloadLine as IconDownload,
RiDragMoveLine as IconDrag,
RiEditLine as IconEdit,
RiErrorWarningLine as IconError,
RiExportLine as IconExport,
RiFilter3Line as IconFilter,
RiGlobalLine as IconGlobal,
RiLayoutGridLine as IconGrid,
RiHome5Line as IconHome,
RiImageAddLine as IconImageAdd,
RiLoader4Line as IconLoading,
RiLockLine as IconLock,
RiLogoutBoxRLine as IconLogout,
RiMailLine as IconMail,
RiImageLine as IconMaterial,
RiMenuLine as IconMenu,
RiMessage3Line as IconMessage,
RiMoreLine as IconMore,
RiMusicLine as IconMusic,
RiNotification3Line as IconNotification,
RiPaletteLine as IconPalette,
RiPauseLine as IconPause,
RiPlayLine as IconPlay,
RiRefreshLine as IconRefresh,
RiRobotLine as IconRobot,
RiSaveLine as IconSave,
RiFileTextLine as IconScript,
RiSearchLine as IconSearch,
RiServerLine as IconServer,
RiSettings3Line as IconSettings,
RiShareLine as IconShare,
RiShieldKeyholeLine as IconShield,
RiStackLine as IconStack,
RiStarLine as IconStar,
RiTaskLine as IconTask,
RiThumbUpLine as IconThumbUp,
RiUploadLine as IconUpload,
RiUserLine as IconUser,
RiVideoLine as IconVideo,
RiEyeLine as IconView,
} from '@remixicon/react';
+4
View File
@@ -0,0 +1,4 @@
export * from './components/index.js';
export * from './locale.js';
export * from './notify.js';
export * from './theme/tokens.js';
+5
View File
@@ -0,0 +1,5 @@
import type { Locale } from '@douyinfe/semi-ui-19/lib/es/locale/interface';
import zhCN from '@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN';
/** 全站默认中文语言包(Semi LocaleProvider) */
export const localeZhCN: Locale = zhCN;
+19
View File
@@ -0,0 +1,19 @@
import { Toast } from '@douyinfe/semi-ui-19';
/** 统一成功提示 */
export function notifySuccess(content: string): void {
Toast.success({ content, duration: 2 });
}
/** 统一错误提示;带 trace_id 时一并展示,便于用户报障(工程规范 04) */
export function notifyError(message: string, traceId?: string): void {
Toast.error({
content:
traceId === undefined || traceId.length === 0 ? message : `${message}(trace: ${traceId})`,
duration: 4,
});
}
export function notifyWarning(content: string): void {
Toast.warning({ content, duration: 3 });
}
+100
View File
@@ -0,0 +1,100 @@
/* 灵犀 UI 包组件样式:只使用 tokens.css 中的语义变量,不写颜色字面量 */
.lx-empty {
padding: 48px 0;
text-align: center;
}
.lx-empty__action {
margin-top: var(--lx-space-4);
}
.lx-error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--lx-space-2);
padding: 64px var(--lx-space-6);
text-align: center;
}
.lx-error-state__icon {
color: var(--lx-color-danger);
display: inline-flex;
}
.lx-error-state__title {
margin: var(--lx-space-2) 0 0;
color: var(--lx-color-text-primary);
font-size: var(--lx-font-size-lg);
line-height: var(--lx-line-height-tight);
}
.lx-error-state__message {
margin: 0;
max-width: 42ch;
color: var(--lx-color-text-secondary);
font-size: var(--lx-font-size-sm);
}
.lx-error-state__trace {
margin: 0;
color: var(--lx-color-text-tertiary);
font-family: var(--lx-font-mono);
font-size: var(--lx-font-size-xs);
}
.lx-error-state__retry {
margin-top: var(--lx-space-3);
}
.lx-page-skeleton {
padding: var(--lx-space-4) 0;
}
.lx-query-form {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--lx-space-4);
margin-bottom: var(--lx-space-4);
}
.lx-query-form__fields {
display: flex;
flex: 1 1 auto;
flex-wrap: wrap;
gap: var(--lx-space-2) var(--lx-space-4);
min-width: 0;
}
.lx-query-form__actions {
flex: 0 0 auto;
}
.lx-data-table .semi-table-container {
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
overflow: hidden;
}
.lx-data-table .semi-table-thead > .semi-table-row > .semi-table-row-head {
background: var(--lx-color-bg-panel);
color: var(--lx-color-text-secondary);
font-size: var(--lx-font-size-xs);
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.lx-data-table .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
border-bottom: 1px solid var(--lx-color-border);
}
.lx-data-table .semi-table-tbody > .semi-table-row:hover > .semi-table-row-cell {
background: var(--lx-color-bg-hover);
}
.lx-form-modal .semi-modal-content {
background: var(--lx-color-bg-elevated);
}
+114
View File
@@ -0,0 +1,114 @@
:root {
/* 方向 A · 云白蓝(Cloud Blue):传统企业后台骨架 + 现代浅色质感 */
--lx-color-bg-app: #f5f7fa;
--lx-color-bg-panel: #ffffff;
--lx-color-bg-elevated: #ffffff;
--lx-color-bg-subtle: #fafbfc;
--lx-color-bg-hover: #f2f4f7;
--lx-color-bg-active: #e8f1ff;
--lx-color-border: #e5e8ef;
--lx-color-border-strong: #d0d5dd;
--lx-color-text-primary: #1f2329;
--lx-color-text-secondary: #646a73;
--lx-color-text-tertiary: #8f959e;
--lx-color-text-inverse: #ffffff;
--lx-color-brand: #1677ff;
--lx-color-brand-hover: #4096ff;
--lx-color-brand-pressed: #0958d9;
--lx-color-brand-soft: #e8f1ff;
--lx-color-on-brand: #ffffff;
--lx-color-focus: #1677ff;
--lx-color-success: #00b42a;
--lx-color-warning: #ff7d00;
--lx-color-danger: #f53f3f;
--lx-color-info: #1677ff;
--lx-color-chart-1: #1677ff;
--lx-color-chart-2: #00b42a;
--lx-color-chart-3: #ff7d00;
--lx-color-chart-4: #722ed1;
--lx-color-chart-5: #eb2f96;
/* 字体 */
--lx-font-sans:
'PingFang SC', 'HarmonyOS Sans SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
--lx-font-mono: 'SF Mono', 'JetBrains Mono', 'Roboto Mono', ui-monospace, monospace;
--lx-font-size-xs: 12px;
--lx-font-size-sm: 13px;
--lx-font-size-md: 14px;
--lx-font-size-lg: 16px;
--lx-font-size-xl: 20px;
--lx-font-size-display: 28px;
--lx-line-height-tight: 1.25;
--lx-line-height-normal: 1.55;
/* 间距(4px 基线) */
--lx-space-1: 4px;
--lx-space-2: 8px;
--lx-space-3: 12px;
--lx-space-4: 16px;
--lx-space-5: 20px;
--lx-space-6: 24px;
--lx-space-8: 32px;
--lx-space-10: 40px;
/* 结构尺寸:顶部模块菜单 + 左侧功能菜单 */
--lx-topbar-height: 56px;
--lx-sidebar-width: 216px;
--lx-sidebar-collapsed-width: 56px;
--lx-content-padding: 24px;
--lx-radius-sm: 4px;
--lx-radius-md: 6px;
--lx-radius-lg: 10px;
--lx-radius-pill: 999px;
--lx-shadow-panel: 0 1px 2px rgba(31, 35, 41, 0.04);
--lx-shadow-overlay: 0 12px 32px rgba(31, 35, 41, 0.14);
--lx-shadow-focus: 0 0 0 3px rgba(22, 119, 255, 0.16);
--lx-motion-fast: 120ms;
--lx-motion-base: 180ms;
--lx-ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Semi Design 变量桥接:业务侧只使用 --lx-*,Semi 组件跟随同一套 token */
body[theme-mode='light'] {
--semi-color-primary: var(--lx-color-brand);
--semi-color-primary-hover: var(--lx-color-brand-hover);
--semi-color-primary-active: var(--lx-color-brand-pressed);
--semi-color-primary-disabled: var(--lx-color-border-strong);
--semi-color-primary-light-default: var(--lx-color-brand-soft);
--semi-color-primary-light-hover: #dbe9ff;
--semi-color-primary-light-active: #cfe1ff;
--semi-color-bg-0: var(--lx-color-bg-app);
--semi-color-bg-1: var(--lx-color-bg-panel);
--semi-color-bg-2: var(--lx-color-bg-panel);
--semi-color-bg-3: var(--lx-color-bg-panel);
--semi-color-bg-4: var(--lx-color-bg-hover);
--semi-color-text-0: var(--lx-color-text-primary);
--semi-color-text-1: var(--lx-color-text-primary);
--semi-color-text-2: var(--lx-color-text-secondary);
--semi-color-text-3: var(--lx-color-text-tertiary);
--semi-color-border: var(--lx-color-border);
--semi-color-fill-0: var(--lx-color-bg-hover);
--semi-color-fill-1: #eef1f5;
--semi-color-fill-2: var(--lx-color-bg-active);
--semi-color-danger: var(--lx-color-danger);
--semi-color-success: var(--lx-color-success);
--semi-color-warning: var(--lx-color-warning);
--semi-color-info: var(--lx-color-info);
--semi-color-focus-border: var(--lx-color-focus);
--semi-color-shadow: var(--lx-shadow-overlay);
}
+15
View File
@@ -0,0 +1,15 @@
/**
* 主题 token 的 JS 副本(供 JS 计算场景使用,如 ECharts / Canvas)
* 注意:颜色字面量只允许出现在 packages/ui/src/theme/(工程规范 03 §3.2)。
*/
export const themeTokens = {
colorBrand: '#f5a524',
colorBrandHover: '#ffb84d',
colorSuccess: '#3fb950',
colorWarning: '#d29922',
colorDanger: '#f85149',
colorInfo: '#58a6ff',
chartColors: ['#f5a524', '#58a6ff', '#3fb950', '#d29922', '#a371f7'],
} as const;
export type ThemeTokens = typeof themeTokens;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true,
"types": []
},
"include": ["src"]
}