feat(ci): 搭建 A0 工程骨架与契约基线 #2

Merged
wang259797 merged 7 commits from feat/ci-a0-foundation into main 2026-11-02 09:27:48 +00:00
51 changed files with 4452 additions and 0 deletions
Showing only changes of commit 073d210a92 - Show all commits

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>灵犀 · 信息流素材生产平台</title>
</head>
<body theme-mode="light">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -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"
}
}
+141
View File
@@ -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');
});
});
+199
View File
@@ -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<string, string>;
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<ApiError> {
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<boolean> | null = null;
/** 单飞刷新:并发 401 只触发一次 /auth/refresh */
export function refreshSession(): Promise<boolean> {
refreshPromise ??= doRefresh().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
async function doRefresh(): Promise<boolean> {
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<T>(path: string, options: RequestOptions, allowRefresh: boolean): Promise<T> {
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<T>(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<T>(path: string, options: RequestOptions = {}): Promise<T> {
return send<T>(path, options, true);
}
+165
View File
@@ -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<string, never>;
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<string, never>;
/**
* @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<string, never>;
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;
};
};
};
}
+37
View File
@@ -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,
},
},
});
+58
View File
@@ -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 <Navigate to={ROUTES.forbidden} replace />;
}
return (
<div
className={collapsed ? 'lx-shell lx-shell--collapsed' : 'lx-shell'}
data-testid="app-shell"
>
<TopBar modules={modules} activeModuleKey={activeModule.key} />
<div className="lx-shell__body">
<SideNav
module={activeModule}
collapsed={collapsed}
onToggle={() => {
setCollapsed((value) => !value);
}}
/>
<main className="lx-workspace">
<PageBreadcrumbs pathname={location.pathname} />
<div className="lx-page">
<Suspense fallback={<PageSkeleton rows={8} />}>
<Outlet />
</Suspense>
</div>
</main>
</div>
</div>
);
}
+33
View File
@@ -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 (
<nav className="lx-breadcrumbs" aria-label="面包屑">
{entries.map((entry, index) => {
const isLast = index === entries.length - 1;
return (
<span key={entry.key} className="lx-breadcrumbs__entry">
{index > 0 ? <span className="lx-breadcrumbs__sep">/</span> : null}
{entry.path !== undefined && !isLast ? (
<Link to={entry.path} className="lx-breadcrumbs__link">
{entry.label}
</Link>
) : (
<span className={isLast ? 'lx-breadcrumbs__current' : 'lx-breadcrumbs__label'}>
{entry.label}
</span>
)}
</span>
);
})}
</nav>
);
}
+59
View File
@@ -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 (
<aside className="lx-sidebar" aria-label="功能导航">
<div className="lx-sidebar__heading">
<span className="lx-sidebar__title">{module.label}</span>
<button
type="button"
className="lx-icon-button lx-sidebar__toggle"
onClick={onToggle}
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
title={collapsed ? '展开侧边栏' : '收起侧边栏'}
>
{collapsed ? <IconMenu size={18} /> : <IconBack size={18} />}
</button>
</div>
<nav className="lx-sidebar__nav">
{module.items.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.key}
to={item.path}
end={item.path === '/'}
className={({ isActive }) =>
isActive ? 'lx-sidebar__item is-active' : 'lx-sidebar__item'
}
title={collapsed ? item.label : undefined}
>
<span className="lx-sidebar__item-icon">
<Icon size={18} />
</span>
<span className="lx-sidebar__item-label">{item.label}</span>
</NavLink>
);
})}
</nav>
<div className="lx-sidebar__footer">
<span className="lx-sidebar__env" title="当前环境:本地开发">
<span className="lx-sidebar__env-dot" aria-hidden="true" />
<span className="lx-sidebar__env-text">本地开发</span>
</span>
</div>
</aside>
);
}
+143
View File
@@ -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 (
<nav className="lx-topbar__modules" aria-label="模块导航">
{modules.map((module) => {
const active = module.key === activeModuleKey;
return (
<button
key={module.key}
type="button"
className={active ? 'lx-topbar__module is-active' : 'lx-topbar__module'}
aria-current={active ? 'page' : undefined}
onClick={() => {
void navigate(module.path);
}}
>
{module.label}
</button>
);
})}
</nav>
);
}
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<string | null>(null);
const menuRef = useRef<HTMLDivElement | null>(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 (
<div className="lx-user" ref={menuRef}>
<button
type="button"
className="lx-user__trigger"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => {
setOpenPath(open ? null : location.pathname);
}}
>
<span className="lx-user__avatar">{user?.name.slice(0, 1) ?? '灵'}</span>
<span className="lx-user__meta">
<span className="lx-user__name">{user?.name ?? '未登录'}</span>
<span className="lx-user__role">{user?.roleName ?? '访客'}</span>
</span>
<IconChevronDown size={14} className="lx-user__chevron" />
</button>
<UserMenuDropdown
open={open}
email={user?.email ?? '—'}
onLogout={() => {
clearSession();
void navigate(ROUTES.login, { replace: true });
}}
/>
</div>
);
}
function UserMenuDropdown({
open,
email,
onLogout,
}: {
open: boolean;
email: string;
onLogout: () => void;
}): ReactNode {
if (!open) {
return null;
}
return (
<div className="lx-user__menu" role="menu">
<div className="lx-user__menu-header">{email}</div>
<button type="button" role="menuitem" className="lx-user__menu-item" onClick={onLogout}>
<IconLogout size={16} />
退出登录
</button>
</div>
);
}
export function TopBar({ modules, activeModuleKey }: TopBarProps): ReactNode {
return (
<header className="lx-topbar">
<div className="lx-topbar__brand">
<span className="lx-topbar__mark">灵</span>
<span className="lx-topbar__brand-text">
<span className="lx-topbar__name">灵犀</span>
<span className="lx-topbar__tag">素材生产平台</span>
</span>
</div>
<ModuleTabs modules={modules} activeModuleKey={activeModuleKey} />
<div className="lx-topbar__actions">
<button type="button" className="lx-icon-button" aria-label="全局搜索" title="全局搜索">
<IconSearch size={18} />
</button>
<button type="button" className="lx-icon-button" aria-label="任务通知" title="任务通知">
<IconNotification size={18} />
<span className="lx-icon-button__dot" aria-hidden="true" />
</button>
<UserMenu />
</div>
</header>
);
}
+39
View File
@@ -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(
<StrictMode>
<LocaleProvider locale={localeZhCN}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</LocaleProvider>
</StrictMode>,
);
@@ -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(['工作台', '平台', '用户与组织']);
});
});
+410
View File
@@ -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<string>,
): 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;
}
+40
View File
@@ -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 (
<main className="lx-error-page" data-testid={`error-${code}`}>
<div className="lx-error-page__code" aria-hidden="true">
{code}
</div>
<div className="lx-error-page__body">
<span className="lx-error-page__icon">
<IconAlert size={22} />
</span>
<p className="lx-eyebrow">灵犀 · 访问异常</p>
<h1 className="lx-error-page__title">{title}</h1>
<p className="lx-error-page__description">{description}</p>
{traceId !== undefined ? (
<p className="lx-error-page__trace">
trace_id:<code>{traceId}</code>
</p>
) : null}
<div className="lx-error-page__actions">{actions}</div>
</div>
</main>
);
}
@@ -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 (
<ErrorLayout
code="403"
title="没有访问权限"
description="你的账号没有该页面的权限点。如需开通,请联系平台管理员调整角色。"
actions={
<>
<Button
theme="solid"
type="primary"
onClick={() => {
void navigate(ROUTES.home);
}}
>
返回工作台
</Button>
<Button
onClick={() => {
void navigate(-1);
}}
>
返回上一页
</Button>
</>
}
/>
);
}
@@ -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 (
<ErrorLayout
code="404"
title="页面不存在"
description="地址可能已变更或输入有误。可以回到工作台,从顶部模块菜单重新进入。"
actions={
<Button
theme="solid"
type="primary"
onClick={() => {
void navigate(ROUTES.home);
}}
>
返回工作台
</Button>
}
/>
);
}
@@ -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 (
<ErrorLayout
code="500"
title="服务暂时不可用"
description="请求处理失败,请稍后重试。若问题持续出现,请把 trace_id 提供给平台管理员排查。"
traceId={traceId}
actions={
<>
<Button
theme="solid"
type="primary"
onClick={() => {
void navigate(0);
}}
>
重新加载
</Button>
<Button
onClick={() => {
void navigate(ROUTES.home);
}}
>
返回工作台
</Button>
</>
}
/>
);
}
@@ -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 (
<ErrorLayout
code="401"
title="请先登录"
description="当前页面需要登录后访问。登录成功后会自动回到工作台。"
actions={
<Button
theme="solid"
type="primary"
onClick={() => {
void navigate(ROUTES.login);
}}
>
去登录
</Button>
}
/>
);
}
+373
View File
@@ -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<ProductionTask['status'], string> = {
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 (
<header className="lx-home__hero">
<div className="lx-home__hero-copy">
<h1>工作台</h1>
<p className="lx-home__hero-meta">
<span>{userName},欢迎回来</span>
<span className="lx-home__hero-dot" aria-hidden="true" />
<span>2026 年 11 月 2 日 周一</span>
<span className="lx-home__hero-dot" aria-hidden="true" />
<span>今日已产出 18 条素材</span>
</p>
</div>
<div className="lx-home__hero-actions">
<Button
icon={<IconExport size={16} />}
onClick={() => {
void navigate('/delivery/export');
}}
>
导出报表
</Button>
<Button
theme="solid"
type="primary"
icon={<IconImageAdd size={16} />}
onClick={() => {
void navigate('/material/image-compose');
}}
>
新建生产任务
</Button>
</div>
</header>
);
}
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 (
<section className="lx-metrics" aria-label="生产概览">
{METRICS.map((metric) => {
const Icon = metric.icon;
return (
<article className="lx-metric" key={metric.key}>
<div className="lx-metric__head">
<span>{metric.label}</span>
<span className="lx-metric__icon">
<Icon size={16} />
</span>
</div>
<p className="lx-metric__value">
{metric.value}
<span className="lx-metric__unit">{metric.unit}</span>
</p>
<p className="lx-metric__foot">
{metric.trend === '' ? (
metric.foot
) : (
<span className="lx-metric__trend">{metric.trend}</span>
)}
{metric.trend === '' ? null : ` · ${metric.foot}`}
</p>
</article>
);
})}
</section>
);
}
function TaskRow({ task }: { task: ProductionTask }): ReactNode {
return (
<div className="lx-table__row" role="row">
<span className="lx-table__name" role="cell">
{task.name}
</span>
<span className="lx-table__muted" role="cell">
{task.kind}
</span>
<span className="lx-table__progress" role="cell">
<span className="lx-progress" aria-hidden="true">
<span className="lx-progress__bar" style={{ width: `${task.progress}%` }} />
</span>
<span className="lx-progress__value">{task.progress}%</span>
</span>
<span role="cell">
<span className={`lx-chip lx-chip--${task.status}`}>{STATUS_LABEL[task.status]}</span>
</span>
<span className="lx-table__muted" role="cell">
{task.updatedAt}
</span>
</div>
);
}
function TaskPanel(): ReactNode {
const navigate = useNavigate();
return (
<section className="lx-panel lx-home__tasks" aria-labelledby="home-tasks-title">
<header className="lx-panel__head">
<div>
<h2 id="home-tasks-title">生产任务</h2>
<p>按最近更新时间排序</p>
</div>
<button
type="button"
className="lx-link-button"
onClick={() => {
void navigate('/material/tasks');
}}
>
查看全部
<IconChevronRight size={14} />
</button>
</header>
<div className="lx-table" role="table" aria-label="进行中的任务">
<div className="lx-table__head" role="row">
<span role="columnheader">任务</span>
<span role="columnheader">类型</span>
<span role="columnheader">进度</span>
<span role="columnheader">状态</span>
<span role="columnheader">更新时间</span>
</div>
{TASKS.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
</section>
);
}
function QuickPanel(): ReactNode {
const navigate = useNavigate();
return (
<aside className="lx-panel lx-home__quick" aria-labelledby="home-quick-title">
<header className="lx-panel__head">
<div>
<h2 id="home-quick-title">快速开始</h2>
<p>常用生产入口</p>
</div>
</header>
<div className="lx-quick-list">
{QUICK_ACTIONS.map((action) => {
const Icon = action.icon;
return (
<button
key={action.key}
type="button"
className="lx-quick-item"
onClick={() => {
void navigate(action.path);
}}
>
<span className="lx-quick-item__icon">
<Icon size={18} />
</span>
<span className="lx-quick-item__copy">
<span className="lx-quick-item__label">{action.label}</span>
<span className="lx-quick-item__hint">{action.hint}</span>
</span>
<IconChevronRight size={16} className="lx-quick-item__arrow" />
</button>
);
})}
</div>
</aside>
);
}
function RecentAssetsPanel(): ReactNode {
const navigate = useNavigate();
return (
<section className="lx-panel lx-home__recent" aria-labelledby="home-recent-title">
<header className="lx-panel__head">
<div>
<h2 id="home-recent-title">最近素材</h2>
<p>今天产出的图片与视频封面</p>
</div>
<button
type="button"
className="lx-link-button"
onClick={() => {
void navigate('/assets/library');
}}
>
打开资产库
<IconChevronRight size={14} />
</button>
</header>
<div className="lx-asset-grid">
{RECENT_ASSETS.map((asset) => (
<button
key={asset.id}
type="button"
className="lx-asset-tile"
onClick={() => {
void navigate('/assets/library');
}}
>
<span className={`lx-asset-tile__preview lx-asset-tile__preview--${asset.tone}`} />
<span className="lx-asset-tile__meta">
<span className="lx-asset-tile__name">{asset.name}</span>
<span className="lx-asset-tile__spec">{asset.spec}</span>
</span>
</button>
))}
</div>
</section>
);
}
export default function HomePage(): ReactNode {
const user = useAuthStore((state) => state.user);
return (
<div className="lx-home">
<HomeHero userName={user?.name ?? '管理员'} />
<HomeMetrics />
<div className="lx-home__grid">
<TaskPanel />
<QuickPanel />
</div>
<RecentAssetsPanel />
</div>
);
}
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
export function LoginBrandPanel(): ReactNode {
return (
<header className="lx-login__topbar">
<div className="lx-login__brand">
<span className="lx-login__mark">灵</span>
<span className="lx-login__wordmark">灵犀</span>
<span className="lx-login__brand-sub">素材生产平台 · LINGXI</span>
</div>
<span className="lx-login__badge">内部系统 · 仅限授权成员</span>
</header>
);
}
+114
View File
@@ -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<HTMLFormElement>) => void;
}
function LoginFormHeader(): ReactNode {
return (
<header className="lx-login__form-head">
<h2>登录灵犀</h2>
<p>使用企业账号登录素材生产平台</p>
</header>
);
}
function LoginFormFooter(): ReactNode {
return (
<footer className="lx-login__form-foot">
<span className="lx-login__form-foot-dot" aria-hidden="true" />
账号由平台管理员开通,如需协助请联系 IT 服务台
</footer>
);
}
function LoginFields({
account,
password,
onAccountChange,
onPasswordChange,
}: Pick<
LoginFormCardProps,
'account' | 'password' | 'onAccountChange' | 'onPasswordChange'
>): ReactNode {
return (
<>
<label className="lx-field">
<span className="lx-field__label">账号</span>
<Input
size="large"
value={account}
onChange={onAccountChange}
placeholder="请输入账号或邮箱"
prefix={<IconMail size={16} />}
autoComplete="username"
/>
</label>
<label className="lx-field">
<span className="lx-field__label">密码</span>
<Input
size="large"
type="password"
value={password}
onChange={onPasswordChange}
placeholder="请输入密码"
prefix={<IconLock size={16} />}
autoComplete="current-password"
/>
</label>
<div className="lx-login__form-meta">
<Checkbox defaultChecked>记住我(7 天)</Checkbox>
<button type="button" className="lx-link-button">
忘记密码?
</button>
</div>
</>
);
}
export function LoginFormCard({
account,
password,
submitting,
onAccountChange,
onPasswordChange,
onSubmit,
}: LoginFormCardProps): ReactNode {
return (
<div className="lx-login__form">
<LoginFormHeader />
<form onSubmit={onSubmit} data-testid="login-form">
<LoginFields
account={account}
password={password}
onAccountChange={onAccountChange}
onPasswordChange={onPasswordChange}
/>
<Button
htmlType="submit"
theme="solid"
type="primary"
size="large"
block
loading={submitting}
data-testid="login-submit"
>
登录
</Button>
</form>
<LoginFormFooter />
</div>
);
}
+35
View File
@@ -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 (
<main className="lx-login">
<LoginBrandPanel />
<section className="lx-login__panel">
<LoginFormCard
account={account}
password={password}
submitting={submitting}
onAccountChange={setAccount}
onPasswordChange={setPassword}
onSubmit={(event) => {
void handleSubmit(event);
}}
/>
</section>
<footer className="lx-login__footer">
<span>© 2026 灵犀 · 内部系统</span>
<span className="lx-login__footer-links">
<span>隐私政策</span>
<span>服务条款</span>
<span>华东一区</span>
</span>
</footer>
</main>
);
}
@@ -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<HTMLFormElement>) => Promise<void>;
} {
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<HTMLFormElement>): Promise<void> => {
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<LoginResponse>('/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 };
}
@@ -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 (
<section className="lx-panel lx-workspace-missing">
<p className="lx-eyebrow">404</p>
<h1>页面不存在</h1>
<p>该功能可能已调整或你没有访问权限。</p>
<Link to={ROUTES.home} className="lx-link-button">
返回工作台
</Link>
</section>
);
}
return (
<section className="lx-workspace-page">
<header className="lx-page-header">
<div>
<p className="lx-eyebrow">{page.moduleLabel}</p>
<h1>{page.item.label}</h1>
<p className="lx-page-header__description">{page.item.description}</p>
</div>
</header>
<div className="lx-panel lx-panel--empty">
<PageEmpty />
</div>
</section>
);
}
+40
View File
@@ -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: <AppLayout />,
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: <UnauthorizedPage /> },
{ path: ROUTES.forbidden, element: <ForbiddenPage /> },
{ path: ROUTES.notFound, element: <NotFoundPage /> },
{ path: ROUTES.serverError, element: <ServerErrorPage /> },
{ path: '*', element: <NotFoundPage /> },
]);
+11
View File
@@ -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];
+141
View File
@@ -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<string, unknown> {
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<AuthState>()((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),
}));
+284
View File
@@ -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);
}
+475
View File
@@ -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);
}
}
+551
View File
@@ -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;
}
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_PUBLIC_API_BASE_URL?: string;
readonly VITE_PUBLIC_DEMO_SESSION?: string;
}
+13
View File
@@ -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"]
}
+27
View File
@@ -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,
},
});
+29
View File
@@ -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,
},
},
},
});
+58
View File
@@ -0,0 +1,58 @@
{
"name": "@lingxi/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./icons": {
"types": "./dist/icons.d.ts",
"import": "./dist/icons.js",
"require": "./dist/icons.cjs"
},
"./theme/tokens.css": "./dist/theme/tokens.css",
"./styles.css": "./dist/styles.css",
"./semi.css": "./dist/semi.css"
},
"typesVersions": {
"*": {
"icons": [
"dist/icons.d.ts"
],
"*": [
"dist/index.d.ts"
]
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.ts src/icons.ts --format esm,cjs --dts --clean --sourcemap && node scripts/copy-assets.mjs",
"dev": "tsup src/index.ts src/icons.ts --format esm,cjs --dts --watch",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"dependencies": {
"@douyinfe/semi-ui-19": "2.104.0",
"@remixicon/react": "4.9.0"
},
"devDependencies": {
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"tsup": "8.5.1",
"typescript": "5.9.3"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { cp, mkdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
await mkdir(resolve(root, 'dist/theme'), { recursive: true });
await cp(resolve(root, 'src/theme/tokens.css'), resolve(root, 'dist/theme/tokens.css'));
await cp(resolve(root, 'src/styles/components.css'), resolve(root, 'dist/styles.css'));
await cp(
resolve(root, 'node_modules/@douyinfe/semi-ui-19/dist/css/semi.min.css'),
resolve(root, 'dist/semi.css'),
);
+50
View File
@@ -0,0 +1,50 @@
import { Table } from '@douyinfe/semi-ui-19';
import type { TableProps } from '@douyinfe/semi-ui-19/lib/es/table/interface';
import type { ReactNode } from 'react';
import { PageEmpty } from './PageEmpty.js';
export interface DataTableProps<T extends Record<string, unknown>> {
columns: NonNullable<TableProps<T>['columns']>;
dataSource: T[];
loading?: boolean;
rowKey?: TableProps<T>['rowKey'];
pagination?: TableProps<T>['pagination'];
empty?: ReactNode;
size?: TableProps<T>['size'];
scroll?: TableProps<T>['scroll'];
className?: string;
onRow?: TableProps<T>['onRow'];
}
/**
* 列表表格统一封装:加载态 + 空态 + 行 key 约定。
* 业务页只负责列定义与数据,不重复写 Empty / loading。
*/
export function DataTable<T extends Record<string, unknown>>({
columns,
dataSource,
loading = false,
rowKey = 'id',
pagination = false,
empty,
size = 'middle',
scroll,
className,
onRow,
}: DataTableProps<T>): ReactNode {
return (
<Table<T>
className={`lx-data-table${className === undefined ? '' : ` ${className}`}`}
columns={columns}
dataSource={dataSource}
loading={loading}
rowKey={rowKey}
pagination={pagination}
size={size}
scroll={scroll}
onRow={onRow}
empty={empty ?? <PageEmpty />}
/>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { Button } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { IconError, IconRefresh } from '../icons.js';
export interface ErrorStateProps {
title?: string;
message?: string;
/** 服务端返回的 trace_id;500 类错误必须展示(工程规范 04) */
traceId?: string;
onRetry?: () => void;
retryText?: string;
className?: string;
}
/** 统一错误态:说明 + trace_id + 重试 */
export function ErrorState({
title = '加载失败',
message = '请稍后重试;若持续失败,请把 trace_id 提供给管理员。',
traceId,
onRetry,
retryText = '重新加载',
className,
}: ErrorStateProps): ReactNode {
return (
<div className={`lx-error-state${className === undefined ? '' : ` ${className}`}`}>
<span className="lx-error-state__icon" aria-hidden="true">
<IconError size={32} />
</span>
<h3 className="lx-error-state__title">{title}</h3>
<p className="lx-error-state__message">{message}</p>
{traceId === undefined || traceId.length === 0 ? null : (
<p className="lx-error-state__trace">trace_id: {traceId}</p>
)}
{onRetry === undefined ? null : (
<Button icon={<IconRefresh />} onClick={onRetry} className="lx-error-state__retry">
{retryText}
</Button>
)}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { Modal } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
export interface FormModalProps {
visible: boolean;
title: ReactNode;
children: ReactNode;
onCancel: () => void;
onOk: () => void;
confirmLoading?: boolean;
okText?: string;
cancelText?: string;
width?: number | string;
className?: string;
}
/** 表单弹窗统一封装:确定 / 取消 / 提交中状态 */
export function FormModal({
visible,
title,
children,
onCancel,
onOk,
confirmLoading = false,
okText = '确定',
cancelText = '取消',
width = 560,
className,
}: FormModalProps): ReactNode {
return (
<Modal
visible={visible}
title={title}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText={okText}
cancelText={cancelText}
width={width}
className={`lx-form-modal${className === undefined ? '' : ` ${className}`}`}
closeOnEsc
maskClosable={false}
>
{children}
</Modal>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Empty } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { IconAsset } from '../icons.js';
export interface PageEmptyProps {
title?: ReactNode;
description?: ReactNode;
action?: ReactNode;
className?: string;
}
/** 统一空态:标题 + 说明 + 可选主操作 */
export function PageEmpty({
title = '暂无数据',
description,
action,
className,
}: PageEmptyProps): ReactNode {
return (
<div className={`lx-empty${className === undefined ? '' : ` ${className}`}`}>
<Empty image={<IconAsset size={40} />} title={title} description={description}>
{action === undefined ? null : <div className="lx-empty__action">{action}</div>}
</Empty>
</div>
);
}
@@ -0,0 +1,27 @@
import { Skeleton } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
export interface PageSkeletonProps {
loading?: boolean;
rows?: number;
children?: ReactNode;
className?: string;
}
/** 统一加载骨架:页面级加载用段落占位,避免布局跳动 */
export function PageSkeleton({
loading = true,
rows = 6,
children,
className,
}: PageSkeletonProps): ReactNode {
return (
<Skeleton
loading={loading}
placeholder={<Skeleton.Paragraph rows={rows} />}
className={`lx-page-skeleton${className === undefined ? '' : ` ${className}`}`}
>
{children}
</Skeleton>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { Button, Form, Space } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { IconRefresh, IconSearch } from '../icons.js';
export interface QueryFormProps {
children: ReactNode;
/** 查询动作;由页面自行发起请求 */
onSubmit: () => void;
onReset?: () => void;
loading?: boolean;
/** 额外的右侧操作(如「新建」) */
extra?: ReactNode;
className?: string;
}
/** 列表页查询表单:字段一行内排列,查询 / 重置固位;筛选行不超过两行(设计约束) */
export function QueryForm({
children,
onSubmit,
onReset,
loading = false,
extra,
className,
}: QueryFormProps): ReactNode {
return (
<Form
className={`lx-query-form${className === undefined ? '' : ` ${className}`}`}
layout="horizontal"
onSubmit={() => {
onSubmit();
}}
>
<div className="lx-query-form__fields">{children}</div>
<Space className="lx-query-form__actions" spacing={8}>
<Button
htmlType="submit"
theme="solid"
type="primary"
icon={<IconSearch />}
loading={loading}
>
查询
</Button>
<Button
icon={<IconRefresh />}
onClick={() => {
onReset?.();
}}
>
重置
</Button>
{extra}
</Space>
</Form>
);
}
+6
View File
@@ -0,0 +1,6 @@
export { DataTable } from './DataTable.js';
export { ErrorState } from './ErrorState.js';
export { FormModal } from './FormModal.js';
export { PageEmpty } from './PageEmpty.js';
export { PageSkeleton } from './PageSkeleton.js';
export { QueryForm } from './QueryForm.js';
+63
View File
@@ -0,0 +1,63 @@
/**
* Remix Icon 统一出口(AGENTS §3.3:业务代码只能从这里按语义名导入)
* 约定:对外只暴露 Icon* 语义名;新增图标时在这里做一次映射,不要在业务代码里直接引图标库。
*/
export {
RiAddLine as IconAdd,
RiAlertLine as IconAlert,
RiAppsLine as IconApps,
RiFolderOpenLine as IconAsset,
RiArrowLeftLine as IconBack,
RiCalendarLine as IconCalendar,
RiBarChartLine as IconChart,
RiCheckLine as IconCheck,
RiArrowDownSLine as IconChevronDown,
RiArrowRightSLine as IconChevronRight,
RiTimeLine as IconClock,
RiCloseLine as IconClose,
RiFileCopyLine as IconCopy,
RiDashboardLine as IconDashboard,
RiDeleteBinLine as IconDelete,
RiSendPlaneLine as IconDelivery,
RiBookOpenLine as IconDocs,
RiDownloadLine as IconDownload,
RiDragMoveLine as IconDrag,
RiEditLine as IconEdit,
RiErrorWarningLine as IconError,
RiExportLine as IconExport,
RiFilter3Line as IconFilter,
RiGlobalLine as IconGlobal,
RiLayoutGridLine as IconGrid,
RiHome5Line as IconHome,
RiImageAddLine as IconImageAdd,
RiLoader4Line as IconLoading,
RiLockLine as IconLock,
RiLogoutBoxRLine as IconLogout,
RiMailLine as IconMail,
RiImageLine as IconMaterial,
RiMenuLine as IconMenu,
RiMessage3Line as IconMessage,
RiMoreLine as IconMore,
RiMusicLine as IconMusic,
RiNotification3Line as IconNotification,
RiPaletteLine as IconPalette,
RiPauseLine as IconPause,
RiPlayLine as IconPlay,
RiRefreshLine as IconRefresh,
RiRobotLine as IconRobot,
RiSaveLine as IconSave,
RiFileTextLine as IconScript,
RiSearchLine as IconSearch,
RiServerLine as IconServer,
RiSettings3Line as IconSettings,
RiShareLine as IconShare,
RiShieldKeyholeLine as IconShield,
RiStackLine as IconStack,
RiStarLine as IconStar,
RiTaskLine as IconTask,
RiThumbUpLine as IconThumbUp,
RiUploadLine as IconUpload,
RiUserLine as IconUser,
RiVideoLine as IconVideo,
RiEyeLine as IconView,
} from '@remixicon/react';
+4
View File
@@ -0,0 +1,4 @@
export * from './components/index.js';
export * from './locale.js';
export * from './notify.js';
export * from './theme/tokens.js';
+5
View File
@@ -0,0 +1,5 @@
import type { Locale } from '@douyinfe/semi-ui-19/lib/es/locale/interface';
import zhCN from '@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN';
/** 全站默认中文语言包(Semi LocaleProvider) */
export const localeZhCN: Locale = zhCN;
+19
View File
@@ -0,0 +1,19 @@
import { Toast } from '@douyinfe/semi-ui-19';
/** 统一成功提示 */
export function notifySuccess(content: string): void {
Toast.success({ content, duration: 2 });
}
/** 统一错误提示;带 trace_id 时一并展示,便于用户报障(工程规范 04) */
export function notifyError(message: string, traceId?: string): void {
Toast.error({
content:
traceId === undefined || traceId.length === 0 ? message : `${message}(trace: ${traceId})`,
duration: 4,
});
}
export function notifyWarning(content: string): void {
Toast.warning({ content, duration: 3 });
}
+100
View File
@@ -0,0 +1,100 @@
/* 灵犀 UI 包组件样式:只使用 tokens.css 中的语义变量,不写颜色字面量 */
.lx-empty {
padding: 48px 0;
text-align: center;
}
.lx-empty__action {
margin-top: var(--lx-space-4);
}
.lx-error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--lx-space-2);
padding: 64px var(--lx-space-6);
text-align: center;
}
.lx-error-state__icon {
color: var(--lx-color-danger);
display: inline-flex;
}
.lx-error-state__title {
margin: var(--lx-space-2) 0 0;
color: var(--lx-color-text-primary);
font-size: var(--lx-font-size-lg);
line-height: var(--lx-line-height-tight);
}
.lx-error-state__message {
margin: 0;
max-width: 42ch;
color: var(--lx-color-text-secondary);
font-size: var(--lx-font-size-sm);
}
.lx-error-state__trace {
margin: 0;
color: var(--lx-color-text-tertiary);
font-family: var(--lx-font-mono);
font-size: var(--lx-font-size-xs);
}
.lx-error-state__retry {
margin-top: var(--lx-space-3);
}
.lx-page-skeleton {
padding: var(--lx-space-4) 0;
}
.lx-query-form {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--lx-space-4);
margin-bottom: var(--lx-space-4);
}
.lx-query-form__fields {
display: flex;
flex: 1 1 auto;
flex-wrap: wrap;
gap: var(--lx-space-2) var(--lx-space-4);
min-width: 0;
}
.lx-query-form__actions {
flex: 0 0 auto;
}
.lx-data-table .semi-table-container {
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
overflow: hidden;
}
.lx-data-table .semi-table-thead > .semi-table-row > .semi-table-row-head {
background: var(--lx-color-bg-panel);
color: var(--lx-color-text-secondary);
font-size: var(--lx-font-size-xs);
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.lx-data-table .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
border-bottom: 1px solid var(--lx-color-border);
}
.lx-data-table .semi-table-tbody > .semi-table-row:hover > .semi-table-row-cell {
background: var(--lx-color-bg-hover);
}
.lx-form-modal .semi-modal-content {
background: var(--lx-color-bg-elevated);
}
+114
View File
@@ -0,0 +1,114 @@
:root {
/* 方向 A · 云白蓝(Cloud Blue):传统企业后台骨架 + 现代浅色质感 */
--lx-color-bg-app: #f5f7fa;
--lx-color-bg-panel: #ffffff;
--lx-color-bg-elevated: #ffffff;
--lx-color-bg-subtle: #fafbfc;
--lx-color-bg-hover: #f2f4f7;
--lx-color-bg-active: #e8f1ff;
--lx-color-border: #e5e8ef;
--lx-color-border-strong: #d0d5dd;
--lx-color-text-primary: #1f2329;
--lx-color-text-secondary: #646a73;
--lx-color-text-tertiary: #8f959e;
--lx-color-text-inverse: #ffffff;
--lx-color-brand: #1677ff;
--lx-color-brand-hover: #4096ff;
--lx-color-brand-pressed: #0958d9;
--lx-color-brand-soft: #e8f1ff;
--lx-color-on-brand: #ffffff;
--lx-color-focus: #1677ff;
--lx-color-success: #00b42a;
--lx-color-warning: #ff7d00;
--lx-color-danger: #f53f3f;
--lx-color-info: #1677ff;
--lx-color-chart-1: #1677ff;
--lx-color-chart-2: #00b42a;
--lx-color-chart-3: #ff7d00;
--lx-color-chart-4: #722ed1;
--lx-color-chart-5: #eb2f96;
/* 字体 */
--lx-font-sans:
'PingFang SC', 'HarmonyOS Sans SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
--lx-font-mono: 'SF Mono', 'JetBrains Mono', 'Roboto Mono', ui-monospace, monospace;
--lx-font-size-xs: 12px;
--lx-font-size-sm: 13px;
--lx-font-size-md: 14px;
--lx-font-size-lg: 16px;
--lx-font-size-xl: 20px;
--lx-font-size-display: 28px;
--lx-line-height-tight: 1.25;
--lx-line-height-normal: 1.55;
/* 间距(4px 基线) */
--lx-space-1: 4px;
--lx-space-2: 8px;
--lx-space-3: 12px;
--lx-space-4: 16px;
--lx-space-5: 20px;
--lx-space-6: 24px;
--lx-space-8: 32px;
--lx-space-10: 40px;
/* 结构尺寸:顶部模块菜单 + 左侧功能菜单 */
--lx-topbar-height: 56px;
--lx-sidebar-width: 216px;
--lx-sidebar-collapsed-width: 56px;
--lx-content-padding: 24px;
--lx-radius-sm: 4px;
--lx-radius-md: 6px;
--lx-radius-lg: 10px;
--lx-radius-pill: 999px;
--lx-shadow-panel: 0 1px 2px rgba(31, 35, 41, 0.04);
--lx-shadow-overlay: 0 12px 32px rgba(31, 35, 41, 0.14);
--lx-shadow-focus: 0 0 0 3px rgba(22, 119, 255, 0.16);
--lx-motion-fast: 120ms;
--lx-motion-base: 180ms;
--lx-ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Semi Design 变量桥接:业务侧只使用 --lx-*,Semi 组件跟随同一套 token */
body[theme-mode='light'] {
--semi-color-primary: var(--lx-color-brand);
--semi-color-primary-hover: var(--lx-color-brand-hover);
--semi-color-primary-active: var(--lx-color-brand-pressed);
--semi-color-primary-disabled: var(--lx-color-border-strong);
--semi-color-primary-light-default: var(--lx-color-brand-soft);
--semi-color-primary-light-hover: #dbe9ff;
--semi-color-primary-light-active: #cfe1ff;
--semi-color-bg-0: var(--lx-color-bg-app);
--semi-color-bg-1: var(--lx-color-bg-panel);
--semi-color-bg-2: var(--lx-color-bg-panel);
--semi-color-bg-3: var(--lx-color-bg-panel);
--semi-color-bg-4: var(--lx-color-bg-hover);
--semi-color-text-0: var(--lx-color-text-primary);
--semi-color-text-1: var(--lx-color-text-primary);
--semi-color-text-2: var(--lx-color-text-secondary);
--semi-color-text-3: var(--lx-color-text-tertiary);
--semi-color-border: var(--lx-color-border);
--semi-color-fill-0: var(--lx-color-bg-hover);
--semi-color-fill-1: #eef1f5;
--semi-color-fill-2: var(--lx-color-bg-active);
--semi-color-danger: var(--lx-color-danger);
--semi-color-success: var(--lx-color-success);
--semi-color-warning: var(--lx-color-warning);
--semi-color-info: var(--lx-color-info);
--semi-color-focus-border: var(--lx-color-focus);
--semi-color-shadow: var(--lx-shadow-overlay);
}
+15
View File
@@ -0,0 +1,15 @@
/**
* 主题 token 的 JS 副本(供 JS 计算场景使用,如 ECharts / Canvas)
* 注意:颜色字面量只允许出现在 packages/ui/src/theme/(工程规范 03 §3.2)。
*/
export const themeTokens = {
colorBrand: '#f5a524',
colorBrandHover: '#ffb84d',
colorSuccess: '#3fb950',
colorWarning: '#d29922',
colorDanger: '#f85149',
colorInfo: '#58a6ff',
chartColors: ['#f5a524', '#58a6ff', '#3fb950', '#d29922', '#a371f7'],
} as const;
export type ThemeTokens = typeof themeTokens;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true,
"types": []
},
"include": ["src"]
}