From 073d210a9245aaf4159efa58c1dec755ebd5ed2a Mon Sep 17 00:00:00 2001
From: Stev_Wang <304865932@qq.com>
Date: Mon, 2 Nov 2026 17:21:16 +0800
Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=90=AD=E5=BB=BA=20Web=20?=
=?UTF-8?q?=E5=A3=B3=E5=B1=82=E4=B8=8E=20UI=20=E5=8C=85=E5=9F=BA=E7=BA=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
React 19 + Vite 6 + Router 7 + TanStack Query + Zustand;Semi 二次封装与 Remix 图标统一出口;顶部模块 + 左侧功能菜单布局、Cloud Blue 主题、登录页与 401/403/404/500 页面;请求层含 token 注入、单飞刷新与统一错误提示。
---
apps/web/index.html | 13 +
apps/web/package.json | 35 ++
apps/web/src/api/client.spec.ts | 141 +++++
apps/web/src/api/client.ts | 199 +++++++
apps/web/src/api/generated/openapi.d.ts | 165 ++++++
apps/web/src/api/query-client.ts | 37 ++
apps/web/src/layout/AppLayout.tsx | 58 ++
apps/web/src/layout/PageBreadcrumbs.tsx | 33 ++
apps/web/src/layout/SideNav.tsx | 59 ++
apps/web/src/layout/TopBar.tsx | 143 +++++
apps/web/src/main.tsx | 39 ++
apps/web/src/navigation/nav-config.spec.ts | 41 ++
apps/web/src/navigation/nav-config.ts | 410 +++++++++++++
apps/web/src/pages/errors/ErrorLayout.tsx | 40 ++
apps/web/src/pages/errors/ForbiddenPage.tsx | 38 ++
apps/web/src/pages/errors/NotFoundPage.tsx | 29 +
apps/web/src/pages/errors/ServerErrorPage.tsx | 41 ++
.../web/src/pages/errors/UnauthorizedPage.tsx | 29 +
apps/web/src/pages/home/HomePage.tsx | 373 ++++++++++++
apps/web/src/pages/login/LoginBrandPanel.tsx | 14 +
apps/web/src/pages/login/LoginFormCard.tsx | 114 ++++
apps/web/src/pages/login/LoginPage.tsx | 35 ++
apps/web/src/pages/login/use-login-form.ts | 70 +++
.../web/src/pages/workspace/WorkspacePage.tsx | 63 ++
apps/web/src/router.tsx | 40 ++
apps/web/src/routes.ts | 11 +
apps/web/src/stores/auth-store.ts | 141 +++++
apps/web/src/styles/global.css | 284 +++++++++
apps/web/src/styles/layout.css | 475 +++++++++++++++
apps/web/src/styles/pages.css | 551 ++++++++++++++++++
apps/web/src/vite-env.d.ts | 6 +
apps/web/tsconfig.json | 13 +
apps/web/vite.config.ts | 27 +
apps/web/vitest.config.ts | 29 +
packages/ui/package.json | 58 ++
packages/ui/scripts/copy-assets.mjs | 13 +
packages/ui/src/components/DataTable.tsx | 50 ++
packages/ui/src/components/ErrorState.tsx | 42 ++
packages/ui/src/components/FormModal.tsx | 47 ++
packages/ui/src/components/PageEmpty.tsx | 27 +
packages/ui/src/components/PageSkeleton.tsx | 27 +
packages/ui/src/components/QueryForm.tsx | 57 ++
packages/ui/src/components/index.ts | 6 +
packages/ui/src/icons.ts | 63 ++
packages/ui/src/index.ts | 4 +
packages/ui/src/locale.ts | 5 +
packages/ui/src/notify.ts | 19 +
packages/ui/src/styles/components.css | 100 ++++
packages/ui/src/theme/tokens.css | 114 ++++
packages/ui/src/theme/tokens.ts | 15 +
packages/ui/tsconfig.json | 9 +
51 files changed, 4452 insertions(+)
create mode 100644 apps/web/index.html
create mode 100644 apps/web/package.json
create mode 100644 apps/web/src/api/client.spec.ts
create mode 100644 apps/web/src/api/client.ts
create mode 100644 apps/web/src/api/generated/openapi.d.ts
create mode 100644 apps/web/src/api/query-client.ts
create mode 100644 apps/web/src/layout/AppLayout.tsx
create mode 100644 apps/web/src/layout/PageBreadcrumbs.tsx
create mode 100644 apps/web/src/layout/SideNav.tsx
create mode 100644 apps/web/src/layout/TopBar.tsx
create mode 100644 apps/web/src/main.tsx
create mode 100644 apps/web/src/navigation/nav-config.spec.ts
create mode 100644 apps/web/src/navigation/nav-config.ts
create mode 100644 apps/web/src/pages/errors/ErrorLayout.tsx
create mode 100644 apps/web/src/pages/errors/ForbiddenPage.tsx
create mode 100644 apps/web/src/pages/errors/NotFoundPage.tsx
create mode 100644 apps/web/src/pages/errors/ServerErrorPage.tsx
create mode 100644 apps/web/src/pages/errors/UnauthorizedPage.tsx
create mode 100644 apps/web/src/pages/home/HomePage.tsx
create mode 100644 apps/web/src/pages/login/LoginBrandPanel.tsx
create mode 100644 apps/web/src/pages/login/LoginFormCard.tsx
create mode 100644 apps/web/src/pages/login/LoginPage.tsx
create mode 100644 apps/web/src/pages/login/use-login-form.ts
create mode 100644 apps/web/src/pages/workspace/WorkspacePage.tsx
create mode 100644 apps/web/src/router.tsx
create mode 100644 apps/web/src/routes.ts
create mode 100644 apps/web/src/stores/auth-store.ts
create mode 100644 apps/web/src/styles/global.css
create mode 100644 apps/web/src/styles/layout.css
create mode 100644 apps/web/src/styles/pages.css
create mode 100644 apps/web/src/vite-env.d.ts
create mode 100644 apps/web/tsconfig.json
create mode 100644 apps/web/vite.config.ts
create mode 100644 apps/web/vitest.config.ts
create mode 100644 packages/ui/package.json
create mode 100644 packages/ui/scripts/copy-assets.mjs
create mode 100644 packages/ui/src/components/DataTable.tsx
create mode 100644 packages/ui/src/components/ErrorState.tsx
create mode 100644 packages/ui/src/components/FormModal.tsx
create mode 100644 packages/ui/src/components/PageEmpty.tsx
create mode 100644 packages/ui/src/components/PageSkeleton.tsx
create mode 100644 packages/ui/src/components/QueryForm.tsx
create mode 100644 packages/ui/src/components/index.ts
create mode 100644 packages/ui/src/icons.ts
create mode 100644 packages/ui/src/index.ts
create mode 100644 packages/ui/src/locale.ts
create mode 100644 packages/ui/src/notify.ts
create mode 100644 packages/ui/src/styles/components.css
create mode 100644 packages/ui/src/theme/tokens.css
create mode 100644 packages/ui/src/theme/tokens.ts
create mode 100644 packages/ui/tsconfig.json
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000..a6d6327
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ 灵犀 · 信息流素材生产平台
+
+
+
+
+
+
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..ea55689
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@lingxi/web",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -p tsconfig.json --noEmit && vite build",
+ "preview": "vite preview --port 4173 --strictPort",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "test": "vitest run --coverage"
+ },
+ "dependencies": {
+ "@douyinfe/semi-ui-19": "2.104.0",
+ "@lingxi/shared": "workspace:*",
+ "@lingxi/ui": "workspace:*",
+ "@tanstack/react-query": "5.105.1",
+ "react": "19.3.1",
+ "react-dom": "19.3.1",
+ "react-router": "7.18.5",
+ "zod": "3.25.76",
+ "zustand": "5.0.16"
+ },
+ "devDependencies": {
+ "@testing-library/react": "16.3.3",
+ "@types/react": "19.3.0",
+ "@types/react-dom": "19.3.0",
+ "@vitejs/plugin-react": "5.2.0",
+ "@vitest/coverage-v8": "3.2.7",
+ "jsdom": "28.1.0",
+ "typescript": "5.9.3",
+ "vite": "6.4.5",
+ "vitest": "3.2.7"
+ }
+}
diff --git a/apps/web/src/api/client.spec.ts b/apps/web/src/api/client.spec.ts
new file mode 100644
index 0000000..3eca30d
--- /dev/null
+++ b/apps/web/src/api/client.spec.ts
@@ -0,0 +1,141 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useAuthStore } from '@/stores/auth-store';
+
+import { ApiError, request, setUnauthorizedHandler } from './client';
+
+const jsonResponse = (body: unknown, status = 200): Response =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+const emptyResponse = (status: number): Response => new Response(null, { status });
+
+function signIn(accessToken = 'token-1', refreshToken = 'refresh-1'): void {
+ useAuthStore.getState().setSession({
+ accessToken,
+ refreshToken,
+ user: { id: '01J0TEST000000000000000000', name: '测试用户', email: 'test@lingxi.local' },
+ permissions: [],
+ });
+}
+
+function authorizationOf(init: RequestInit | undefined): string | null {
+ return init?.headers instanceof Headers ? init.headers.get('Authorization') : null;
+}
+
+function toUrl(input: string | URL | Request | undefined): string {
+ if (typeof input === 'string') {
+ return input;
+ }
+ if (input instanceof URL) {
+ return input.href;
+ }
+ return input?.url ?? '';
+}
+
+describe('请求层', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ window.localStorage.clear();
+ useAuthStore.getState().clearSession();
+ setUnauthorizedHandler(null);
+ });
+
+ it('注入 Bearer token 并解析 JSON', async () => {
+ signIn();
+ const fetchMock = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValue(jsonResponse({ status: 'ok' }));
+
+ await expect(request<{ status: string }>('/health')).resolves.toEqual({ status: 'ok' });
+ const init = fetchMock.mock.calls[0]?.[1];
+ expect(authorizationOf(init)).toBe('Bearer token-1');
+ expect(toUrl(fetchMock.mock.calls[0]?.[0])).toBe('http://127.0.0.1:3000/api/v1/health');
+ });
+
+ it('auth: false 时不注入 token', async () => {
+ signIn();
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ ok: true }));
+ await request('/auth/login', {
+ method: 'POST',
+ auth: false,
+ body: { email: 'a', password: 'b' },
+ });
+ expect(authorizationOf(fetchMock.mock.calls[0]?.[1])).toBeNull();
+ });
+
+ it('204 返回 undefined', async () => {
+ signIn();
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(emptyResponse(204));
+ await expect(request('/resource')).resolves.toBeUndefined();
+ });
+
+ it('统一错误体解析为 ApiError,保留 trace_id', async () => {
+ signIn();
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ jsonResponse(
+ { code: 42201, message: '业务校验失败', details: { field: 'name' }, trace_id: 'trace-1' },
+ 422,
+ ),
+ );
+ const error = await request('/resource').catch((caught: unknown) => caught);
+ expect(error).toBeInstanceOf(ApiError);
+ const apiError = error instanceof ApiError ? error : null;
+ expect(apiError?.code).toBe(42201);
+ expect(apiError?.status).toBe(422);
+ expect(apiError?.traceId).toBe('trace-1');
+ expect(apiError?.details).toEqual({ field: 'name' });
+ });
+
+ it('401 时单飞刷新并重放原请求', async () => {
+ signIn('expired-token', 'refresh-1');
+ let refreshCalls = 0;
+ vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
+ const url = toUrl(input);
+ const authorization = authorizationOf(init);
+ if (url.endsWith('/auth/refresh')) {
+ refreshCalls += 1;
+ return Promise.resolve(
+ jsonResponse({ accessToken: 'new-token', refreshToken: 'refresh-2' }),
+ );
+ }
+ if (authorization === 'Bearer expired-token') {
+ return Promise.resolve(emptyResponse(401));
+ }
+ return Promise.resolve(jsonResponse({ ok: true }));
+ });
+
+ const [first, second] = await Promise.all([request('/a'), request('/b')]);
+ expect(first).toEqual({ ok: true });
+ expect(second).toEqual({ ok: true });
+ expect(refreshCalls).toBe(1);
+ expect(useAuthStore.getState().accessToken).toBe('new-token');
+ });
+
+ it('刷新失败时清空会话并触发未授权回调', async () => {
+ signIn('expired-token', 'refresh-1');
+ const onUnauthorized = vi.fn();
+ setUnauthorizedHandler(onUnauthorized);
+ vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
+ const url = toUrl(input);
+ return Promise.resolve(
+ url.endsWith('/auth/refresh') ? emptyResponse(401) : emptyResponse(401),
+ );
+ });
+
+ await expect(request('/secure')).rejects.toBeInstanceOf(ApiError);
+ expect(onUnauthorized).toHaveBeenCalledTimes(1);
+ expect(useAuthStore.getState().accessToken).toBeNull();
+ });
+
+ it('把请求体序列化为 JSON', async () => {
+ signIn();
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ ok: true }));
+ await request('/resource', { method: 'POST', body: { name: '素材' } });
+ const init = fetchMock.mock.calls[0]?.[1];
+ expect(init?.body).toBe(JSON.stringify({ name: '素材' }));
+ expect(init?.method).toBe('POST');
+ });
+});
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
new file mode 100644
index 0000000..37dab18
--- /dev/null
+++ b/apps/web/src/api/client.ts
@@ -0,0 +1,199 @@
+import { apiErrorBodySchema } from '@lingxi/shared';
+
+import { useAuthStore } from '@/stores/auth-store';
+
+const DEFAULT_BASE_URL = 'http://127.0.0.1:3000/api/v1';
+
+export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
+
+export interface RequestOptions {
+ method?: HttpMethod;
+ body?: unknown;
+ headers?: Record;
+ signal?: AbortSignal;
+ /** 登录、刷新等接口不需要携带 token */
+ auth?: boolean;
+}
+
+export interface ApiErrorInit {
+ code: number;
+ status: number;
+ message: string;
+ details?: unknown;
+ traceId?: string;
+}
+
+/** 前端统一 API 错误:与服务端错误体 {code,message,details,trace_id} 对齐 */
+export class ApiError extends Error {
+ readonly code: number;
+ readonly status: number;
+ readonly details?: unknown;
+ readonly traceId?: string;
+
+ constructor(init: ApiErrorInit) {
+ super(init.message);
+ this.name = 'ApiError';
+ this.code = init.code;
+ this.status = init.status;
+ this.details = init.details;
+ this.traceId = init.traceId;
+ }
+}
+
+let unauthorizedHandler: (() => void) | null = null;
+
+export function setUnauthorizedHandler(handler: (() => void) | null): void {
+ unauthorizedHandler = handler;
+}
+
+function baseUrl(): string {
+ const configured = import.meta.env.VITE_PUBLIC_API_BASE_URL;
+ return (configured ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
+}
+
+function buildUrl(path: string): string {
+ if (/^https?:\/\//.test(path)) {
+ return path;
+ }
+ return `${baseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
+}
+
+function serializeBody(body: unknown): BodyInit | undefined {
+ if (body === undefined) {
+ return undefined;
+ }
+ if (
+ typeof body === 'string' ||
+ body instanceof FormData ||
+ body instanceof Blob ||
+ body instanceof URLSearchParams ||
+ body instanceof ArrayBuffer
+ ) {
+ return body;
+ }
+ return JSON.stringify(body);
+}
+
+function fallbackCode(status: number): number {
+ if (status === 401) return 40101;
+ if (status === 403) return 40301;
+ if (status === 404) return 40401;
+ if (status === 409) return 40903;
+ if (status === 429) return 42901;
+ if (status >= 500) return 50001;
+ return 40001;
+}
+
+async function toApiError(response: Response): Promise {
+ let payload: unknown = null;
+ try {
+ payload = await response.json();
+ } catch {
+ payload = null;
+ }
+ const parsed = apiErrorBodySchema.safeParse(payload);
+ if (parsed.success) {
+ return new ApiError({
+ code: parsed.data.code,
+ status: response.status,
+ message: parsed.data.message,
+ details: parsed.data.details,
+ traceId: parsed.data.trace_id,
+ });
+ }
+ return new ApiError({
+ code: fallbackCode(response.status),
+ status: response.status,
+ message: `请求失败(HTTP ${response.status})`,
+ });
+}
+
+let refreshPromise: Promise | null = null;
+
+/** 单飞刷新:并发 401 只触发一次 /auth/refresh */
+export function refreshSession(): Promise {
+ refreshPromise ??= doRefresh().finally(() => {
+ refreshPromise = null;
+ });
+ return refreshPromise;
+}
+
+async function doRefresh(): Promise {
+ const { refreshToken, clearSession } = useAuthStore.getState();
+ if (refreshToken === null) {
+ return false;
+ }
+ let response: Response;
+ try {
+ response = await fetch(buildUrl('/auth/refresh'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refreshToken }),
+ });
+ } catch {
+ return false;
+ }
+ if (!response.ok) {
+ clearSession();
+ unauthorizedHandler?.();
+ return false;
+ }
+ const payload: unknown = await response.json().catch(() => null);
+ const accessToken =
+ typeof payload === 'object' && payload !== null && 'accessToken' in payload
+ ? payload.accessToken
+ : null;
+ const nextRefreshToken =
+ typeof payload === 'object' && payload !== null && 'refreshToken' in payload
+ ? payload.refreshToken
+ : null;
+ if (typeof accessToken !== 'string' || accessToken.length === 0) {
+ clearSession();
+ unauthorizedHandler?.();
+ return false;
+ }
+ useAuthStore.getState().setTokens({
+ accessToken,
+ refreshToken: typeof nextRefreshToken === 'string' ? nextRefreshToken : refreshToken,
+ });
+ return true;
+}
+
+async function send(path: string, options: RequestOptions, allowRefresh: boolean): Promise {
+ const { accessToken } = useAuthStore.getState();
+ const headers = new Headers(options.headers);
+ const body = serializeBody(options.body);
+ if (body !== undefined && !(body instanceof FormData) && !headers.has('Content-Type')) {
+ headers.set('Content-Type', 'application/json');
+ }
+ if (options.auth !== false && accessToken !== null) {
+ headers.set('Authorization', `Bearer ${accessToken}`);
+ }
+
+ const response = await fetch(buildUrl(path), {
+ method: options.method ?? 'GET',
+ headers,
+ body,
+ signal: options.signal,
+ });
+
+ if (response.status === 401 && allowRefresh && options.auth !== false) {
+ const refreshed = await refreshSession();
+ if (refreshed) {
+ return send(path, options, false);
+ }
+ }
+
+ if (!response.ok) {
+ throw await toApiError(response);
+ }
+ if (response.status === 204) {
+ return undefined as T;
+ }
+ return (await response.json()) as T;
+}
+
+/** 业务请求入口:自动注入 token、401 单飞刷新、统一错误体解析 */
+export function request(path: string, options: RequestOptions = {}): Promise {
+ return send(path, options, true);
+}
diff --git a/apps/web/src/api/generated/openapi.d.ts b/apps/web/src/api/generated/openapi.d.ts
new file mode 100644
index 0000000..371a9b8
--- /dev/null
+++ b/apps/web/src/api/generated/openapi.d.ts
@@ -0,0 +1,165 @@
+/**
+ * This file was auto-generated by openapi-typescript.
+ * Do not make direct changes to the file.
+ */
+
+export interface paths {
+ '/health': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 健康检查
+ * @description 真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。
+ */
+ get: operations['HealthController_check'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+}
+export type webhooks = Record;
+export interface components {
+ schemas: {
+ ComponentHealthDto: {
+ /**
+ * @example up
+ * @enum {string}
+ */
+ status: 'up' | 'down';
+ /**
+ * @description 检查耗时(毫秒)
+ * @example 3
+ */
+ latencyMs: number;
+ /** @description 失败原因(仅 down 时返回) */
+ error?: string;
+ };
+ HealthChecksDto: {
+ database: components['schemas']['ComponentHealthDto'];
+ redis: components['schemas']['ComponentHealthDto'];
+ };
+ HealthResponseDto: {
+ /**
+ * @example ok
+ * @enum {string}
+ */
+ status: 'ok' | 'degraded';
+ /** @example development */
+ env: string;
+ /** @example 0.1.0 */
+ version: string;
+ /**
+ * @description 进程运行秒数
+ * @example 128
+ */
+ uptimeSeconds: number;
+ checks: components['schemas']['HealthChecksDto'];
+ /** @example 2026-11-02T08:00:00.000Z */
+ timestamp: string;
+ };
+ ErrorResponseDto: {
+ /**
+ * @description 错误码(见 ErrorCode 常量)
+ * @example 42202
+ */
+ code: number;
+ /**
+ * @description 可直接展示给用户的提示
+ * @example 内容触发合规拦截
+ */
+ message: string;
+ /**
+ * @description 补充信息(字段错误、命中规则等),结构由错误码决定
+ * @example {
+ * "issues": [
+ * {
+ * "path": "name",
+ * "message": "Required"
+ * }
+ * ]
+ * }
+ */
+ details?: Record;
+ /**
+ * @description 请求追踪 ID,与日志同源
+ * @example 01JABCDEF0123456789ABCDEFG
+ */
+ trace_id: string;
+ };
+ /**
+ * @description 业务错误码(只允许使用 @lingxi/shared 中登记的错误码)
+ * @enum {integer}
+ */
+ ErrorCode:
+ | 40001
+ | 40101
+ | 40102
+ | 40103
+ | 40301
+ | 40302
+ | 40303
+ | 40304
+ | 40401
+ | 40901
+ | 40902
+ | 40903
+ | 42201
+ | 42202
+ | 42203
+ | 42901
+ | 42902
+ | 50001
+ | 50002
+ | 50003;
+ ErrorResponse: {
+ code: components['schemas']['ErrorCode'];
+ message: string;
+ /** @description 补充信息,结构由错误码决定 */
+ details?: unknown;
+ trace_id: string;
+ };
+ };
+ responses: never;
+ parameters: never;
+ requestBodies: never;
+ headers: never;
+ pathItems: never;
+}
+export type $defs = Record;
+export interface operations {
+ HealthController_check: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description 全部依赖健康 */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['HealthResponseDto'];
+ };
+ };
+ /** @description 依赖不可用(错误码 50002),details 内附各依赖检查结果 */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+}
diff --git a/apps/web/src/api/query-client.ts b/apps/web/src/api/query-client.ts
new file mode 100644
index 0000000..8fee4a6
--- /dev/null
+++ b/apps/web/src/api/query-client.ts
@@ -0,0 +1,37 @@
+import { notifyError } from '@lingxi/ui';
+import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
+
+import { ApiError } from './client';
+
+function reportError(error: unknown): void {
+ if (error instanceof ApiError) {
+ if (error.status === 401) {
+ return;
+ }
+ notifyError(error.message, error.traceId);
+ return;
+ }
+ notifyError(error instanceof Error ? error.message : '网络请求失败,请稍后重试');
+}
+
+function shouldRetry(failureCount: number, error: unknown): boolean {
+ if (failureCount >= 2) {
+ return false;
+ }
+ if (error instanceof ApiError) {
+ return error.status >= 500 || error.status === 429;
+ }
+ return true;
+}
+
+export const queryClient = new QueryClient({
+ queryCache: new QueryCache({ onError: reportError }),
+ mutationCache: new MutationCache({ onError: reportError }),
+ defaultOptions: {
+ queries: {
+ retry: shouldRetry,
+ refetchOnWindowFocus: false,
+ staleTime: 30_000,
+ },
+ },
+});
diff --git a/apps/web/src/layout/AppLayout.tsx b/apps/web/src/layout/AppLayout.tsx
new file mode 100644
index 0000000..bc45d99
--- /dev/null
+++ b/apps/web/src/layout/AppLayout.tsx
@@ -0,0 +1,58 @@
+import { PageSkeleton } from '@lingxi/ui';
+import type { ReactNode } from 'react';
+import { Suspense, useMemo, useState } from 'react';
+import { Navigate, Outlet, useLocation } from 'react-router';
+
+import { filterNavByPermissions, findActiveNav, NAV_MODULES } from '@/navigation/nav-config';
+import { ROUTES } from '@/routes';
+import { useAuthStore } from '@/stores/auth-store';
+
+import { PageBreadcrumbs } from './PageBreadcrumbs';
+import { SideNav } from './SideNav';
+import { TopBar } from './TopBar';
+
+export function AppLayout(): ReactNode {
+ const location = useLocation();
+ const permissions = useAuthStore((state) => state.permissions);
+ const [collapsed, setCollapsed] = useState(false);
+
+ const modules = useMemo(
+ () => filterNavByPermissions(NAV_MODULES, new Set(permissions)),
+ [permissions],
+ );
+ const active = useMemo(
+ () => findActiveNav(modules, location.pathname),
+ [modules, location.pathname],
+ );
+ const activeModule = active?.module ?? modules[0];
+
+ if (!activeModule) {
+ return ;
+ }
+
+ return (
+
+
+
+
{
+ setCollapsed((value) => !value);
+ }}
+ />
+
+
+
+ }>
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/layout/PageBreadcrumbs.tsx b/apps/web/src/layout/PageBreadcrumbs.tsx
new file mode 100644
index 0000000..c672137
--- /dev/null
+++ b/apps/web/src/layout/PageBreadcrumbs.tsx
@@ -0,0 +1,33 @@
+import type { ReactNode } from 'react';
+import { Link } from 'react-router';
+
+import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
+
+export interface PageBreadcrumbsProps {
+ pathname: string;
+}
+
+export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
+ const entries = getBreadcrumbs(NAV_MODULES, pathname);
+ return (
+
+ );
+}
diff --git a/apps/web/src/layout/SideNav.tsx b/apps/web/src/layout/SideNav.tsx
new file mode 100644
index 0000000..ee1fa55
--- /dev/null
+++ b/apps/web/src/layout/SideNav.tsx
@@ -0,0 +1,59 @@
+import { IconBack, IconMenu } from '@lingxi/ui/icons';
+import type { ReactNode } from 'react';
+import { NavLink } from 'react-router';
+
+import type { NavModule } from '@/navigation/nav-config';
+
+export interface SideNavProps {
+ module: NavModule;
+ collapsed: boolean;
+ onToggle: () => void;
+}
+
+export function SideNav({ module, collapsed, onToggle }: SideNavProps): ReactNode {
+ return (
+
+ );
+}
diff --git a/apps/web/src/layout/TopBar.tsx b/apps/web/src/layout/TopBar.tsx
new file mode 100644
index 0000000..319e1eb
--- /dev/null
+++ b/apps/web/src/layout/TopBar.tsx
@@ -0,0 +1,143 @@
+import { IconChevronDown, IconLogout, IconNotification, IconSearch } from '@lingxi/ui/icons';
+import { type ReactNode, useEffect, useRef, useState } from 'react';
+import { useLocation, useNavigate } from 'react-router';
+
+import type { NavModule } from '@/navigation/nav-config';
+import { ROUTES } from '@/routes';
+import { useAuthStore } from '@/stores/auth-store';
+
+export interface TopBarProps {
+ modules: readonly NavModule[];
+ activeModuleKey: string;
+}
+
+function ModuleTabs({
+ modules,
+ activeModuleKey,
+}: {
+ modules: readonly NavModule[];
+ activeModuleKey: string;
+}): ReactNode {
+ const navigate = useNavigate();
+ return (
+
+ );
+}
+
+function UserMenu(): ReactNode {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const user = useAuthStore((state) => state.user);
+ const clearSession = useAuthStore((state) => state.clearSession);
+ const [openPath, setOpenPath] = useState(null);
+ const menuRef = useRef(null);
+ const open = openPath === location.pathname;
+
+ useEffect(() => {
+ const onPointerDown = (event: MouseEvent): void => {
+ if (menuRef.current !== null && !menuRef.current.contains(event.target as Node)) {
+ setOpenPath(null);
+ }
+ };
+ document.addEventListener('mousedown', onPointerDown);
+ return () => {
+ document.removeEventListener('mousedown', onPointerDown);
+ };
+ }, []);
+
+ return (
+
+
+ {
+ clearSession();
+ void navigate(ROUTES.login, { replace: true });
+ }}
+ />
+
+ );
+}
+
+function UserMenuDropdown({
+ open,
+ email,
+ onLogout,
+}: {
+ open: boolean;
+ email: string;
+ onLogout: () => void;
+}): ReactNode {
+ if (!open) {
+ return null;
+ }
+ return (
+
+ );
+}
+
+export function TopBar({ modules, activeModuleKey }: TopBarProps): ReactNode {
+ return (
+
+
+ 灵
+
+ 灵犀
+ 素材生产平台
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
new file mode 100644
index 0000000..b2721a0
--- /dev/null
+++ b/apps/web/src/main.tsx
@@ -0,0 +1,39 @@
+import '@douyinfe/semi-ui-19/react19-adapter';
+import '@lingxi/ui/semi.css';
+import '@lingxi/ui/theme/tokens.css';
+import '@lingxi/ui/styles.css';
+import '@/styles/global.css';
+import '@/styles/layout.css';
+import '@/styles/pages.css';
+
+import { LocaleProvider } from '@douyinfe/semi-ui-19';
+import { localeZhCN } from '@lingxi/ui';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { RouterProvider } from 'react-router';
+
+import { setUnauthorizedHandler } from '@/api/client';
+import { queryClient } from '@/api/query-client';
+import { router } from '@/router';
+import { ROUTES } from '@/routes';
+
+setUnauthorizedHandler(() => {
+ void router.navigate(ROUTES.login, { replace: true });
+});
+
+const container = document.getElementById('root');
+
+if (container === null) {
+ throw new Error('找不到应用根节点 #root');
+}
+
+createRoot(container).render(
+
+
+
+
+
+
+ ,
+);
diff --git a/apps/web/src/navigation/nav-config.spec.ts b/apps/web/src/navigation/nav-config.spec.ts
new file mode 100644
index 0000000..d707a8f
--- /dev/null
+++ b/apps/web/src/navigation/nav-config.spec.ts
@@ -0,0 +1,41 @@
+import type { PermissionCode } from '@lingxi/shared';
+import { describe, expect, it } from 'vitest';
+
+import { filterNavByPermissions, findActiveNav, getBreadcrumbs, NAV_MODULES } from './nav-config';
+
+describe('导航配置', () => {
+ it('拥有全部权限时展示全部模块与菜单', () => {
+ const permissions = new Set(
+ NAV_MODULES.flatMap((module) => module.items.map((item) => item.permission)).filter(
+ (code): code is PermissionCode => code !== undefined,
+ ),
+ );
+ const modules = filterNavByPermissions(NAV_MODULES, permissions);
+ expect(modules.map((module) => module.key)).toEqual(NAV_MODULES.map((module) => module.key));
+ });
+
+ it('缺少权限点时过滤菜单,模块没有子菜单时整组隐藏', () => {
+ const modules = filterNavByPermissions(NAV_MODULES, new Set(['script:read']));
+ const script = modules.find((module) => module.key === 'script');
+ expect(script?.items.map((item) => item.key)).toEqual(['library']);
+ expect(modules.some((module) => module.key === 'platform')).toBe(false);
+ });
+
+ it('只有无权限要求的菜单时仍然可见', () => {
+ const modules = filterNavByPermissions(NAV_MODULES, new Set());
+ expect(modules.map((module) => module.key)).toEqual(['dashboard']);
+ });
+
+ it('按路径定位当前模块与菜单', () => {
+ const active = findActiveNav(NAV_MODULES, '/material/quick-review');
+ expect(active?.module.key).toBe('material');
+ expect(active?.item?.label).toBe('快筛');
+ expect(findActiveNav(NAV_MODULES, '/not-exists')).toBeNull();
+ });
+
+ it('面包屑从导航配置派生', () => {
+ expect(getBreadcrumbs(NAV_MODULES, '/')).toEqual([{ key: 'home', label: '工作台', path: '/' }]);
+ const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/platform/users');
+ expect(breadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '平台', '用户与组织']);
+ });
+});
diff --git a/apps/web/src/navigation/nav-config.ts b/apps/web/src/navigation/nav-config.ts
new file mode 100644
index 0000000..fb4d82a
--- /dev/null
+++ b/apps/web/src/navigation/nav-config.ts
@@ -0,0 +1,410 @@
+import { Permission, type PermissionCode } from '@lingxi/shared';
+import {
+ IconAsset,
+ IconCheck,
+ IconClock,
+ IconCopy,
+ IconDelivery,
+ IconDocs,
+ IconExport,
+ IconFilter,
+ IconGlobal,
+ IconGrid,
+ IconHome,
+ IconImageAdd,
+ IconMaterial,
+ IconPlay,
+ IconRobot,
+ IconScript,
+ IconSettings,
+ IconShield,
+ IconStack,
+ IconTask,
+ IconUser,
+ IconVideo,
+ IconView,
+} from '@lingxi/ui/icons';
+import type { ComponentType } from 'react';
+
+export type NavIcon = ComponentType<{ size?: number | string; className?: string }>;
+
+export interface NavItem {
+ key: string;
+ label: string;
+ path: string;
+ icon: NavIcon;
+ /** 未配置权限点的菜单对所有登录用户可见 */
+ permission?: PermissionCode;
+ description?: string;
+}
+
+export interface NavModule {
+ key: string;
+ label: string;
+ path: string;
+ icon: NavIcon;
+ permission?: PermissionCode;
+ items: NavItem[];
+}
+
+/**
+ * 顶部模块菜单 + 左侧功能菜单的唯一配置源(A0-FE-03)。
+ * 新增页面时在这里登记;侧边栏与面包屑都从这里派生,禁止在页面内写死导航。
+ */
+export const NAV_MODULES: readonly NavModule[] = [
+ {
+ key: 'dashboard',
+ label: '工作台',
+ path: '/',
+ icon: IconHome,
+ items: [
+ {
+ key: 'overview',
+ label: '工作台',
+ path: '/',
+ icon: IconHome,
+ description: '今天的生产任务、待审核脚本与最近素材',
+ },
+ ],
+ },
+ {
+ key: 'script',
+ label: '脚本',
+ path: '/scripts/library',
+ icon: IconScript,
+ permission: Permission.SCRIPT_READ,
+ items: [
+ {
+ key: 'library',
+ label: '脚本库',
+ path: '/scripts/library',
+ icon: IconDocs,
+ permission: Permission.SCRIPT_READ,
+ description: '按客户、行业与状态检索已生成脚本',
+ },
+ {
+ key: 'framework-cards',
+ label: '要素卡',
+ path: '/scripts/framework-cards',
+ icon: IconTask,
+ permission: Permission.SCRIPT_WRITE,
+ description: '维护行业包的要素卡字段与填写规则',
+ },
+ {
+ key: 'templates',
+ label: '脚本模板',
+ path: '/scripts/templates',
+ icon: IconStack,
+ permission: Permission.SCRIPT_WRITE,
+ description: '结构模板、镜头模板与提示词模板',
+ },
+ {
+ key: 'batch',
+ label: '批量生成',
+ path: '/scripts/batch',
+ icon: IconRobot,
+ permission: Permission.SCRIPT_WRITE,
+ description: '按要素卡批量生成脚本并排队审核',
+ },
+ {
+ key: 'review',
+ label: '脚本审核',
+ path: '/scripts/review',
+ icon: IconCheck,
+ permission: Permission.SCRIPT_REVIEW,
+ description: '审核生成结果,标记低质量脚本',
+ },
+ ],
+ },
+ {
+ key: 'material',
+ label: '素材',
+ path: '/material/image-compose',
+ icon: IconMaterial,
+ permission: Permission.MATERIAL_READ,
+ items: [
+ {
+ key: 'image-compose',
+ label: '图片合成',
+ path: '/material/image-compose',
+ icon: IconImageAdd,
+ permission: Permission.MATERIAL_RENDER,
+ description: 'PSD 套版、抠图与确定性合成',
+ },
+ {
+ key: 'video',
+ label: '视频生产',
+ path: '/material/video',
+ icon: IconVideo,
+ permission: Permission.MATERIAL_RENDER,
+ description: '逐镜生成、合成与成片预览',
+ },
+ {
+ key: 'tasks',
+ label: '生产任务',
+ path: '/material/tasks',
+ icon: IconTask,
+ permission: Permission.MATERIAL_READ,
+ description: '任务进度、失败原因与重试',
+ },
+ {
+ key: 'quick-review',
+ label: '快筛',
+ path: '/material/quick-review',
+ icon: IconFilter,
+ permission: Permission.MATERIAL_REVIEW,
+ description: '按快捷键快速筛选候选素材',
+ },
+ {
+ key: 'preview',
+ label: '成片预览',
+ path: '/material/preview',
+ icon: IconPlay,
+ permission: Permission.MATERIAL_READ,
+ description: '播放成片并核对规格与标识',
+ },
+ ],
+ },
+ {
+ key: 'asset',
+ label: '资产',
+ path: '/assets/library',
+ icon: IconAsset,
+ permission: Permission.ASSET_READ,
+ items: [
+ {
+ key: 'library',
+ label: '资产库',
+ path: '/assets/library',
+ icon: IconAsset,
+ permission: Permission.ASSET_READ,
+ description: '图片、视频、音频与模板资产',
+ },
+ {
+ key: 'templates',
+ label: '模板库',
+ path: '/assets/templates',
+ icon: IconCopy,
+ permission: Permission.ASSET_READ,
+ description: 'PSD 模板与版式配置',
+ },
+ {
+ key: 'pools',
+ label: '人物与场景',
+ path: '/assets/pools',
+ icon: IconUser,
+ permission: Permission.ASSET_READ,
+ description: '人物、场景与风格池',
+ },
+ {
+ key: 'prompts',
+ label: '提示词模板',
+ path: '/assets/prompts',
+ icon: IconRobot,
+ permission: Permission.ASSET_READ,
+ description: '视频与图片生成的提示词版本',
+ },
+ ],
+ },
+ {
+ key: 'delivery',
+ label: '交付',
+ path: '/delivery/export',
+ icon: IconDelivery,
+ permission: Permission.DELIVERY_READ,
+ items: [
+ {
+ key: 'export',
+ label: '导出任务',
+ path: '/delivery/export',
+ icon: IconExport,
+ permission: Permission.DELIVERY_EXPORT,
+ description: '勾选素材导出并按批次归档',
+ },
+ {
+ key: 'records',
+ label: '导出记录',
+ path: '/delivery/records',
+ icon: IconClock,
+ permission: Permission.DELIVERY_READ,
+ description: '导出批次、签名链接与归档状态',
+ },
+ {
+ key: 'manifests',
+ label: '交付清单',
+ path: '/delivery/manifests',
+ icon: IconTask,
+ permission: Permission.DELIVERY_READ,
+ description: '交付清单、客户签收与留痕',
+ },
+ ],
+ },
+ {
+ key: 'platform',
+ label: '平台',
+ path: '/platform/users',
+ icon: IconSettings,
+ permission: Permission.USER_MANAGE,
+ items: [
+ {
+ key: 'users',
+ label: '用户与组织',
+ path: '/platform/users',
+ icon: IconUser,
+ permission: Permission.USER_MANAGE,
+ description: '账号、组织与登录状态',
+ },
+ {
+ key: 'roles',
+ label: '角色权限',
+ path: '/platform/roles',
+ icon: IconShield,
+ permission: Permission.ROLE_MANAGE,
+ description: '角色、权限点与职责互斥',
+ },
+ {
+ key: 'customers',
+ label: '客户',
+ path: '/platform/customers',
+ icon: IconGlobal,
+ permission: Permission.CUSTOMER_MANAGE,
+ description: '客户、行业归属与数据范围',
+ },
+ {
+ key: 'industries',
+ label: '行业树',
+ path: '/platform/industries',
+ icon: IconStack,
+ permission: Permission.INDUSTRY_MANAGE,
+ description: '行业分类树与行业包绑定',
+ },
+ {
+ key: 'dicts',
+ label: '字典',
+ path: '/platform/dicts',
+ icon: IconDocs,
+ permission: Permission.DICT_MANAGE,
+ description: '核心字典与业务枚举',
+ },
+ {
+ key: 'audit',
+ label: '审计日志',
+ path: '/platform/audit',
+ icon: IconView,
+ permission: Permission.AUDIT_READ,
+ description: '关键操作留痕与导出记录',
+ },
+ {
+ key: 'ai',
+ label: '模型接入',
+ path: '/platform/ai',
+ icon: IconRobot,
+ permission: Permission.AI_CONFIGURE,
+ description: '供应商、路由与预算阈值',
+ },
+ {
+ key: 'settings',
+ label: '系统配置',
+ path: '/platform/settings',
+ icon: IconSettings,
+ permission: Permission.SYSTEM_CONFIGURE,
+ description: '阈值、上限与功能开关',
+ },
+ {
+ key: 'industry-packs',
+ label: '行业包',
+ path: '/platform/industry-packs',
+ icon: IconGrid,
+ permission: Permission.INDUSTRY_MANAGE,
+ description: '行业包版本、发布与灰度',
+ },
+ {
+ key: 'forms',
+ label: '表单设计器',
+ path: '/platform/forms',
+ icon: IconGrid,
+ permission: Permission.FORM_DESIGN,
+ description: '受控字段白名单与表单布局',
+ },
+ {
+ key: 'customer-fields',
+ label: '客户字段',
+ path: '/platform/customer-fields',
+ icon: IconTask,
+ permission: Permission.CUSTOMER_MANAGE,
+ description: '客户级扩展字段与审批',
+ },
+ {
+ key: 'compliance',
+ label: '合规规则',
+ path: '/platform/compliance',
+ icon: IconShield,
+ permission: Permission.SYSTEM_CONFIGURE,
+ description: '禁忌词、极限词与合规规则集',
+ },
+ ],
+ },
+];
+
+export function filterNavByPermissions(
+ modules: readonly NavModule[],
+ permissions: ReadonlySet,
+): NavModule[] {
+ return modules
+ .map((module) => {
+ if (module.permission && !permissions.has(module.permission)) {
+ return null;
+ }
+ const items = module.items.filter(
+ (item) => item.permission === undefined || permissions.has(item.permission),
+ );
+ return items.length > 0 ? { ...module, items } : null;
+ })
+ .filter((module): module is NavModule => module !== null);
+}
+
+export function findNavModule(
+ modules: readonly NavModule[],
+ moduleKey: string,
+): NavModule | undefined {
+ return modules.find((module) => module.key === moduleKey);
+}
+
+export interface ActiveNav {
+ module: NavModule;
+ item: NavItem | undefined;
+}
+
+export function findActiveNav(modules: readonly NavModule[], pathname: string): ActiveNav | null {
+ const normalized = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname;
+ for (const module of modules) {
+ const item = module.items.find((candidate) => candidate.path === normalized);
+ if (item) {
+ return { module, item };
+ }
+ }
+ return null;
+}
+
+export interface BreadcrumbEntry {
+ key: string;
+ label: string;
+ path?: string;
+}
+
+export function getBreadcrumbs(modules: readonly NavModule[], pathname: string): BreadcrumbEntry[] {
+ const active = findActiveNav(modules, pathname);
+ if (!active) {
+ return [{ key: 'home', label: '工作台', path: '/' }];
+ }
+ const entries: BreadcrumbEntry[] = [{ key: 'home', label: '工作台', path: '/' }];
+ if (active.module.key !== 'dashboard') {
+ entries.push({ key: active.module.key, label: active.module.label });
+ }
+ if (active.item && active.item.path !== '/' && active.item.key !== active.module.items[0]?.key) {
+ entries.push({ key: active.item.key, label: active.item.label, path: active.item.path });
+ } else if (active.item && active.item.path !== '/') {
+ entries.push({ key: active.item.key, label: active.item.label, path: active.item.path });
+ }
+ return entries;
+}
diff --git a/apps/web/src/pages/errors/ErrorLayout.tsx b/apps/web/src/pages/errors/ErrorLayout.tsx
new file mode 100644
index 0000000..18f0b6e
--- /dev/null
+++ b/apps/web/src/pages/errors/ErrorLayout.tsx
@@ -0,0 +1,40 @@
+import { IconAlert } from '@lingxi/ui/icons';
+import type { ReactNode } from 'react';
+
+export interface ErrorLayoutProps {
+ code: string;
+ title: string;
+ description: string;
+ traceId?: string;
+ actions?: ReactNode;
+}
+
+export function ErrorLayout({
+ code,
+ title,
+ description,
+ traceId,
+ actions,
+}: ErrorLayoutProps): ReactNode {
+ return (
+
+
+ {code}
+
+
+
+
+
+
灵犀 · 访问异常
+
{title}
+
{description}
+ {traceId !== undefined ? (
+
+ trace_id:{traceId}
+
+ ) : null}
+
{actions}
+
+
+ );
+}
diff --git a/apps/web/src/pages/errors/ForbiddenPage.tsx b/apps/web/src/pages/errors/ForbiddenPage.tsx
new file mode 100644
index 0000000..866aad9
--- /dev/null
+++ b/apps/web/src/pages/errors/ForbiddenPage.tsx
@@ -0,0 +1,38 @@
+import { Button } from '@douyinfe/semi-ui-19';
+import type { ReactNode } from 'react';
+import { useNavigate } from 'react-router';
+
+import { ROUTES } from '@/routes';
+
+import { ErrorLayout } from './ErrorLayout';
+
+export function ForbiddenPage(): ReactNode {
+ const navigate = useNavigate();
+ return (
+
+
+
+ >
+ }
+ />
+ );
+}
diff --git a/apps/web/src/pages/errors/NotFoundPage.tsx b/apps/web/src/pages/errors/NotFoundPage.tsx
new file mode 100644
index 0000000..5a969ad
--- /dev/null
+++ b/apps/web/src/pages/errors/NotFoundPage.tsx
@@ -0,0 +1,29 @@
+import { Button } from '@douyinfe/semi-ui-19';
+import type { ReactNode } from 'react';
+import { useNavigate } from 'react-router';
+
+import { ROUTES } from '@/routes';
+
+import { ErrorLayout } from './ErrorLayout';
+
+export function NotFoundPage(): ReactNode {
+ const navigate = useNavigate();
+ return (
+ {
+ void navigate(ROUTES.home);
+ }}
+ >
+ 返回工作台
+
+ }
+ />
+ );
+}
diff --git a/apps/web/src/pages/errors/ServerErrorPage.tsx b/apps/web/src/pages/errors/ServerErrorPage.tsx
new file mode 100644
index 0000000..45f3a67
--- /dev/null
+++ b/apps/web/src/pages/errors/ServerErrorPage.tsx
@@ -0,0 +1,41 @@
+import { Button } from '@douyinfe/semi-ui-19';
+import type { ReactNode } from 'react';
+import { useNavigate, useSearchParams } from 'react-router';
+
+import { ROUTES } from '@/routes';
+
+import { ErrorLayout } from './ErrorLayout';
+
+export function ServerErrorPage(): ReactNode {
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const traceId = searchParams.get('trace_id') ?? undefined;
+ return (
+
+
+
+ >
+ }
+ />
+ );
+}
diff --git a/apps/web/src/pages/errors/UnauthorizedPage.tsx b/apps/web/src/pages/errors/UnauthorizedPage.tsx
new file mode 100644
index 0000000..844c208
--- /dev/null
+++ b/apps/web/src/pages/errors/UnauthorizedPage.tsx
@@ -0,0 +1,29 @@
+import { Button } from '@douyinfe/semi-ui-19';
+import type { ReactNode } from 'react';
+import { useNavigate } from 'react-router';
+
+import { ROUTES } from '@/routes';
+
+import { ErrorLayout } from './ErrorLayout';
+
+export function UnauthorizedPage(): ReactNode {
+ const navigate = useNavigate();
+ return (
+ {
+ void navigate(ROUTES.login);
+ }}
+ >
+ 去登录
+
+ }
+ />
+ );
+}
diff --git a/apps/web/src/pages/home/HomePage.tsx b/apps/web/src/pages/home/HomePage.tsx
new file mode 100644
index 0000000..2552f98
--- /dev/null
+++ b/apps/web/src/pages/home/HomePage.tsx
@@ -0,0 +1,373 @@
+import { Button } from '@douyinfe/semi-ui-19';
+import {
+ IconChart,
+ IconChevronRight,
+ IconClock,
+ IconDownload,
+ IconExport,
+ IconImageAdd,
+ IconPlay,
+ IconScript,
+ IconTask,
+} from '@lingxi/ui/icons';
+import type { ReactNode } from 'react';
+import { useNavigate } from 'react-router';
+
+import { useAuthStore } from '@/stores/auth-store';
+
+interface ProductionTask {
+ id: string;
+ name: string;
+ kind: string;
+ progress: number;
+ status: 'running' | 'review' | 'done';
+ updatedAt: string;
+}
+
+const TASKS: readonly ProductionTask[] = [
+ {
+ id: 'task-1',
+ name: '夏日防晒 · 图片合成批次',
+ kind: '图片合成',
+ progress: 68,
+ status: 'running',
+ updatedAt: '12 分钟前',
+ },
+ {
+ id: 'task-2',
+ name: '通勤穿搭 · 视频逐镜生成',
+ kind: '视频生产',
+ progress: 42,
+ status: 'running',
+ updatedAt: '26 分钟前',
+ },
+ {
+ id: 'task-3',
+ name: '会员日 · 脚本批量生成',
+ kind: '脚本',
+ progress: 100,
+ status: 'review',
+ updatedAt: '1 小时前',
+ },
+ {
+ id: 'task-4',
+ name: '春季新品 · 快筛候选集',
+ kind: '快筛',
+ progress: 100,
+ status: 'done',
+ updatedAt: '今天 10:24',
+ },
+];
+
+const STATUS_LABEL: Record = {
+ running: '进行中',
+ review: '待审核',
+ done: '已完成',
+};
+
+const QUICK_ACTIONS = [
+ {
+ key: 'script',
+ label: '新建脚本',
+ hint: '从要素卡开始',
+ path: '/scripts/batch',
+ icon: IconScript,
+ },
+ {
+ key: 'image',
+ label: '图片合成',
+ hint: 'PSD 套版与抠图',
+ path: '/material/image-compose',
+ icon: IconImageAdd,
+ },
+ {
+ key: 'video',
+ label: '视频生产',
+ hint: '逐镜生成成片',
+ path: '/material/video',
+ icon: IconPlay,
+ },
+ {
+ key: 'export',
+ label: '导出素材',
+ hint: '勾选后导出并归档',
+ path: '/delivery/export',
+ icon: IconExport,
+ },
+] as const;
+
+const RECENT_ASSETS = [
+ { id: 'a1', name: '防晒喷雾 · 主图 01', spec: '1080 × 1920', tone: 1 },
+ { id: 'a2', name: '通勤穿搭 · 封面', spec: '1080 × 1920', tone: 2 },
+ { id: 'a3', name: '会员日 · 利益点', spec: '1080 × 1920', tone: 3 },
+ { id: 'a4', name: '春季新品 · 场景 A', spec: '1080 × 1920', tone: 4 },
+ { id: 'a5', name: '防晒喷雾 · 细节', spec: '1080 × 1080', tone: 5 },
+ { id: 'a6', name: '通勤穿搭 · 结尾卡', spec: '1080 × 1920', tone: 6 },
+] as const;
+
+function HomeHero({ userName }: { userName: string }): ReactNode {
+ const navigate = useNavigate();
+ return (
+
+ );
+}
+
+const METRICS = [
+ {
+ key: 'output',
+ label: '今日产出',
+ value: '128',
+ unit: '条',
+ foot: '较昨日 +12.4%',
+ trend: '+12.4%',
+ icon: IconChart,
+ },
+ {
+ key: 'running',
+ label: '进行中任务',
+ value: '6',
+ unit: '个',
+ foot: '2 个排队中 · 预计 4:12 完成',
+ trend: '',
+ icon: IconTask,
+ },
+ {
+ key: 'review',
+ label: '待审核',
+ value: '24',
+ unit: '条',
+ foot: '8 条已超 2 小时',
+ trend: '',
+ icon: IconClock,
+ },
+ {
+ key: 'export',
+ label: '本周导出',
+ value: '512',
+ unit: '条',
+ foot: '较上周 +8.1%',
+ trend: '+8.1%',
+ icon: IconDownload,
+ },
+] as const;
+
+function HomeMetrics(): ReactNode {
+ return (
+
+ {METRICS.map((metric) => {
+ const Icon = metric.icon;
+ return (
+
+
+ {metric.label}
+
+
+
+
+
+ {metric.value}
+ {metric.unit}
+
+
+ {metric.trend === '' ? (
+ metric.foot
+ ) : (
+ {metric.trend}
+ )}
+ {metric.trend === '' ? null : ` · ${metric.foot}`}
+
+
+ );
+ })}
+
+ );
+}
+
+function TaskRow({ task }: { task: ProductionTask }): ReactNode {
+ return (
+
+
+ {task.name}
+
+
+ {task.kind}
+
+
+
+
+
+ {task.progress}%
+
+
+ {STATUS_LABEL[task.status]}
+
+
+ {task.updatedAt}
+
+
+ );
+}
+
+function TaskPanel(): ReactNode {
+ const navigate = useNavigate();
+ return (
+
+
+
+
+
+
+
+
+ 任务
+ 类型
+ 进度
+ 状态
+ 更新时间
+
+ {TASKS.map((task) => (
+
+ ))}
+
+
+ );
+}
+
+function QuickPanel(): ReactNode {
+ const navigate = useNavigate();
+ return (
+
+ );
+}
+
+function RecentAssetsPanel(): ReactNode {
+ const navigate = useNavigate();
+ return (
+
+
+
+
+
+
+
+ {RECENT_ASSETS.map((asset) => (
+
+ ))}
+
+
+ );
+}
+
+export default function HomePage(): ReactNode {
+ const user = useAuthStore((state) => state.user);
+ return (
+
+ );
+}
diff --git a/apps/web/src/pages/login/LoginBrandPanel.tsx b/apps/web/src/pages/login/LoginBrandPanel.tsx
new file mode 100644
index 0000000..193dbf7
--- /dev/null
+++ b/apps/web/src/pages/login/LoginBrandPanel.tsx
@@ -0,0 +1,14 @@
+import type { ReactNode } from 'react';
+
+export function LoginBrandPanel(): ReactNode {
+ return (
+
+ );
+}
diff --git a/apps/web/src/pages/login/LoginFormCard.tsx b/apps/web/src/pages/login/LoginFormCard.tsx
new file mode 100644
index 0000000..7cd111b
--- /dev/null
+++ b/apps/web/src/pages/login/LoginFormCard.tsx
@@ -0,0 +1,114 @@
+import { Button, Checkbox, Input } from '@douyinfe/semi-ui-19';
+import { IconLock, IconMail } from '@lingxi/ui/icons';
+import type { ReactNode, SyntheticEvent } from 'react';
+
+export interface LoginFormCardProps {
+ account: string;
+ password: string;
+ submitting: boolean;
+ onAccountChange: (value: string) => void;
+ onPasswordChange: (value: string) => void;
+ onSubmit: (event: SyntheticEvent) => void;
+}
+
+function LoginFormHeader(): ReactNode {
+ return (
+
+ 登录灵犀
+ 使用企业账号登录素材生产平台
+
+ );
+}
+
+function LoginFormFooter(): ReactNode {
+ return (
+
+ );
+}
+
+function LoginFields({
+ account,
+ password,
+ onAccountChange,
+ onPasswordChange,
+}: Pick<
+ LoginFormCardProps,
+ 'account' | 'password' | 'onAccountChange' | 'onPasswordChange'
+>): ReactNode {
+ return (
+ <>
+
+
+
+
+
+ 记住我(7 天)
+
+
+ >
+ );
+}
+
+export function LoginFormCard({
+ account,
+ password,
+ submitting,
+ onAccountChange,
+ onPasswordChange,
+ onSubmit,
+}: LoginFormCardProps): ReactNode {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/login/LoginPage.tsx b/apps/web/src/pages/login/LoginPage.tsx
new file mode 100644
index 0000000..fea0f3a
--- /dev/null
+++ b/apps/web/src/pages/login/LoginPage.tsx
@@ -0,0 +1,35 @@
+import type { ReactNode } from 'react';
+
+import { LoginBrandPanel } from './LoginBrandPanel';
+import { LoginFormCard } from './LoginFormCard';
+import { useLoginForm } from './use-login-form';
+
+export default function LoginPage(): ReactNode {
+ const { account, password, submitting, setAccount, setPassword, handleSubmit } = useLoginForm();
+
+ return (
+
+
+
+ {
+ void handleSubmit(event);
+ }}
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/login/use-login-form.ts b/apps/web/src/pages/login/use-login-form.ts
new file mode 100644
index 0000000..b52dbf7
--- /dev/null
+++ b/apps/web/src/pages/login/use-login-form.ts
@@ -0,0 +1,70 @@
+import { notifyError, notifyWarning } from '@lingxi/ui';
+import { type SyntheticEvent, useState } from 'react';
+import { useNavigate } from 'react-router';
+
+import { ApiError, request } from '@/api/client';
+import { ROUTES } from '@/routes';
+import type { AuthUser } from '@/stores/auth-store';
+import { useAuthStore } from '@/stores/auth-store';
+
+interface LoginResponse {
+ accessToken: string;
+ refreshToken?: string;
+ user: AuthUser;
+ permissions?: string[];
+}
+
+const demoEnabled = import.meta.env.VITE_PUBLIC_DEMO_SESSION !== 'false';
+
+export function useLoginForm(): {
+ account: string;
+ password: string;
+ submitting: boolean;
+ setAccount: (value: string) => void;
+ setPassword: (value: string) => void;
+ handleSubmit: (event: SyntheticEvent) => Promise;
+} {
+ const navigate = useNavigate();
+ const signInDemo = useAuthStore((state) => state.signInDemo);
+ const setSession = useAuthStore((state) => state.setSession);
+ const [account, setAccount] = useState('');
+ const [password, setPassword] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSubmit = async (event: SyntheticEvent): Promise => {
+ event.preventDefault();
+ if (account.trim() === '' || password === '') {
+ notifyWarning('请输入账号和密码');
+ return;
+ }
+ setSubmitting(true);
+ try {
+ if (demoEnabled) {
+ signInDemo();
+ await navigate(ROUTES.home, { replace: true });
+ return;
+ }
+ const session = await request('/auth/login', {
+ method: 'POST',
+ auth: false,
+ body: { email: account.trim(), password },
+ });
+ setSession({
+ accessToken: session.accessToken,
+ refreshToken: session.refreshToken ?? null,
+ user: session.user,
+ permissions: session.permissions,
+ });
+ await navigate(ROUTES.home, { replace: true });
+ } catch (error) {
+ notifyError(
+ error instanceof Error ? error.message : '登录失败,请稍后重试',
+ error instanceof ApiError ? error.traceId : undefined,
+ );
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return { account, password, submitting, setAccount, setPassword, handleSubmit };
+}
diff --git a/apps/web/src/pages/workspace/WorkspacePage.tsx b/apps/web/src/pages/workspace/WorkspacePage.tsx
new file mode 100644
index 0000000..32c6f19
--- /dev/null
+++ b/apps/web/src/pages/workspace/WorkspacePage.tsx
@@ -0,0 +1,63 @@
+import { PageEmpty } from '@lingxi/ui';
+import type { ReactNode } from 'react';
+import { Link, useParams } from 'react-router';
+
+import {
+ filterNavByPermissions,
+ findNavModule,
+ NAV_MODULES,
+ type NavItem,
+} from '@/navigation/nav-config';
+import { ROUTES } from '@/routes';
+import { useAuthStore } from '@/stores/auth-store';
+
+function resolvePage(
+ moduleKey: string | undefined,
+ itemKey: string | undefined,
+): { moduleLabel: string; item: NavItem } | null {
+ if (moduleKey === undefined) {
+ return null;
+ }
+ const permissions = new Set(useAuthStore.getState().permissions);
+ const modules = filterNavByPermissions(NAV_MODULES, permissions);
+ const module = findNavModule(modules, moduleKey);
+ if (module === undefined) {
+ return null;
+ }
+ const item = module.items.find((candidate) => candidate.key === itemKey) ?? module.items[0];
+ return item === undefined ? null : { moduleLabel: module.label, item };
+}
+
+export default function WorkspacePage(): ReactNode {
+ const { moduleKey, itemKey } = useParams<{ moduleKey: string; itemKey?: string }>();
+ const page = resolvePage(moduleKey, itemKey);
+
+ if (page === null) {
+ return (
+
+ 404
+ 页面不存在
+ 该功能可能已调整或你没有访问权限。
+
+ 返回工作台
+
+
+ );
+ }
+
+ return (
+
+
+
+
{page.moduleLabel}
+
{page.item.label}
+
{page.item.description}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
new file mode 100644
index 0000000..e6a3a1d
--- /dev/null
+++ b/apps/web/src/router.tsx
@@ -0,0 +1,40 @@
+import { createBrowserRouter } from 'react-router';
+
+import { AppLayout } from '@/layout/AppLayout';
+import { ForbiddenPage } from '@/pages/errors/ForbiddenPage';
+import { NotFoundPage } from '@/pages/errors/NotFoundPage';
+import { ServerErrorPage } from '@/pages/errors/ServerErrorPage';
+import { UnauthorizedPage } from '@/pages/errors/UnauthorizedPage';
+import { ROUTES } from '@/routes';
+
+export const router = createBrowserRouter([
+ {
+ path: ROUTES.login,
+ lazy: async () => ({
+ Component: (await import('@/pages/login/LoginPage')).default,
+ }),
+ },
+ {
+ path: '/',
+ element: ,
+ children: [
+ {
+ index: true,
+ lazy: async () => ({
+ Component: (await import('@/pages/home/HomePage')).default,
+ }),
+ },
+ {
+ path: ':moduleKey/:itemKey?',
+ lazy: async () => ({
+ Component: (await import('@/pages/workspace/WorkspacePage')).default,
+ }),
+ },
+ ],
+ },
+ { path: ROUTES.unauthorized, element: },
+ { path: ROUTES.forbidden, element: },
+ { path: ROUTES.notFound, element: },
+ { path: ROUTES.serverError, element: },
+ { path: '*', element: },
+]);
diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts
new file mode 100644
index 0000000..949c99e
--- /dev/null
+++ b/apps/web/src/routes.ts
@@ -0,0 +1,11 @@
+/** 前端路由常量(工程规范 04:路由集中管理,禁止散落字符串) */
+export const ROUTES = {
+ home: '/',
+ login: '/login',
+ unauthorized: '/401',
+ forbidden: '/403',
+ notFound: '/404',
+ serverError: '/500',
+} as const;
+
+export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
diff --git a/apps/web/src/stores/auth-store.ts b/apps/web/src/stores/auth-store.ts
new file mode 100644
index 0000000..0baa386
--- /dev/null
+++ b/apps/web/src/stores/auth-store.ts
@@ -0,0 +1,141 @@
+import { PERMISSION_CODES } from '@lingxi/shared';
+import { create } from 'zustand';
+
+export interface AuthUser {
+ id: string;
+ name: string;
+ email: string;
+ roleName?: string;
+}
+
+export interface AuthSession {
+ accessToken: string | null;
+ refreshToken: string | null;
+ user: AuthUser | null;
+ permissions: string[];
+}
+
+export interface SetSessionInput {
+ accessToken: string;
+ refreshToken?: string | null;
+ user: AuthUser;
+ permissions?: readonly string[];
+}
+
+export interface AuthState extends AuthSession {
+ setSession: (input: SetSessionInput) => void;
+ setTokens: (tokens: { accessToken: string; refreshToken?: string | null }) => void;
+ clearSession: () => void;
+ signInDemo: () => void;
+ hasPermission: (code: string) => boolean;
+}
+
+const STORAGE_KEY = 'lingxi.auth.session';
+
+const EMPTY_SESSION: AuthSession = {
+ accessToken: null,
+ refreshToken: null,
+ user: null,
+ permissions: [],
+};
+
+export const DEMO_USER: AuthUser = {
+ id: '01J0DEMO000000000000000000',
+ name: '灵犀管理员',
+ email: 'admin@lingxi.local',
+ roleName: '平台管理员',
+};
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
+function isAuthUser(value: unknown): value is AuthUser {
+ return (
+ isRecord(value) &&
+ typeof value.id === 'string' &&
+ typeof value.name === 'string' &&
+ typeof value.email === 'string'
+ );
+}
+
+function isAuthSession(value: unknown): value is AuthSession {
+ return (
+ isRecord(value) &&
+ (typeof value.accessToken === 'string' || value.accessToken === null) &&
+ (typeof value.refreshToken === 'string' || value.refreshToken === null) &&
+ (isAuthUser(value.user) || value.user === null) &&
+ Array.isArray(value.permissions) &&
+ value.permissions.every((permission) => typeof permission === 'string')
+ );
+}
+
+function readPersistedSession(): AuthSession {
+ if (typeof window === 'undefined') {
+ return EMPTY_SESSION;
+ }
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (raw === null) {
+ return EMPTY_SESSION;
+ }
+ const parsed: unknown = JSON.parse(raw);
+ return isAuthSession(parsed) ? parsed : EMPTY_SESSION;
+ } catch {
+ return EMPTY_SESSION;
+ }
+}
+
+function persistSession(session: AuthSession): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ if (session.accessToken === null) {
+ window.localStorage.removeItem(STORAGE_KEY);
+ return;
+ }
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
+ } catch {
+ // 本地存储不可用(隐私模式等)时退化为内存会话,不影响登录流程
+ }
+}
+
+export const useAuthStore = create()((set, get) => ({
+ ...readPersistedSession(),
+ setSession: (input) => {
+ const session: AuthSession = {
+ accessToken: input.accessToken,
+ refreshToken: input.refreshToken ?? null,
+ user: input.user,
+ permissions: [...(input.permissions ?? PERMISSION_CODES)],
+ };
+ persistSession(session);
+ set(session);
+ },
+ setTokens: (tokens) => {
+ const current = get();
+ const session: AuthSession = {
+ ...current,
+ accessToken: tokens.accessToken,
+ refreshToken: tokens.refreshToken ?? current.refreshToken,
+ };
+ persistSession(session);
+ set({ accessToken: session.accessToken, refreshToken: session.refreshToken });
+ },
+ clearSession: () => {
+ persistSession(EMPTY_SESSION);
+ set(EMPTY_SESSION);
+ },
+ signInDemo: () => {
+ const session: AuthSession = {
+ accessToken: 'demo-access-token',
+ refreshToken: 'demo-refresh-token',
+ user: DEMO_USER,
+ permissions: [...PERMISSION_CODES],
+ };
+ persistSession(session);
+ set(session);
+ },
+ hasPermission: (code) => get().permissions.includes(code),
+}));
diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css
new file mode 100644
index 0000000..b26928f
--- /dev/null
+++ b/apps/web/src/styles/global.css
@@ -0,0 +1,284 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ font-family: var(--lx-font-sans);
+ font-size: var(--lx-font-size-md);
+ line-height: var(--lx-line-height-normal);
+ color: var(--lx-color-text-primary);
+ background: var(--lx-color-bg-app);
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+
+h1,
+h2,
+h3,
+h4,
+p {
+ margin: 0;
+}
+
+button,
+input,
+textarea,
+select {
+ font: inherit;
+ color: inherit;
+}
+
+button {
+ padding: 0;
+ border: none;
+ background: none;
+ cursor: pointer;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+ul,
+ol {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+::selection {
+ color: #ffffff;
+ background: var(--lx-color-brand);
+}
+
+:focus-visible {
+ outline: 2px solid var(--lx-color-focus);
+ outline-offset: 2px;
+}
+
+::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+::-webkit-scrollbar-thumb {
+ border: 2px solid transparent;
+ border-radius: var(--lx-radius-pill);
+ background: var(--lx-color-border-strong);
+ background-clip: content-box;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+/* ---------- 基础排版 ---------- */
+
+.lx-eyebrow {
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1;
+ letter-spacing: 0.08em;
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-link-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-secondary);
+ transition: color var(--lx-motion-fast) var(--lx-ease-out);
+}
+
+.lx-link-button:hover {
+ color: var(--lx-color-brand);
+}
+
+/* ---------- 面板 ---------- */
+
+.lx-panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--lx-space-4);
+ padding: var(--lx-space-5);
+ border: 1px solid var(--lx-color-border);
+ border-radius: var(--lx-radius-md);
+ background: var(--lx-color-bg-panel);
+ box-shadow: var(--lx-shadow-panel);
+}
+
+.lx-panel__head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--lx-space-4);
+}
+
+.lx-panel__head h2 {
+ font-size: var(--lx-font-size-lg);
+ font-weight: 600;
+ line-height: var(--lx-line-height-tight);
+}
+
+.lx-panel__head p {
+ margin-top: 4px;
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-tertiary);
+}
+
+/* ---------- 标签 / 进度 ---------- */
+
+.lx-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 22px;
+ padding: 0 8px;
+ border-radius: var(--lx-radius-sm);
+ font-size: var(--lx-font-size-xs);
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.lx-chip::before {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: currentColor;
+ content: '';
+}
+
+.lx-chip--running {
+ color: var(--lx-color-brand);
+ background: var(--lx-color-brand-soft);
+}
+
+.lx-chip--review {
+ color: var(--lx-color-warning);
+ background: #fff4e8;
+}
+
+.lx-chip--done {
+ color: var(--lx-color-success);
+ background: #e8f8ec;
+}
+
+.lx-chip--failed {
+ color: var(--lx-color-danger);
+ background: #ffecec;
+}
+
+.lx-progress {
+ position: relative;
+ display: block;
+ width: 96px;
+ height: 6px;
+ overflow: hidden;
+ border-radius: var(--lx-radius-pill);
+ background: #eef1f5;
+}
+
+.lx-progress__bar {
+ position: absolute;
+ inset: 0 auto 0 0;
+ border-radius: inherit;
+ background: var(--lx-color-brand);
+}
+
+.lx-progress__value {
+ width: 40px;
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-secondary);
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+/* ---------- 表格 ---------- */
+
+.lx-table {
+ overflow: hidden;
+ border: 1px solid var(--lx-color-border);
+ border-radius: var(--lx-radius-md);
+ background: var(--lx-color-bg-panel);
+}
+
+.lx-table__head,
+.lx-table__row {
+ display: grid;
+ grid-template-columns: minmax(240px, 1.6fr) 110px 170px 110px 120px;
+ align-items: center;
+ gap: var(--lx-space-4);
+ padding: 0 var(--lx-space-4);
+}
+
+.lx-table__head {
+ height: 40px;
+ border-bottom: 1px solid var(--lx-color-border);
+ background: var(--lx-color-bg-subtle);
+ font-size: var(--lx-font-size-xs);
+ font-weight: 500;
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-table__row {
+ min-height: 52px;
+ border-bottom: 1px solid var(--lx-color-border);
+ font-size: var(--lx-font-size-sm);
+ transition: background var(--lx-motion-fast) var(--lx-ease-out);
+}
+
+.lx-table__row:last-child {
+ border-bottom: none;
+}
+
+.lx-table__row:hover {
+ background: var(--lx-color-bg-subtle);
+}
+
+.lx-table__name {
+ overflow: hidden;
+ font-weight: 500;
+ color: var(--lx-color-text-primary);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lx-table__muted {
+ color: var(--lx-color-text-secondary);
+ font-variant-numeric: tabular-nums;
+}
+
+.lx-table__progress {
+ display: flex;
+ align-items: center;
+ gap: var(--lx-space-2);
+}
+
+/* ---------- 字段 ---------- */
+
+.lx-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.lx-field__label {
+ font-size: var(--lx-font-size-sm);
+ font-weight: 500;
+ color: var(--lx-color-text-primary);
+}
+
+.lx-field .semi-input-wrapper {
+ border-radius: var(--lx-radius-md);
+}
diff --git a/apps/web/src/styles/layout.css b/apps/web/src/styles/layout.css
new file mode 100644
index 0000000..914a74e
--- /dev/null
+++ b/apps/web/src/styles/layout.css
@@ -0,0 +1,475 @@
+/* 方向 A · 云白蓝:白顶栏 + 左栏 + 浅灰工作区 */
+
+.lx-shell {
+ display: flex;
+ height: 100%;
+ min-height: 0;
+ flex-direction: column;
+ background: var(--lx-color-bg-app);
+}
+
+/* ---------- 顶栏 ---------- */
+
+.lx-topbar {
+ z-index: 20;
+ display: flex;
+ flex: 0 0 var(--lx-topbar-height);
+ align-items: center;
+ gap: var(--lx-space-6);
+ height: var(--lx-topbar-height);
+ padding: 0 var(--lx-space-6) 0 var(--lx-space-5);
+ border-bottom: 1px solid var(--lx-color-border);
+ background: var(--lx-color-bg-panel);
+}
+
+.lx-topbar__brand {
+ display: flex;
+ min-width: 184px;
+ align-items: center;
+ gap: 10px;
+}
+
+.lx-topbar__mark {
+ display: inline-flex;
+ width: 34px;
+ height: 34px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ background: linear-gradient(135deg, #1677ff, #4096ff);
+ color: #ffffff;
+ font-size: 18px;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.lx-topbar__name {
+ font-size: var(--lx-font-size-lg);
+ font-weight: 600;
+ line-height: 1.15;
+ color: var(--lx-color-text-primary);
+}
+
+.lx-topbar__tag {
+ margin-top: 1px;
+ font-size: 10px;
+ line-height: 1.2;
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-topbar__brand-text {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.25;
+}
+
+.lx-topbar__modules {
+ display: flex;
+ height: 100%;
+ align-items: stretch;
+ gap: var(--lx-space-1);
+}
+
+.lx-topbar__module {
+ position: relative;
+ padding: 0 14px;
+ font-size: var(--lx-font-size-md);
+ color: var(--lx-color-text-secondary);
+ transition: color var(--lx-motion-fast) var(--lx-ease-out);
+}
+
+.lx-topbar__module:hover {
+ color: var(--lx-color-text-primary);
+}
+
+.lx-topbar__module.is-active {
+ color: var(--lx-color-brand);
+ font-weight: 600;
+}
+
+.lx-topbar__module.is-active::after {
+ position: absolute;
+ right: 12px;
+ bottom: 0;
+ left: 12px;
+ height: 2px;
+ border-radius: 2px 2px 0 0;
+ background: var(--lx-color-brand);
+ content: '';
+}
+
+.lx-topbar__actions {
+ display: flex;
+ margin-left: auto;
+ align-items: center;
+ gap: var(--lx-space-2);
+}
+
+.lx-icon-button {
+ position: relative;
+ display: inline-flex;
+ width: 32px;
+ height: 32px;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--lx-radius-md);
+ color: var(--lx-color-text-secondary);
+ transition:
+ color var(--lx-motion-fast) var(--lx-ease-out),
+ background var(--lx-motion-fast) var(--lx-ease-out);
+}
+
+.lx-icon-button:hover {
+ color: var(--lx-color-text-primary);
+ background: var(--lx-color-bg-hover);
+}
+
+.lx-icon-button__dot {
+ position: absolute;
+ top: 7px;
+ right: 7px;
+ width: 6px;
+ height: 6px;
+ border: 1.5px solid var(--lx-color-bg-panel);
+ border-radius: 50%;
+ background: var(--lx-color-danger);
+}
+
+/* ---------- 用户菜单 ---------- */
+
+.lx-user {
+ position: relative;
+}
+
+.lx-user__trigger {
+ display: flex;
+ height: 40px;
+ align-items: center;
+ gap: 8px;
+ padding: 0 6px 0 4px;
+ border-radius: var(--lx-radius-md);
+ transition: background var(--lx-motion-fast) var(--lx-ease-out);
+}
+
+.lx-user__trigger:hover {
+ background: var(--lx-color-bg-hover);
+}
+
+.lx-user__avatar {
+ display: inline-flex;
+ width: 30px;
+ height: 30px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background: var(--lx-color-brand-soft);
+ color: var(--lx-color-brand);
+ font-size: var(--lx-font-size-sm);
+ font-weight: 600;
+}
+
+.lx-user__meta {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ align-items: flex-start;
+ line-height: 1.25;
+}
+
+.lx-user__name {
+ font-size: var(--lx-font-size-sm);
+ font-weight: 500;
+ color: var(--lx-color-text-primary);
+}
+
+.lx-user__role {
+ font-size: 11px;
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-user__chevron {
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-user__menu {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ width: 208px;
+ padding: 6px;
+ border: 1px solid var(--lx-color-border);
+ border-radius: var(--lx-radius-md);
+ background: var(--lx-color-bg-elevated);
+ box-shadow: var(--lx-shadow-overlay);
+}
+
+.lx-user__menu-header {
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--lx-color-border);
+ margin-bottom: 4px;
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-tertiary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lx-user__menu-item {
+ display: flex;
+ width: 100%;
+ height: 34px;
+ align-items: center;
+ gap: 8px;
+ padding: 0 10px;
+ border-radius: var(--lx-radius-sm);
+ font-size: var(--lx-font-size-sm);
+ color: var(--lx-color-text-primary);
+ text-align: left;
+}
+
+.lx-user__menu-item:hover {
+ background: var(--lx-color-bg-hover);
+}
+
+/* ---------- 主体 ---------- */
+
+.lx-shell__body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+}
+
+.lx-sidebar {
+ display: flex;
+ flex: 0 0 var(--lx-sidebar-width);
+ width: var(--lx-sidebar-width);
+ min-height: 0;
+ flex-direction: column;
+ border-right: 1px solid var(--lx-color-border);
+ background: var(--lx-color-bg-panel);
+ transition: width var(--lx-motion-base) var(--lx-ease-out);
+}
+
+.lx-shell--collapsed .lx-sidebar {
+ flex-basis: var(--lx-sidebar-collapsed-width);
+ width: var(--lx-sidebar-collapsed-width);
+}
+
+.lx-sidebar__heading {
+ display: flex;
+ height: 48px;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 var(--lx-space-4) 0 var(--lx-space-5);
+}
+
+.lx-sidebar__title {
+ font-size: var(--lx-font-size-sm);
+ font-weight: 600;
+ color: var(--lx-color-text-primary);
+}
+
+.lx-sidebar__toggle {
+ display: inline-flex;
+ width: 24px;
+ height: 24px;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--lx-radius-sm);
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-sidebar__toggle:hover {
+ color: var(--lx-color-text-primary);
+ background: var(--lx-color-bg-hover);
+}
+
+.lx-sidebar__nav {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 2px;
+ padding: 0 10px;
+ overflow-y: auto;
+}
+
+.lx-sidebar__item {
+ display: flex;
+ min-height: 38px;
+ align-items: center;
+ gap: 10px;
+ padding: 0 12px;
+ border-radius: var(--lx-radius-md);
+ color: var(--lx-color-text-secondary);
+ font-size: var(--lx-font-size-sm);
+ transition:
+ color var(--lx-motion-fast) var(--lx-ease-out),
+ background var(--lx-motion-fast) var(--lx-ease-out);
+}
+
+.lx-sidebar__item:hover {
+ color: var(--lx-color-text-primary);
+ background: var(--lx-color-bg-hover);
+}
+
+.lx-sidebar__item.is-active {
+ color: var(--lx-color-brand);
+ background: var(--lx-color-brand-soft);
+ font-weight: 500;
+}
+
+.lx-sidebar__item-icon {
+ display: inline-flex;
+ flex: 0 0 18px;
+}
+
+.lx-sidebar__item-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lx-sidebar__footer {
+ padding: var(--lx-space-4) var(--lx-space-5);
+ border-top: 1px solid var(--lx-color-border);
+}
+
+.lx-sidebar__env {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-sidebar__env-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--lx-color-success);
+}
+
+.lx-shell--collapsed .lx-sidebar__title,
+.lx-shell--collapsed .lx-sidebar__item-label,
+.lx-shell--collapsed .lx-sidebar__env-text {
+ display: none;
+}
+
+.lx-shell--collapsed .lx-sidebar__heading {
+ justify-content: center;
+ padding: 0;
+}
+
+.lx-shell--collapsed .lx-sidebar__item {
+ justify-content: center;
+ padding: 0;
+}
+
+.lx-shell--collapsed .lx-sidebar__footer {
+ padding: var(--lx-space-4) 0;
+}
+
+/* ---------- 工作区 ---------- */
+
+.lx-workspace {
+ display: flex;
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.lx-breadcrumbs {
+ display: flex;
+ flex: 0 0 44px;
+ height: 44px;
+ align-items: center;
+ gap: 6px;
+ padding: 0 var(--lx-content-padding);
+ border-bottom: 1px solid var(--lx-color-border);
+ background: var(--lx-color-bg-panel);
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-breadcrumbs__entry {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.lx-breadcrumbs__link {
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-breadcrumbs__link:hover {
+ color: var(--lx-color-brand);
+}
+
+.lx-breadcrumbs__current {
+ color: var(--lx-color-text-primary);
+ font-weight: 500;
+}
+
+.lx-breadcrumbs__sep {
+ color: #c9cdd4;
+}
+
+.lx-page {
+ flex: 1;
+ min-height: 0;
+ padding: var(--lx-content-padding);
+ overflow-y: auto;
+}
+
+@media (width <= 1080px) {
+ .lx-topbar__tag {
+ display: none;
+ }
+
+ .lx-sidebar {
+ flex-basis: var(--lx-sidebar-collapsed-width);
+ width: var(--lx-sidebar-collapsed-width);
+ }
+
+ .lx-sidebar__title,
+ .lx-sidebar__item-label,
+ .lx-sidebar__env-text {
+ display: none;
+ }
+
+ .lx-sidebar__heading {
+ justify-content: center;
+ padding: 0;
+ }
+
+ .lx-sidebar__item {
+ justify-content: center;
+ padding: 0;
+ }
+}
+
+@media (width <= 900px) {
+ .lx-topbar {
+ gap: var(--lx-space-3);
+ padding: 0 var(--lx-space-4);
+ }
+
+ .lx-topbar__brand {
+ min-width: auto;
+ }
+
+ .lx-topbar__tag {
+ display: none;
+ }
+
+ .lx-user__meta {
+ display: none;
+ }
+
+ .lx-content-padding,
+ .lx-page {
+ padding: var(--lx-space-4);
+ }
+}
diff --git a/apps/web/src/styles/pages.css b/apps/web/src/styles/pages.css
new file mode 100644
index 0000000..dac6780
--- /dev/null
+++ b/apps/web/src/styles/pages.css
@@ -0,0 +1,551 @@
+/* 方向 A · 云白蓝:登录页与业务页 */
+
+/* ---------- 登录页 ---------- */
+
+.lx-login {
+ position: relative;
+ display: flex;
+ min-height: 100%;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 96px var(--lx-space-6) 72px;
+ background:
+ radial-gradient(circle at 50% 0%, #eef5ff 0%, transparent 46%), var(--lx-color-bg-app);
+}
+
+.lx-login__topbar {
+ position: absolute;
+ top: 0;
+ right: 0;
+ left: 0;
+ display: flex;
+ height: 72px;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 var(--lx-space-8);
+}
+
+.lx-login__brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.lx-login__mark {
+ display: inline-flex;
+ width: 34px;
+ height: 34px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ background: linear-gradient(135deg, #1677ff, #4096ff);
+ color: #ffffff;
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.lx-login__wordmark {
+ font-size: var(--lx-font-size-lg);
+ font-weight: 600;
+ color: var(--lx-color-text-primary);
+}
+
+.lx-login__brand-sub {
+ font-size: 11px;
+ color: var(--lx-color-text-tertiary);
+ letter-spacing: 0.06em;
+}
+
+.lx-login__badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 28px;
+ padding: 0 10px;
+ border: 1px solid var(--lx-color-border);
+ border-radius: var(--lx-radius-sm);
+ background: var(--lx-color-bg-panel);
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-secondary);
+}
+
+.lx-login__badge::before {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--lx-color-success);
+ content: '';
+}
+
+.lx-login__panel {
+ width: 100%;
+ max-width: 400px;
+ padding: 32px;
+ border: 1px solid var(--lx-color-border);
+ border-radius: 8px;
+ background: var(--lx-color-bg-panel);
+ box-shadow: 0 8px 28px rgba(31, 35, 41, 0.08);
+}
+
+.lx-login__form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--lx-space-5);
+}
+
+.lx-login__form-head h2 {
+ font-size: 24px;
+ font-weight: 600;
+ line-height: 1.3;
+ color: var(--lx-color-text-primary);
+}
+
+.lx-login__form-head p {
+ margin-top: 6px;
+ font-size: var(--lx-font-size-sm);
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-login__form form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--lx-space-4);
+}
+
+.lx-login__form-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--lx-space-3);
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-login__form-foot {
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ padding-top: var(--lx-space-4);
+ border-top: 1px solid var(--lx-color-border);
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-login__form-foot-dot {
+ width: 5px;
+ height: 5px;
+ flex: 0 0 5px;
+ margin-top: 6px;
+ border-radius: 50%;
+ background: var(--lx-color-border-strong);
+}
+
+.lx-login__footer {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ display: flex;
+ height: 56px;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 var(--lx-space-8);
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-login__footer-links {
+ display: flex;
+ gap: var(--lx-space-4);
+}
+
+/* ---------- 工作台 ---------- */
+
+.lx-home {
+ display: flex;
+ flex-direction: column;
+ gap: var(--lx-space-4);
+}
+
+.lx-home__hero {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--lx-space-4);
+}
+
+.lx-home__hero h1 {
+ font-size: 24px;
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.lx-home__hero-meta {
+ display: flex;
+ margin-top: 6px;
+ align-items: center;
+ gap: 8px;
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-home__hero-dot {
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: var(--lx-color-border-strong);
+}
+
+.lx-home__hero-actions {
+ display: flex;
+ gap: var(--lx-space-2);
+}
+
+.lx-metrics {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: var(--lx-space-4);
+}
+
+.lx-metric {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 18px 20px;
+ border: 1px solid var(--lx-color-border);
+ border-radius: var(--lx-radius-md);
+ background: var(--lx-color-bg-panel);
+ box-shadow: var(--lx-shadow-panel);
+}
+
+.lx-metric__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-secondary);
+}
+
+.lx-metric__icon {
+ display: inline-flex;
+ width: 28px;
+ height: 28px;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--lx-radius-sm);
+ background: var(--lx-color-brand-soft);
+ color: var(--lx-color-brand);
+}
+
+.lx-metric__value {
+ display: flex;
+ align-items: baseline;
+ gap: 4px;
+ font-size: 28px;
+ font-weight: 600;
+ line-height: 1;
+ color: var(--lx-color-text-primary);
+ font-variant-numeric: tabular-nums;
+}
+
+.lx-metric__unit {
+ font-size: var(--lx-font-size-xs);
+ font-weight: 400;
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-metric__foot {
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-metric__trend {
+ color: var(--lx-color-success);
+}
+
+.lx-home__grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 316px;
+ align-items: start;
+ gap: var(--lx-space-4);
+}
+
+.lx-home__tasks {
+ min-width: 0;
+}
+
+.lx-home__quick {
+ gap: var(--lx-space-3);
+}
+
+.lx-quick-list {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--lx-space-2);
+}
+
+.lx-quick-item {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 12px;
+ border: 1px solid var(--lx-color-border);
+ border-radius: var(--lx-radius-md);
+ background: var(--lx-color-bg-panel);
+ text-align: left;
+ transition:
+ border-color var(--lx-motion-fast) var(--lx-ease-out),
+ background var(--lx-motion-fast) var(--lx-ease-out);
+}
+
+.lx-quick-item:hover {
+ border-color: var(--lx-color-brand);
+ background: #f8fbff;
+}
+
+.lx-quick-item__icon {
+ display: inline-flex;
+ width: 30px;
+ height: 30px;
+ flex: 0 0 30px;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--lx-radius-sm);
+ background: var(--lx-color-brand-soft);
+ color: var(--lx-color-brand);
+}
+
+.lx-quick-item__copy {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.lx-quick-item__label {
+ font-size: var(--lx-font-size-sm);
+ font-weight: 500;
+ color: var(--lx-color-text-primary);
+ white-space: nowrap;
+}
+
+.lx-quick-item__hint {
+ overflow: hidden;
+ font-size: 11px;
+ color: var(--lx-color-text-tertiary);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lx-quick-item__arrow {
+ display: none;
+}
+
+.lx-home__recent {
+ min-width: 0;
+}
+
+.lx-asset-grid {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: var(--lx-space-3);
+}
+
+.lx-asset-tile {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 8px;
+ text-align: left;
+}
+
+.lx-asset-tile__preview {
+ display: block;
+ aspect-ratio: 3 / 4;
+ border: 1px solid var(--lx-color-border);
+ border-radius: var(--lx-radius-md);
+ background: #eef2f7;
+}
+
+.lx-asset-tile__preview--1 {
+ background: linear-gradient(150deg, #dbeafe, #93c5fd 55%, #60a5fa);
+}
+
+.lx-asset-tile__preview--2 {
+ background: linear-gradient(150deg, #e0f2fe, #bae6fd 55%, #7dd3fc);
+}
+
+.lx-asset-tile__preview--3 {
+ background: linear-gradient(150deg, #fef3c7, #fde68a 55%, #fcd34d);
+}
+
+.lx-asset-tile__preview--4 {
+ background: linear-gradient(150deg, #dcfce7, #bbf7d0 55%, #86efac);
+}
+
+.lx-asset-tile__preview--5 {
+ background: linear-gradient(150deg, #fae8ff, #f5d0fe 55%, #e9a8f5);
+}
+
+.lx-asset-tile__preview--6 {
+ background: linear-gradient(150deg, #ffe4e6, #fecdd3 55%, #fda4af);
+}
+
+.lx-asset-tile__meta {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.lx-asset-tile__name {
+ overflow: hidden;
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-primary);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lx-asset-tile__spec {
+ font-size: 11px;
+ color: var(--lx-color-text-tertiary);
+}
+
+/* ---------- 占位工作区 / 错误页 ---------- */
+
+.lx-page-header {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: var(--lx-space-4);
+}
+
+.lx-page-header h1 {
+ font-size: 22px;
+ font-weight: 600;
+}
+
+.lx-page-header p {
+ font-size: var(--lx-font-size-sm);
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-panel--empty {
+ align-items: center;
+ justify-content: center;
+ min-height: 320px;
+}
+
+.lx-workspace-missing {
+ display: flex;
+ min-height: 320px;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--lx-space-3);
+ color: var(--lx-color-text-secondary);
+}
+
+.lx-workspace-missing strong {
+ font-size: 40px;
+ font-weight: 600;
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-error-page {
+ display: flex;
+ min-height: 70vh;
+ align-items: center;
+ justify-content: center;
+ padding: var(--lx-space-8);
+}
+
+.lx-error-page__body {
+ display: flex;
+ max-width: 480px;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+}
+
+.lx-error-page__icon {
+ display: inline-flex;
+ width: 56px;
+ height: 56px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 14px;
+ background: var(--lx-color-brand-soft);
+ color: var(--lx-color-brand);
+}
+
+.lx-error-page__code {
+ margin-top: var(--lx-space-4);
+ font-size: 13px;
+ font-weight: 600;
+ letter-spacing: 0.12em;
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-error-page__title {
+ margin-top: 6px;
+ font-size: 24px;
+ font-weight: 600;
+}
+
+.lx-error-page__description {
+ margin-top: 8px;
+ color: var(--lx-color-text-secondary);
+}
+
+.lx-error-page__trace {
+ margin-top: 12px;
+ padding: 6px 10px;
+ border: 1px solid var(--lx-color-border);
+ border-radius: var(--lx-radius-sm);
+ background: var(--lx-color-bg-panel);
+ font-size: var(--lx-font-size-xs);
+ color: var(--lx-color-text-tertiary);
+}
+
+.lx-error-page__trace code {
+ font-family: var(--lx-font-mono);
+ color: var(--lx-color-text-secondary);
+}
+
+.lx-error-page__actions {
+ display: flex;
+ margin-top: var(--lx-space-5);
+ gap: var(--lx-space-2);
+}
+
+@media (width <= 1200px) {
+ .lx-metrics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .lx-home__grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .lx-asset-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+}
+
+@media (width <= 720px) {
+ .lx-metrics {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .lx-asset-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .lx-home__hero {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
new file mode 100644
index 0000000..afae500
--- /dev/null
+++ b/apps/web/src/vite-env.d.ts
@@ -0,0 +1,6 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_PUBLIC_API_BASE_URL?: string;
+ readonly VITE_PUBLIC_DEMO_SESSION?: string;
+}
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..a8e8c04
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vite/client", "node"],
+ "noEmit": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src", "vite.config.ts", "vitest.config.ts"]
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000..adb95c8
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,27 @@
+import { fileURLToPath, URL } from 'node:url';
+
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+ server: {
+ host: '127.0.0.1',
+ port: 5173,
+ strictPort: true,
+ },
+ preview: {
+ host: '127.0.0.1',
+ port: 4173,
+ strictPort: true,
+ },
+ build: {
+ target: 'es2022',
+ sourcemap: true,
+ },
+});
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
new file mode 100644
index 0000000..cc613b5
--- /dev/null
+++ b/apps/web/vitest.config.ts
@@ -0,0 +1,29 @@
+import { fileURLToPath, URL } from 'node:url';
+
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+ test: {
+ environment: 'jsdom',
+ include: ['src/**/*.spec.{ts,tsx}'],
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'lcov', 'json-summary'],
+ include: ['src/api/**/*.ts', 'src/stores/**/*.ts', 'src/navigation/**/*.ts'],
+ exclude: ['src/api/generated/**', 'src/**/*.spec.{ts,tsx}'],
+ thresholds: {
+ lines: 70,
+ functions: 70,
+ statements: 70,
+ branches: 60,
+ },
+ },
+ },
+});
diff --git a/packages/ui/package.json b/packages/ui/package.json
new file mode 100644
index 0000000..ac4f60b
--- /dev/null
+++ b/packages/ui/package.json
@@ -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"
+ }
+}
diff --git a/packages/ui/scripts/copy-assets.mjs b/packages/ui/scripts/copy-assets.mjs
new file mode 100644
index 0000000..b524fa6
--- /dev/null
+++ b/packages/ui/scripts/copy-assets.mjs
@@ -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'),
+);
diff --git a/packages/ui/src/components/DataTable.tsx b/packages/ui/src/components/DataTable.tsx
new file mode 100644
index 0000000..94012a8
--- /dev/null
+++ b/packages/ui/src/components/DataTable.tsx
@@ -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> {
+ columns: NonNullable['columns']>;
+ dataSource: T[];
+ loading?: boolean;
+ rowKey?: TableProps['rowKey'];
+ pagination?: TableProps['pagination'];
+ empty?: ReactNode;
+ size?: TableProps['size'];
+ scroll?: TableProps['scroll'];
+ className?: string;
+ onRow?: TableProps['onRow'];
+}
+
+/**
+ * 列表表格统一封装:加载态 + 空态 + 行 key 约定。
+ * 业务页只负责列定义与数据,不重复写 Empty / loading。
+ */
+export function DataTable>({
+ columns,
+ dataSource,
+ loading = false,
+ rowKey = 'id',
+ pagination = false,
+ empty,
+ size = 'middle',
+ scroll,
+ className,
+ onRow,
+}: DataTableProps): ReactNode {
+ return (
+
+ 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 ?? }
+ />
+ );
+}
diff --git a/packages/ui/src/components/ErrorState.tsx b/packages/ui/src/components/ErrorState.tsx
new file mode 100644
index 0000000..44a5cdb
--- /dev/null
+++ b/packages/ui/src/components/ErrorState.tsx
@@ -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 (
+
+
+
+
+
{title}
+
{message}
+ {traceId === undefined || traceId.length === 0 ? null : (
+
trace_id: {traceId}
+ )}
+ {onRetry === undefined ? null : (
+
} onClick={onRetry} className="lx-error-state__retry">
+ {retryText}
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/components/FormModal.tsx b/packages/ui/src/components/FormModal.tsx
new file mode 100644
index 0000000..ada5ecb
--- /dev/null
+++ b/packages/ui/src/components/FormModal.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
diff --git a/packages/ui/src/components/PageEmpty.tsx b/packages/ui/src/components/PageEmpty.tsx
new file mode 100644
index 0000000..9a0c381
--- /dev/null
+++ b/packages/ui/src/components/PageEmpty.tsx
@@ -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 (
+
+
} title={title} description={description}>
+ {action === undefined ? null :
{action}
}
+
+
+ );
+}
diff --git a/packages/ui/src/components/PageSkeleton.tsx b/packages/ui/src/components/PageSkeleton.tsx
new file mode 100644
index 0000000..7ab2c21
--- /dev/null
+++ b/packages/ui/src/components/PageSkeleton.tsx
@@ -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 (
+ }
+ className={`lx-page-skeleton${className === undefined ? '' : ` ${className}`}`}
+ >
+ {children}
+
+ );
+}
diff --git a/packages/ui/src/components/QueryForm.tsx b/packages/ui/src/components/QueryForm.tsx
new file mode 100644
index 0000000..291d1f3
--- /dev/null
+++ b/packages/ui/src/components/QueryForm.tsx
@@ -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 (
+
+ );
+}
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
new file mode 100644
index 0000000..d88e3c9
--- /dev/null
+++ b/packages/ui/src/components/index.ts
@@ -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';
diff --git a/packages/ui/src/icons.ts b/packages/ui/src/icons.ts
new file mode 100644
index 0000000..1febbbd
--- /dev/null
+++ b/packages/ui/src/icons.ts
@@ -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';
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
new file mode 100644
index 0000000..348dfd2
--- /dev/null
+++ b/packages/ui/src/index.ts
@@ -0,0 +1,4 @@
+export * from './components/index.js';
+export * from './locale.js';
+export * from './notify.js';
+export * from './theme/tokens.js';
diff --git a/packages/ui/src/locale.ts b/packages/ui/src/locale.ts
new file mode 100644
index 0000000..76a17a9
--- /dev/null
+++ b/packages/ui/src/locale.ts
@@ -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;
diff --git a/packages/ui/src/notify.ts b/packages/ui/src/notify.ts
new file mode 100644
index 0000000..2bbe32f
--- /dev/null
+++ b/packages/ui/src/notify.ts
@@ -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 });
+}
diff --git a/packages/ui/src/styles/components.css b/packages/ui/src/styles/components.css
new file mode 100644
index 0000000..c3e4ac6
--- /dev/null
+++ b/packages/ui/src/styles/components.css
@@ -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);
+}
diff --git a/packages/ui/src/theme/tokens.css b/packages/ui/src/theme/tokens.css
new file mode 100644
index 0000000..ac41160
--- /dev/null
+++ b/packages/ui/src/theme/tokens.css
@@ -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);
+}
diff --git a/packages/ui/src/theme/tokens.ts b/packages/ui/src/theme/tokens.ts
new file mode 100644
index 0000000..b081830
--- /dev/null
+++ b/packages/ui/src/theme/tokens.ts
@@ -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;
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
new file mode 100644
index 0000000..4e55bb6
--- /dev/null
+++ b/packages/ui/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "noEmit": true,
+ "types": []
+ },
+ "include": ["src"]
+}