- 建立 pnpm workspace + Turborepo 单仓、ESLint 9/Prettier/commitlint/husky 门禁 - NestJS + Fastify API:统一错误体、trace_id、Zod 管道、/health 真实依赖探活、OpenAPI 3.1 契约 - Prisma 基线:outbox_events/idempotency_keys/system_configs 与首版迁移、幂等种子 - shared 契约包:错误码、权限点、ULID、游标分页、命名/时间工具 - ui 包与 Web 壳层:Semi + Remix 图标统一出口、Cloud Blue 视觉、登录/工作台/错误页 - worker 骨架、Playwright 冒烟、Gitea CI、OpenAPI 快照与 oasdiff 回退脚本 - 补充 A0 实施报告与本地开发说明
This commit is contained in:
1 parent
9c3ab9768a
commit
04f49690f4
155 files changed
+18402
-28
No files matched your search
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(['工作台', '平台', '用户与组织']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 /> },
|
||||
]);
|
||||
@@ -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];
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_PUBLIC_API_BASE_URL?: string;
|
||||
readonly VITE_PUBLIC_DEMO_SESSION?: string;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user