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 ( +
+
{email}
+ +
+ ); +} + +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 ( +
+ +
+ + + +

灵犀 · 访问异常

+

{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 ( +
+
+

工作台

+

+ {userName},欢迎回来 +

+
+
+ + +
+
+ ); +} + +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} + + + + + {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 ( +
+
+ 灵 + 灵犀 + 素材生产平台 · LINGXI +
+ 内部系统 · 仅限授权成员 +
+ ); +} 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); + }} + /> +
+
+ © 2026 灵犀 · 内部系统 + + 隐私政策 + 服务条款 + 华东一区 + +
+
+ ); +} 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 : ( + + )} +
+ ); +} 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 ( +
{ + onSubmit(); + }} + > +
{children}
+ + + + {extra} + +
+ ); +} 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"] +}