feat(a0): 完成工程骨架与契约基线
ci / 质量门禁 (push) Canceled after 0s

- 建立 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:
Stev_Wang committed 2026-11-02 17:01:50 +08:00
1 parent 9c3ab9768a
commit 04f49690f4
155 files changed
+18402 -28

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
{
"name": "@lingxi/api",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run --coverage",
"test:int": "vitest run --config vitest.int.config.ts"
},
"dependencies": {
"@fastify/cors": "11.3.1",
"@fastify/helmet": "13.2.0",
"@fastify/static": "10.1.8",
"@lingxi/shared": "workspace:*",
"@nestjs/common": "11.2.8",
"@nestjs/core": "11.2.8",
"@nestjs/platform-fastify": "11.2.8",
"@nestjs/swagger": "11.4.7",
"@prisma/client": "6.19.3",
"dotenv": "18.0.9",
"fastify": "5.12.7",
"ioredis": "5.11.1",
"nestjs-pino": "5.3.1",
"pino": "10.4.0",
"pino-http": "11.0.0",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.2",
"zod": "3.25.76"
},
"devDependencies": {
"@nestjs/testing": "11.2.8",
"@types/node": "24.19.2",
"@vitest/coverage-v8": "3.2.7",
"pino-pretty": "13.2.0",
"tsx": "4.23.15",
"typescript": "5.9.3",
"vitest": "3.2.7"
}
}
+51
View File
@@ -0,0 +1,51 @@
import { REQUEST_ID_HEADER, ulid } from '@lingxi/shared';
import { Module } from '@nestjs/common';
import { LoggerModule } from 'nestjs-pino';
import { APP_CONFIG, AppConfigModule } from './config/config.module';
import type { AppConfig } from './config/env';
import { PrismaModule } from './infra/prisma/prisma.module';
import { RedisModule } from './infra/redis/redis.module';
import { PlatformModule } from './modules/platform/platform.module';
@Module({
imports: [
AppConfigModule,
LoggerModule.forRootAsync({
imports: [AppConfigModule],
inject: [APP_CONFIG],
useFactory: (config: AppConfig) => ({
pinoHttp: {
level: config.logLevel,
genReqId: (request, response) => {
const header = request.headers[REQUEST_ID_HEADER];
const existing = Array.isArray(header) ? header[0] : header;
const traceId = typeof existing === 'string' && existing.length > 0 ? existing : ulid();
response.setHeader(REQUEST_ID_HEADER, traceId);
return traceId;
},
customProps: (request) => ({ trace_id: request.id }),
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
censor: '[redacted]',
},
autoLogging: {
ignore: (request) => request.url === '/health',
},
...(config.nodeEnv === 'development' && process.env.VITEST === undefined
? {
transport: {
target: 'pino-pretty',
options: { singleLine: true, translateTime: 'SYS:HH:MM:ss.l' },
},
}
: {}),
},
}),
}),
PrismaModule,
RedisModule,
PlatformModule,
],
})
export class AppModule {}
+44
View File
@@ -0,0 +1,44 @@
import helmet from '@fastify/helmet';
import { API_PREFIX } from '@lingxi/shared';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { APP_CONFIG } from './config/config.module';
import type { AppConfig } from './config/env';
import { buildOpenApiDocument, setupOpenApi } from './openapi/document';
export interface CreatedApiApp {
app: NestFastifyApplication;
config: AppConfig;
}
/** 创建并配置 API 应用(测试与 OpenAPI 导出复用,不负责监听端口) */
export async function createApp(): Promise<CreatedApiApp> {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ trustProxy: true, bodyLimit: 1024 * 1024 }),
{ bufferLogs: true },
);
const config = app.get<AppConfig>(APP_CONFIG);
app.useLogger(app.get(Logger));
app.setGlobalPrefix(API_PREFIX, { exclude: ['health'] });
app.useGlobalFilters(new AllExceptionsFilter());
app.enableCors({
origin: config.webOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
});
// 安全响应头;Swagger UI 需要内联脚本,CSP 在关闭文档页后再开启
await app.register(helmet, { contentSecurityPolicy: false });
app.enableShutdownHooks();
return { app, config };
}
export { buildOpenApiDocument, setupOpenApi };
@@ -0,0 +1,25 @@
import { ERROR_MESSAGES, type ErrorCode, httpStatusOf } from '@lingxi/shared';
export interface AppExceptionOptions {
code: ErrorCode;
message?: string;
details?: unknown;
}
/**
* 业务异常:由统一异常过滤器序列化为 { code, message, details, trace_id }。
* 业务代码禁止直接 throw new Error 表达可预期错误。
*/
export class AppException extends Error {
readonly code: ErrorCode;
readonly status: number;
readonly details?: unknown;
constructor(options: AppExceptionOptions) {
super(options.message ?? ERROR_MESSAGES[options.code]);
this.name = 'AppException';
this.code = options.code;
this.status = httpStatusOf(options.code);
this.details = options.details;
}
}
@@ -0,0 +1,46 @@
import { ErrorCode } from '@lingxi/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, type TestApp } from '../../testing/test-app';
describe('统一异常过滤器(冒烟)', () => {
let testApp: TestApp;
beforeAll(async () => {
testApp = await createTestApp({ includeErrorRoutes: true });
});
afterAll(async () => {
await testApp.app.close();
});
it('AppException 输出业务错误码与 trace_id', async () => {
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/app-error' });
expect(response.statusCode).toBe(409);
const body = response.json<{ code: number; message: string; trace_id: string }>();
expect(body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
expect(body.message).toBe('当前状态不允许该操作');
expect(body.trace_id.length).toBeGreaterThan(0);
});
it('HttpException 404 映射为 40401', async () => {
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/http-404' });
expect(response.statusCode).toBe(404);
expect(response.json<{ code: number }>().code).toBe(ErrorCode.NOT_FOUND);
});
it('未知异常收敛为 50001,不泄露内部信息', async () => {
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/boom' });
expect(response.statusCode).toBe(500);
const body = response.json<{ code: number; message: string; trace_id: string }>();
expect(body.code).toBe(ErrorCode.INTERNAL_ERROR);
expect(body.message).toBe('服务内部错误');
expect(body.trace_id.length).toBeGreaterThan(0);
});
it('未注册路由返回统一 404 错误体', async () => {
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/not-exists' });
expect(response.statusCode).toBe(404);
expect(response.json<{ code: number }>().code).toBe(ErrorCode.NOT_FOUND);
});
});
@@ -0,0 +1,121 @@
import { type ApiErrorBody, ErrorCode, httpStatusOf, isErrorCode } from '@lingxi/shared';
import { type ArgumentsHost, Catch, type ExceptionFilter, HttpException } from '@nestjs/common';
import { AppException } from '../errors/app-exception';
interface RequestWithTrace {
id?: string;
url?: string;
method?: string;
log?: {
error: (payload: unknown, message?: string) => void;
};
}
interface ReplyLike {
status: (code: number) => { send: (body: unknown) => void };
}
function codeFromHttpStatus(status: number): ErrorCode {
switch (status) {
case 400:
return ErrorCode.PARAM_INVALID;
case 401:
return ErrorCode.UNAUTHENTICATED;
case 403:
return ErrorCode.PERMISSION_DENIED;
case 404:
return ErrorCode.NOT_FOUND;
case 409:
return ErrorCode.STATE_NOT_ALLOWED;
case 422:
return ErrorCode.BUSINESS_RULE_VIOLATION;
case 429:
return ErrorCode.RATE_LIMITED;
case 502:
case 503:
return ErrorCode.DEPENDENCY_UNAVAILABLE;
case 504:
return ErrorCode.TIMEOUT;
default:
return status >= 400 && status < 500 ? ErrorCode.PARAM_INVALID : ErrorCode.INTERNAL_ERROR;
}
}
function messageFromHttpException(exception: HttpException): string {
const response = exception.getResponse();
if (typeof response === 'string') {
return response;
}
if ('message' in response) {
const message = response.message;
if (typeof message === 'string') {
return message;
}
if (Array.isArray(message) && message.length > 0 && typeof message[0] === 'string') {
return message.join('; ');
}
}
return exception.message;
}
/**
* 全局异常过滤器:所有错误统一输出 { code, message, details, trace_id }(工程规范 04)。
* trace_id 与 Pino 的 request id 同源,响应用户报障时可反查日志。
*/
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const context = host.switchToHttp();
const request = context.getRequest<RequestWithTrace>();
const reply = context.getResponse<ReplyLike>();
const traceId = request.id ?? '';
let status = 500;
let body: ApiErrorBody;
if (exception instanceof AppException) {
status = exception.status;
body = {
code: exception.code,
message: exception.message,
...(exception.details === undefined ? {} : { details: exception.details }),
trace_id: traceId,
};
} else if (exception instanceof HttpException) {
status = exception.getStatus();
const code = codeFromHttpStatus(status);
body = {
code,
message: status >= 500 ? '服务内部错误' : messageFromHttpException(exception),
trace_id: traceId,
};
} else {
body = {
code: ErrorCode.INTERNAL_ERROR,
message: '服务内部错误',
trace_id: traceId,
};
}
if (status >= 500) {
request.log?.error(
{
err: exception,
trace_id: traceId,
method: request.method,
url: request.url,
code: body.code,
},
'request failed',
);
}
reply.status(status).send(body);
}
}
/** 供测试或未来的 OpenAPI 生成复用:确认错误码与 HTTP 状态一致 */
export function httpStatusForCode(code: number): number {
return isErrorCode(code) ? httpStatusOf(code) : 500;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';
/** OpenAPI 错误响应模型(工程规范 04 统一错误体) */
export class ErrorResponseDto {
@ApiProperty({ type: Number, description: '错误码(见 ErrorCode 常量)', example: 42202 })
code!: number;
@ApiProperty({ type: String, description: '可直接展示给用户的提示', example: '内容触发合规拦截' })
message!: string;
@ApiProperty({
type: Object,
description: '补充信息(字段错误、命中规则等),结构由错误码决定',
required: false,
example: { issues: [{ path: 'name', message: 'Required' }] },
})
details?: unknown;
@ApiProperty({
type: String,
description: '请求追踪 ID,与日志同源',
example: '01JABCDEF0123456789ABCDEFG',
})
trace_id!: string;
}
@@ -0,0 +1,39 @@
import { ErrorCode } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { AppException } from '../errors/app-exception';
import { ZodValidationPipe } from './zod-validation.pipe';
const schema = z.object({
name: z.string().min(2),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
describe('ZodValidationPipe', () => {
it('通过校验并返回转换后的值', () => {
const pipe = new ZodValidationPipe(schema);
expect(pipe.transform({ name: '素材', limit: '30' })).toEqual({ name: '素材', limit: 30 });
});
it('缺省值生效', () => {
const pipe = new ZodValidationPipe(schema);
expect(pipe.transform({ name: '素材' })).toEqual({ name: '素材', limit: 20 });
});
it('校验失败抛 40001,details 带字段错误', () => {
const pipe = new ZodValidationPipe(schema);
try {
pipe.transform({ name: 'x' });
throw new Error('应当抛错');
} catch (error) {
expect(error).toBeInstanceOf(AppException);
const appError = error instanceof AppException ? error : null;
expect(appError?.code).toBe(ErrorCode.PARAM_INVALID);
expect(appError?.status).toBe(400);
expect(appError?.details).toMatchObject({
issues: [{ path: 'name' }],
});
}
});
});
@@ -0,0 +1,30 @@
import { ErrorCode } from '@lingxi/shared';
import { type PipeTransform } from '@nestjs/common';
import type { ZodSchema } from 'zod';
import { AppException } from '../errors/app-exception';
/**
* Zod 校验管道(静态 DTO):body / query / params 共用。
* 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。
*/
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodSchema<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: '参数校验失败',
details: {
issues: result.error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
})),
},
});
}
return result.data;
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { type AppConfig, loadEnv } from './env';
export const APP_CONFIG = Symbol('APP_CONFIG');
@Global()
@Module({
providers: [
{
provide: APP_CONFIG,
useFactory: (): AppConfig => loadEnv(),
},
],
exports: [APP_CONFIG],
})
export class AppConfigModule {}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { loadEnv } from './env';
const validEnv: NodeJS.ProcessEnv = {
NODE_ENV: 'test',
DATABASE_URL: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test',
REDIS_URL: 'redis://127.0.0.1:16379',
WEB_ORIGIN: 'http://127.0.0.1:5173,http://localhost:5173',
};
describe('loadEnv', () => {
it('解析必填项并给可选项默认值', () => {
const config = loadEnv(validEnv);
expect(config.nodeEnv).toBe('test');
expect(config.isProduction).toBe(false);
expect(config.port).toBe(3000);
expect(config.host).toBe('0.0.0.0');
expect(config.logLevel).toBe('info');
expect(config.maxUploadMb).toBe(500);
expect(config.featureVideo).toBe(false);
expect(config.webOrigins).toEqual(['http://127.0.0.1:5173', 'http://localhost:5173']);
});
it('字符串数字会被转成 number', () => {
const config = loadEnv({ ...validEnv, PORT: '8080', MAX_UPLOAD_MB: '200' });
expect(config.port).toBe(8080);
expect(config.maxUploadMb).toBe(200);
});
it('缺少 DATABASE_URL 时抛错并带字段名', () => {
expect(() => loadEnv({ ...validEnv, DATABASE_URL: undefined })).toThrow(/DATABASE_URL/);
});
it('拒绝非 postgres 连接串', () => {
expect(() => loadEnv({ ...validEnv, DATABASE_URL: 'mysql://localhost/db' })).toThrow(
/DATABASE_URL/,
);
});
it('拒绝非法 LOG_LEVEL', () => {
expect(() => loadEnv({ ...validEnv, LOG_LEVEL: 'verbose' })).toThrow(/LOG_LEVEL/);
});
it('FEATURE_VIDEO=true 转为布尔值', () => {
expect(loadEnv({ ...validEnv, FEATURE_VIDEO: 'true' }).featureVideo).toBe(true);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
HOST: z.string().min(1).default('0.0.0.0'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
DATABASE_URL: z
.string()
.min(1)
.refine(
(value) => value.startsWith('postgresql://') || value.startsWith('postgres://'),
'DATABASE_URL 必须是 postgresql:// 连接串',
),
REDIS_URL: z
.string()
.min(1)
.refine(
(value) => value.startsWith('redis://') || value.startsWith('rediss://'),
'REDIS_URL 必须是 redis:// 连接串',
),
WEB_ORIGIN: z.string().default('http://127.0.0.1:5173'),
MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(10240).default(500),
FEATURE_VIDEO: z
.enum(['true', 'false'])
.default('false')
.transform((value) => value === 'true'),
});
export type RawEnv = z.infer<typeof envSchema>;
export interface AppConfig {
nodeEnv: RawEnv['NODE_ENV'];
isProduction: boolean;
port: number;
host: string;
logLevel: RawEnv['LOG_LEVEL'];
databaseUrl: string;
redisUrl: string;
webOrigins: string[];
maxUploadMb: number;
featureVideo: boolean;
}
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig {
const result = envSchema.safeParse(source);
if (!result.success) {
const details = result.error.issues
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
.join('; ');
throw new Error(`环境变量校验失败:${details}`);
}
const env = result.data;
return {
nodeEnv: env.NODE_ENV,
isProduction: env.NODE_ENV === 'production',
port: env.PORT,
host: env.HOST,
logLevel: env.LOG_LEVEL,
databaseUrl: env.DATABASE_URL,
redisUrl: env.REDIS_URL,
webOrigins: env.WEB_ORIGIN.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0),
maxUploadMb: env.MAX_UPLOAD_MB,
featureVideo: env.FEATURE_VIDEO,
};
}
+17
View File
@@ -0,0 +1,17 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { config as loadDotenvFile } from 'dotenv';
/**
* 按工程规范 09 的顺序加载本地环境变量:.env.local 优先,.env 兜底。
* 只做加载,不做校验(校验见 config/env.ts)。
*/
export function loadDotenv(): void {
for (const file of ['.env.local', '.env']) {
const path = resolve(process.cwd(), file);
if (existsSync(path)) {
loadDotenvFile({ path });
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp, type CreatedApiApp } from './bootstrap';
import { loadDotenv } from './config/load-dotenv';
/**
* 集成测试:需要 pnpm compose:up 后真实 PG / Redis。
* 单独运行:pnpm test:int
*/
describe('GET /health(集成)', () => {
let created: CreatedApiApp;
beforeAll(async () => {
loadDotenv();
created = await createApp();
await created.app.init();
});
afterAll(async () => {
await created.app.close();
});
it('真实依赖健康时返回 200', async () => {
const response = await created.app.inject({ method: 'GET', url: '/health' });
expect(response.statusCode).toBe(200);
const body = response.json<{
status: string;
checks: { database: { status: string }; redis: { status: string } };
}>();
expect(body.status).toBe('ok');
expect(body.checks.database.status).toBe('up');
expect(body.checks.redis.status).toBe('up');
});
});
+41
View File
@@ -0,0 +1,41 @@
import { ErrorCode } from '@lingxi/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, type TestApp } from './testing/test-app';
describe('GET /health(冒烟)', () => {
let testApp: TestApp;
beforeAll(async () => {
testApp = await createTestApp();
});
afterAll(async () => {
await testApp.app.close();
});
it('依赖健康时返回 200 与检查明细', async () => {
const response = await testApp.app.inject({ method: 'GET', url: '/health' });
expect(response.statusCode).toBe(200);
const body = response.json<{
status: string;
checks: { database: { status: string }; redis: { status: string } };
}>();
expect(body.status).toBe('ok');
expect(body.checks.database.status).toBe('up');
expect(body.checks.redis.status).toBe('up');
});
});
describe('GET /health(依赖异常)', () => {
it('数据库不可用时返回 503 与统一错误体', async () => {
const testApp = await createTestApp({ databaseUp: false });
const response = await testApp.app.inject({ method: 'GET', url: '/health' });
expect(response.statusCode).toBe(503);
const body = response.json<{ code: number; message: string; trace_id: string }>();
expect(body.code).toBe(ErrorCode.DEPENDENCY_UNAVAILABLE);
expect(body.message).toBe('依赖服务不可用');
expect(body.trace_id.length).toBeGreaterThan(0);
await testApp.app.close();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { testConfig } from '../testing/test-app';
import { PrismaService } from './prisma/prisma.service';
import { RedisService } from './redis/redis.service';
describe('PrismaService', () => {
it('关闭应用时断开 Prisma 连接', async () => {
const service = new PrismaService(testConfig);
const disconnect = vi.spyOn(service, '$disconnect').mockResolvedValue();
await service.onModuleDestroy();
expect(disconnect).toHaveBeenCalledTimes(1);
});
});
describe('RedisService', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('ping 返回 PONG', async () => {
const service = new RedisService(testConfig);
const client = service.getClient();
vi.spyOn(client, 'connect').mockResolvedValue(undefined);
vi.spyOn(client, 'ping').mockResolvedValue('PONG');
await expect(service.ping()).resolves.toBe('PONG');
});
it('关闭应用时断开 Redis 连接', () => {
const service = new RedisService(testConfig);
const client = service.getClient();
const disconnect = vi.spyOn(client, 'disconnect').mockReturnValue(undefined);
service.onModuleDestroy();
expect(disconnect).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
import { PRISMA } from '../tokens';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [
{
provide: PRISMA,
inject: [APP_CONFIG],
useFactory: (config: AppConfig): PrismaService => new PrismaService(config),
},
],
exports: [PRISMA],
})
export class PrismaModule {}
@@ -0,0 +1,24 @@
import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
/**
* Prisma 封装。
* 不主动 $connect:Prisma 首次查询时才建连接,这样 OpenAPI 导出、单元测试不需要真实数据库;
* /health 会执行 SELECT 1 做真实检查。
*/
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleDestroy {
constructor(@Inject(APP_CONFIG) config: AppConfig) {
super({
datasources: { db: { url: config.databaseUrl } },
log: config.isProduction ? ['error'] : ['warn', 'error'],
});
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
import { REDIS } from '../tokens';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [
{
provide: REDIS,
inject: [APP_CONFIG],
useFactory: (config: AppConfig): RedisService => new RedisService(config),
},
],
exports: [REDIS],
})
export class RedisModule {}
+44
View File
@@ -0,0 +1,44 @@
import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
/**
* Redis 客户端封装。
* lazyConnect:不在应用启动时连接,避免 OpenAPI 导出 / 单测依赖真实 Redis;
* /health 调 ping 时才会真正连接。
*/
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly client: Redis;
constructor(@Inject(APP_CONFIG) config: AppConfig) {
this.client = new Redis(config.redisUrl, {
lazyConnect: true,
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
});
this.client.on('error', () => {
// 连接错误由健康检查与请求日志暴露,这里只避免 ioredis 抛未处理异常
});
}
getClient(): Redis {
return this.client;
}
async ping(): Promise<'PONG'> {
if (this.client.status === 'wait') {
await this.client.connect();
}
await this.client.ping();
return 'PONG';
}
onModuleDestroy(): void {
if (this.client.status !== 'end') {
this.client.disconnect();
}
}
}
+3
View File
@@ -0,0 +1,3 @@
/** 依赖注入 token:避免跨模块直接依赖具体实现 */
export const PRISMA = Symbol('PRISMA');
export const REDIS = Symbol('REDIS');
+24
View File
@@ -0,0 +1,24 @@
import 'reflect-metadata';
import { Logger } from 'nestjs-pino';
import { createApp, setupOpenApi } from './bootstrap';
import { loadDotenv } from './config/load-dotenv';
async function bootstrap(): Promise<void> {
loadDotenv();
const { app, config } = await createApp();
await app.init();
setupOpenApi(app, config);
await app.listen(config.port, config.host);
const logger = app.get(Logger);
logger.log(`灵犀 API 已启动:http://${config.host}:${config.port}(文档 /api/docs)`);
}
void bootstrap().catch((error: unknown) => {
process.stderr.write(
`API 启动失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
);
process.exitCode = 1;
});
@@ -0,0 +1,25 @@
import { Controller, Get, Inject } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { HealthService } from './health.service';
import { type HealthReport, HealthResponseDto } from './health.types';
@ApiTags('platform')
@Controller('health')
export class HealthController {
constructor(@Inject(HealthService) private readonly healthService: HealthService) {}
@Get()
@ApiOperation({
summary: '健康检查',
description: '真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。',
})
@ApiOkResponse({ type: HealthResponseDto, description: '全部依赖健康' })
@ApiResponse({
status: 503,
description: '依赖不可用(错误码 50002),details 内附各依赖检查结果',
})
async check(): Promise<HealthReport> {
return this.healthService.check();
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [HealthService],
})
export class HealthModule {}
@@ -0,0 +1,94 @@
import { ErrorCode, nowIso } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { AppException } from '../../../common/errors/app-exception';
import { APP_CONFIG } from '../../../config/config.module';
import type { AppConfig } from '../../../config/env';
import type { PrismaService } from '../../../infra/prisma/prisma.service';
import type { RedisService } from '../../../infra/redis/redis.service';
import { PRISMA } from '../../../infra/tokens';
import { REDIS } from '../../../infra/tokens';
import type { ComponentHealth, HealthReport } from './health.types';
const API_VERSION = '0.1.0';
const CHECK_TIMEOUT_MS = 2000;
function withTimeout<T>(task: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`检查超时(${String(timeoutMs)}ms)`));
}, timeoutMs);
void task.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}
/**
* 健康检查:真实检查数据库(SELECT 1)与 Redis(PING),不只看端口(技术架构 §4.1.1)。
* 任一依赖不可用时返回 503 + 统一错误体,details 里带完整检查快照。
*/
@Injectable()
export class HealthService {
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(REDIS) private readonly redis: RedisService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
async check(): Promise<HealthReport> {
const [database, redis] = await Promise.all([this.checkDatabase(), this.checkRedis()]);
const report: HealthReport = {
status: database.status === 'up' && redis.status === 'up' ? 'ok' : 'degraded',
env: this.config.nodeEnv,
version: API_VERSION,
uptimeSeconds: Math.round(process.uptime()),
checks: { database, redis },
timestamp: nowIso(),
};
if (report.status === 'degraded') {
throw new AppException({
code: ErrorCode.DEPENDENCY_UNAVAILABLE,
message: '依赖服务不可用',
details: report.checks,
});
}
return report;
}
private async checkDatabase(): Promise<ComponentHealth> {
const startedAt = Date.now();
try {
await withTimeout(this.prisma.$queryRaw`SELECT 1`, CHECK_TIMEOUT_MS);
return { status: 'up', latencyMs: Date.now() - startedAt };
} catch (error) {
return {
status: 'down',
latencyMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : '未知错误',
};
}
}
private async checkRedis(): Promise<ComponentHealth> {
const startedAt = Date.now();
try {
await withTimeout(this.redis.ping(), CHECK_TIMEOUT_MS);
return { status: 'up', latencyMs: Date.now() - startedAt };
} catch (error) {
return {
status: 'down',
latencyMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : '未知错误',
};
}
}
}
@@ -0,0 +1,58 @@
import { ApiProperty } from '@nestjs/swagger';
export interface ComponentHealth {
status: 'up' | 'down';
latencyMs: number;
error?: string;
}
export interface HealthReport {
status: 'ok' | 'degraded';
env: string;
version: string;
uptimeSeconds: number;
checks: {
database: ComponentHealth;
redis: ComponentHealth;
};
timestamp: string;
}
export class ComponentHealthDto {
@ApiProperty({ type: String, enum: ['up', 'down'], example: 'up' })
status!: 'up' | 'down';
@ApiProperty({ type: Number, description: '检查耗时(毫秒)', example: 3 })
latencyMs!: number;
@ApiProperty({ type: String, required: false, description: '失败原因(仅 down 时返回)' })
error?: string;
}
export class HealthChecksDto {
@ApiProperty({ type: ComponentHealthDto })
database!: ComponentHealthDto;
@ApiProperty({ type: ComponentHealthDto })
redis!: ComponentHealthDto;
}
export class HealthResponseDto {
@ApiProperty({ type: String, enum: ['ok', 'degraded'], example: 'ok' })
status!: 'ok' | 'degraded';
@ApiProperty({ type: String, example: 'development' })
env!: string;
@ApiProperty({ type: String, example: '0.1.0' })
version!: string;
@ApiProperty({ type: Number, description: '进程运行秒数', example: 128 })
uptimeSeconds!: number;
@ApiProperty({ type: HealthChecksDto })
checks!: HealthChecksDto;
@ApiProperty({ type: String, example: '2026-11-02T08:00:00.000Z' })
timestamp!: string;
}
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { HealthModule } from './health/health.module';
/**
* platform 模块:健康检查、系统配置、审计、AI 网关等平台能力的归属模块(技术架构 §3)。
* A0 只落地健康检查;其余能力按里程碑在本模块内新增。
*/
@Global()
@Module({
imports: [HealthModule],
exports: [HealthModule],
})
export class PlatformModule {}
+36
View File
@@ -0,0 +1,36 @@
import { ErrorCode } from '@lingxi/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, type TestApp,testConfig } from '../testing/test-app';
import { buildOpenApiDocument } from './document';
describe('OpenAPI 产物(冒烟)', () => {
let testApp: TestApp;
beforeAll(async () => {
testApp = await createTestApp();
});
afterAll(async () => {
await testApp.app.close();
});
it('版本为 3.1 且包含健康检查路由', () => {
const document = buildOpenApiDocument(testApp.app, testConfig);
expect(document.openapi).toBe('3.1.0');
expect(Object.keys(document.paths).some((path) => path.endsWith('/health'))).toBe(true);
expect(document.info.version).toBe('0.1.0');
});
it('包含全部错误码枚举与统一错误体', () => {
const document = buildOpenApiDocument(testApp.app, testConfig);
const schemas = document.components?.schemas ?? {};
expect(schemas.ErrorCode).toBeDefined();
expect(schemas.ErrorResponse).toBeDefined();
const errorCodeSchema = schemas.ErrorCode;
expect(errorCodeSchema && 'enum' in errorCodeSchema ? errorCodeSchema.enum : []).toHaveLength(
Object.values(ErrorCode).length,
);
expect('x-error-codes' in document).toBe(true);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { ErrorCode } from '@lingxi/shared';
import { type INestApplication } from '@nestjs/common';
import type { OpenAPIObject } from '@nestjs/swagger';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ErrorResponseDto } from '../common/http/error-response.dto';
import type { AppConfig } from '../config/env';
/**
* 生成 OpenAPI 文档。
* - 版本固定 3.1(技术架构:OpenAPI 3.1 + oasdiff breaking 检测)
* - 错误码以 ErrorCode schema + x-error-codes 扩展显式写入产物,供前端 codegen 与契约比对
*/
export function buildOpenApiDocument(app: INestApplication, config: AppConfig): OpenAPIObject {
const builder = new DocumentBuilder()
.setTitle('灵犀 API')
.setDescription(
[
'信息流广告素材生产平台后端 API。',
'',
'统一约定:',
'- 前缀 /api/v1(健康检查 /health 除外)',
'- 错误体 { code, message, details, trace_id }',
'- 列表响应 { items, next_cursor }',
].join('\n'),
)
.setVersion('0.1.0')
.setOpenAPIVersion('3.1.0')
.addServer('/api/v1', 'API v1')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, builder, {
extraModels: [ErrorResponseDto],
});
const components = document.components ?? {};
const schemas = components.schemas ?? {};
schemas.ErrorCode = {
type: 'integer',
description: '业务错误码(只允许使用 @lingxi/shared 中登记的错误码)',
enum: Object.values(ErrorCode),
};
schemas.ErrorResponse = {
type: 'object',
required: ['code', 'message', 'trace_id'],
properties: {
code: { $ref: '#/components/schemas/ErrorCode' },
message: { type: 'string' },
details: { description: '补充信息,结构由错误码决定' },
trace_id: { type: 'string' },
},
};
document.components = { ...components, schemas };
Object.assign(document, {
'x-error-codes': Object.values(ErrorCode).map((code) => ({ code })),
'x-api-config': {
env: config.nodeEnv,
featureVideo: config.featureVideo,
},
});
return document;
}
export function setupOpenApi(app: INestApplication, config: AppConfig): OpenAPIObject {
const document = buildOpenApiDocument(app, config);
SwaggerModule.setup('api/docs', app, document, {
jsonDocumentUrl: 'api/openapi.json',
});
return document;
}
+30
View File
@@ -0,0 +1,30 @@
import 'reflect-metadata';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { createApp } from '../bootstrap';
import { loadDotenv } from '../config/load-dotenv';
import { buildOpenApiDocument } from '../openapi/document';
async function main(): Promise<void> {
loadDotenv();
const { app, config } = await createApp();
await app.init();
const document = buildOpenApiDocument(app, config);
const outputDir = resolve(process.cwd(), 'openapi');
const outputFile = resolve(outputDir, 'lingxi.openapi.json');
await mkdir(outputDir, { recursive: true });
await writeFile(outputFile, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
process.stdout.write(`OpenAPI 已导出:${outputFile}\n`);
await app.close();
}
void main().catch((error: unknown) => {
process.stderr.write(
`OpenAPI 导出失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
);
process.exitCode = 1;
});
+91
View File
@@ -0,0 +1,91 @@
import { API_PREFIX, ErrorCode } from '@lingxi/shared';
import { Controller, Get, NotFoundException } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import { type Mock, vi } from 'vitest';
import { AppException } from '../common/errors/app-exception';
import { AllExceptionsFilter } from '../common/filters/all-exceptions.filter';
import { APP_CONFIG } from '../config/config.module';
import type { AppConfig } from '../config/env';
import { PRISMA, REDIS } from '../infra/tokens';
import { HealthController } from '../modules/platform/health/health.controller';
import { HealthService } from '../modules/platform/health/health.service';
export const testConfig: AppConfig = {
nodeEnv: 'test',
isProduction: false,
port: 3000,
host: '127.0.0.1',
logLevel: 'error',
databaseUrl: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test',
redisUrl: 'redis://127.0.0.1:16379',
webOrigins: ['http://127.0.0.1:5173'],
maxUploadMb: 500,
featureVideo: false,
};
@Controller('__test')
class ThrowingController {
@Get('app-error')
appError(): never {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '当前状态不允许该操作' });
}
@Get('http-404')
notFound(): never {
throw new NotFoundException('资源不存在');
}
@Get('boom')
boom(): never {
throw new Error('unexpected failure');
}
}
export interface TestAppOptions {
databaseUp?: boolean;
redisUp?: boolean;
includeErrorRoutes?: boolean;
}
export interface TestApp {
app: NestFastifyApplication;
prisma: { $queryRaw: Mock };
redis: { ping: Mock };
}
export async function createTestApp(options: TestAppOptions = {}): Promise<TestApp> {
const prisma = {
$queryRaw:
options.databaseUp === false
? vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:15432'))
: vi.fn().mockResolvedValue([{ '?column?': 1 }]),
};
const redis = {
ping:
options.redisUp === false
? vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:16379'))
: vi.fn().mockResolvedValue('PONG'),
};
const moduleRef = await Test.createTestingModule({
controllers:
options.includeErrorRoutes === true
? [HealthController, ThrowingController]
: [HealthController],
providers: [
HealthService,
{ provide: PRISMA, useValue: prisma },
{ provide: REDIS, useValue: redis },
{ provide: APP_CONFIG, useValue: testConfig },
],
}).compile();
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.setGlobalPrefix(API_PREFIX, { exclude: ['health'] });
app.useGlobalFilters(new AllExceptionsFilter());
await app.init();
return { app, prisma, redis };
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"declarationMap": false,
"sourceMap": true
},
"include": ["src"],
"exclude": ["src/**/*.spec.ts", "src/**/*.int.spec.ts", "src/scripts/**"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022"],
"types": ["node"],
"noEmit": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": false
},
"include": ["src", "vitest.config.ts", "vitest.int.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.spec.ts'],
exclude: ['**/*.int.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'json-summary'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.spec.ts', 'src/**/*.int.spec.ts', 'src/main.ts', 'src/scripts/**'],
thresholds: { lines: 60, functions: 60, statements: 60, branches: 60 },
},
},
});
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.int.spec.ts'],
testTimeout: 30_000,
hookTimeout: 30_000,
},
});
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>灵犀 · 信息流素材生产平台</title>
</head>
<body theme-mode="light">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@lingxi/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json --noEmit && vite build",
"preview": "vite preview --port 4173 --strictPort",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run --coverage"
},
"dependencies": {
"@douyinfe/semi-ui-19": "2.104.0",
"@lingxi/shared": "workspace:*",
"@lingxi/ui": "workspace:*",
"@tanstack/react-query": "5.105.1",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-router": "7.18.5",
"zod": "3.25.76",
"zustand": "5.0.16"
},
"devDependencies": {
"@testing-library/react": "16.3.3",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "5.2.0",
"@vitest/coverage-v8": "3.2.7",
"jsdom": "28.1.0",
"typescript": "5.9.3",
"vite": "6.4.5",
"vitest": "3.2.7"
}
}
+141
View File
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { ApiError, request, setUnauthorizedHandler } from './client';
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
const emptyResponse = (status: number): Response => new Response(null, { status });
function signIn(accessToken = 'token-1', refreshToken = 'refresh-1'): void {
useAuthStore.getState().setSession({
accessToken,
refreshToken,
user: { id: '01J0TEST000000000000000000', name: '测试用户', email: 'test@lingxi.local' },
permissions: [],
});
}
function authorizationOf(init: RequestInit | undefined): string | null {
return init?.headers instanceof Headers ? init.headers.get('Authorization') : null;
}
function toUrl(input: string | URL | Request | undefined): string {
if (typeof input === 'string') {
return input;
}
if (input instanceof URL) {
return input.href;
}
return input?.url ?? '';
}
describe('请求层', () => {
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
useAuthStore.getState().clearSession();
setUnauthorizedHandler(null);
});
it('注入 Bearer token 并解析 JSON', async () => {
signIn();
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(jsonResponse({ status: 'ok' }));
await expect(request<{ status: string }>('/health')).resolves.toEqual({ status: 'ok' });
const init = fetchMock.mock.calls[0]?.[1];
expect(authorizationOf(init)).toBe('Bearer token-1');
expect(toUrl(fetchMock.mock.calls[0]?.[0])).toBe('http://127.0.0.1:3000/api/v1/health');
});
it('auth: false 时不注入 token', async () => {
signIn();
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ ok: true }));
await request('/auth/login', {
method: 'POST',
auth: false,
body: { email: 'a', password: 'b' },
});
expect(authorizationOf(fetchMock.mock.calls[0]?.[1])).toBeNull();
});
it('204 返回 undefined', async () => {
signIn();
vi.spyOn(globalThis, 'fetch').mockResolvedValue(emptyResponse(204));
await expect(request('/resource')).resolves.toBeUndefined();
});
it('统一错误体解析为 ApiError,保留 trace_id', async () => {
signIn();
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
jsonResponse(
{ code: 42201, message: '业务校验失败', details: { field: 'name' }, trace_id: 'trace-1' },
422,
),
);
const error = await request('/resource').catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(ApiError);
const apiError = error instanceof ApiError ? error : null;
expect(apiError?.code).toBe(42201);
expect(apiError?.status).toBe(422);
expect(apiError?.traceId).toBe('trace-1');
expect(apiError?.details).toEqual({ field: 'name' });
});
it('401 时单飞刷新并重放原请求', async () => {
signIn('expired-token', 'refresh-1');
let refreshCalls = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
const url = toUrl(input);
const authorization = authorizationOf(init);
if (url.endsWith('/auth/refresh')) {
refreshCalls += 1;
return Promise.resolve(
jsonResponse({ accessToken: 'new-token', refreshToken: 'refresh-2' }),
);
}
if (authorization === 'Bearer expired-token') {
return Promise.resolve(emptyResponse(401));
}
return Promise.resolve(jsonResponse({ ok: true }));
});
const [first, second] = await Promise.all([request('/a'), request('/b')]);
expect(first).toEqual({ ok: true });
expect(second).toEqual({ ok: true });
expect(refreshCalls).toBe(1);
expect(useAuthStore.getState().accessToken).toBe('new-token');
});
it('刷新失败时清空会话并触发未授权回调', async () => {
signIn('expired-token', 'refresh-1');
const onUnauthorized = vi.fn();
setUnauthorizedHandler(onUnauthorized);
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
const url = toUrl(input);
return Promise.resolve(
url.endsWith('/auth/refresh') ? emptyResponse(401) : emptyResponse(401),
);
});
await expect(request('/secure')).rejects.toBeInstanceOf(ApiError);
expect(onUnauthorized).toHaveBeenCalledTimes(1);
expect(useAuthStore.getState().accessToken).toBeNull();
});
it('把请求体序列化为 JSON', async () => {
signIn();
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ ok: true }));
await request('/resource', { method: 'POST', body: { name: '素材' } });
const init = fetchMock.mock.calls[0]?.[1];
expect(init?.body).toBe(JSON.stringify({ name: '素材' }));
expect(init?.method).toBe('POST');
});
});
+199
View File
@@ -0,0 +1,199 @@
import { apiErrorBodySchema } from '@lingxi/shared';
import { useAuthStore } from '@/stores/auth-store';
const DEFAULT_BASE_URL = 'http://127.0.0.1:3000/api/v1';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export interface RequestOptions {
method?: HttpMethod;
body?: unknown;
headers?: Record<string, string>;
signal?: AbortSignal;
/** 登录、刷新等接口不需要携带 token */
auth?: boolean;
}
export interface ApiErrorInit {
code: number;
status: number;
message: string;
details?: unknown;
traceId?: string;
}
/** 前端统一 API 错误:与服务端错误体 {code,message,details,trace_id} 对齐 */
export class ApiError extends Error {
readonly code: number;
readonly status: number;
readonly details?: unknown;
readonly traceId?: string;
constructor(init: ApiErrorInit) {
super(init.message);
this.name = 'ApiError';
this.code = init.code;
this.status = init.status;
this.details = init.details;
this.traceId = init.traceId;
}
}
let unauthorizedHandler: (() => void) | null = null;
export function setUnauthorizedHandler(handler: (() => void) | null): void {
unauthorizedHandler = handler;
}
function baseUrl(): string {
const configured = import.meta.env.VITE_PUBLIC_API_BASE_URL;
return (configured ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
}
function buildUrl(path: string): string {
if (/^https?:\/\//.test(path)) {
return path;
}
return `${baseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
}
function serializeBody(body: unknown): BodyInit | undefined {
if (body === undefined) {
return undefined;
}
if (
typeof body === 'string' ||
body instanceof FormData ||
body instanceof Blob ||
body instanceof URLSearchParams ||
body instanceof ArrayBuffer
) {
return body;
}
return JSON.stringify(body);
}
function fallbackCode(status: number): number {
if (status === 401) return 40101;
if (status === 403) return 40301;
if (status === 404) return 40401;
if (status === 409) return 40903;
if (status === 429) return 42901;
if (status >= 500) return 50001;
return 40001;
}
async function toApiError(response: Response): Promise<ApiError> {
let payload: unknown = null;
try {
payload = await response.json();
} catch {
payload = null;
}
const parsed = apiErrorBodySchema.safeParse(payload);
if (parsed.success) {
return new ApiError({
code: parsed.data.code,
status: response.status,
message: parsed.data.message,
details: parsed.data.details,
traceId: parsed.data.trace_id,
});
}
return new ApiError({
code: fallbackCode(response.status),
status: response.status,
message: `请求失败(HTTP ${response.status})`,
});
}
let refreshPromise: Promise<boolean> | null = null;
/** 单飞刷新:并发 401 只触发一次 /auth/refresh */
export function refreshSession(): Promise<boolean> {
refreshPromise ??= doRefresh().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
async function doRefresh(): Promise<boolean> {
const { refreshToken, clearSession } = useAuthStore.getState();
if (refreshToken === null) {
return false;
}
let response: Response;
try {
response = await fetch(buildUrl('/auth/refresh'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
} catch {
return false;
}
if (!response.ok) {
clearSession();
unauthorizedHandler?.();
return false;
}
const payload: unknown = await response.json().catch(() => null);
const accessToken =
typeof payload === 'object' && payload !== null && 'accessToken' in payload
? payload.accessToken
: null;
const nextRefreshToken =
typeof payload === 'object' && payload !== null && 'refreshToken' in payload
? payload.refreshToken
: null;
if (typeof accessToken !== 'string' || accessToken.length === 0) {
clearSession();
unauthorizedHandler?.();
return false;
}
useAuthStore.getState().setTokens({
accessToken,
refreshToken: typeof nextRefreshToken === 'string' ? nextRefreshToken : refreshToken,
});
return true;
}
async function send<T>(path: string, options: RequestOptions, allowRefresh: boolean): Promise<T> {
const { accessToken } = useAuthStore.getState();
const headers = new Headers(options.headers);
const body = serializeBody(options.body);
if (body !== undefined && !(body instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (options.auth !== false && accessToken !== null) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
const response = await fetch(buildUrl(path), {
method: options.method ?? 'GET',
headers,
body,
signal: options.signal,
});
if (response.status === 401 && allowRefresh && options.auth !== false) {
const refreshed = await refreshSession();
if (refreshed) {
return send<T>(path, options, false);
}
}
if (!response.ok) {
throw await toApiError(response);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
/** 业务请求入口:自动注入 token、401 单飞刷新、统一错误体解析 */
export function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
return send<T>(path, options, true);
}
+165
View File
@@ -0,0 +1,165 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
'/health': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 健康检查
* @description 真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。
*/
get: operations['HealthController_check'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
ComponentHealthDto: {
/**
* @example up
* @enum {string}
*/
status: 'up' | 'down';
/**
* @description 检查耗时(毫秒)
* @example 3
*/
latencyMs: number;
/** @description 失败原因(仅 down 时返回) */
error?: string;
};
HealthChecksDto: {
database: components['schemas']['ComponentHealthDto'];
redis: components['schemas']['ComponentHealthDto'];
};
HealthResponseDto: {
/**
* @example ok
* @enum {string}
*/
status: 'ok' | 'degraded';
/** @example development */
env: string;
/** @example 0.1.0 */
version: string;
/**
* @description 进程运行秒数
* @example 128
*/
uptimeSeconds: number;
checks: components['schemas']['HealthChecksDto'];
/** @example 2026-11-02T08:00:00.000Z */
timestamp: string;
};
ErrorResponseDto: {
/**
* @description 错误码(见 ErrorCode 常量)
* @example 42202
*/
code: number;
/**
* @description 可直接展示给用户的提示
* @example 内容触发合规拦截
*/
message: string;
/**
* @description 补充信息(字段错误、命中规则等),结构由错误码决定
* @example {
* "issues": [
* {
* "path": "name",
* "message": "Required"
* }
* ]
* }
*/
details?: Record<string, never>;
/**
* @description 请求追踪 ID,与日志同源
* @example 01JABCDEF0123456789ABCDEFG
*/
trace_id: string;
};
/**
* @description 业务错误码(只允许使用 @lingxi/shared 中登记的错误码)
* @enum {integer}
*/
ErrorCode:
| 40001
| 40101
| 40102
| 40103
| 40301
| 40302
| 40303
| 40304
| 40401
| 40901
| 40902
| 40903
| 42201
| 42202
| 42203
| 42901
| 42902
| 50001
| 50002
| 50003;
ErrorResponse: {
code: components['schemas']['ErrorCode'];
message: string;
/** @description 补充信息,结构由错误码决定 */
details?: unknown;
trace_id: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
HealthController_check: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description 全部依赖健康 */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['HealthResponseDto'];
};
};
/** @description 依赖不可用(错误码 50002),details 内附各依赖检查结果 */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}
+37
View File
@@ -0,0 +1,37 @@
import { notifyError } from '@lingxi/ui';
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
import { ApiError } from './client';
function reportError(error: unknown): void {
if (error instanceof ApiError) {
if (error.status === 401) {
return;
}
notifyError(error.message, error.traceId);
return;
}
notifyError(error instanceof Error ? error.message : '网络请求失败,请稍后重试');
}
function shouldRetry(failureCount: number, error: unknown): boolean {
if (failureCount >= 2) {
return false;
}
if (error instanceof ApiError) {
return error.status >= 500 || error.status === 429;
}
return true;
}
export const queryClient = new QueryClient({
queryCache: new QueryCache({ onError: reportError }),
mutationCache: new MutationCache({ onError: reportError }),
defaultOptions: {
queries: {
retry: shouldRetry,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
+58
View File
@@ -0,0 +1,58 @@
import { PageSkeleton } from '@lingxi/ui';
import type { ReactNode } from 'react';
import { Suspense, useMemo, useState } from 'react';
import { Navigate, Outlet, useLocation } from 'react-router';
import { filterNavByPermissions, findActiveNav, NAV_MODULES } from '@/navigation/nav-config';
import { ROUTES } from '@/routes';
import { useAuthStore } from '@/stores/auth-store';
import { PageBreadcrumbs } from './PageBreadcrumbs';
import { SideNav } from './SideNav';
import { TopBar } from './TopBar';
export function AppLayout(): ReactNode {
const location = useLocation();
const permissions = useAuthStore((state) => state.permissions);
const [collapsed, setCollapsed] = useState(false);
const modules = useMemo(
() => filterNavByPermissions(NAV_MODULES, new Set(permissions)),
[permissions],
);
const active = useMemo(
() => findActiveNav(modules, location.pathname),
[modules, location.pathname],
);
const activeModule = active?.module ?? modules[0];
if (!activeModule) {
return <Navigate to={ROUTES.forbidden} replace />;
}
return (
<div
className={collapsed ? 'lx-shell lx-shell--collapsed' : 'lx-shell'}
data-testid="app-shell"
>
<TopBar modules={modules} activeModuleKey={activeModule.key} />
<div className="lx-shell__body">
<SideNav
module={activeModule}
collapsed={collapsed}
onToggle={() => {
setCollapsed((value) => !value);
}}
/>
<main className="lx-workspace">
<PageBreadcrumbs pathname={location.pathname} />
<div className="lx-page">
<Suspense fallback={<PageSkeleton rows={8} />}>
<Outlet />
</Suspense>
</div>
</main>
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
export interface PageBreadcrumbsProps {
pathname: string;
}
export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
const entries = getBreadcrumbs(NAV_MODULES, pathname);
return (
<nav className="lx-breadcrumbs" aria-label="面包屑">
{entries.map((entry, index) => {
const isLast = index === entries.length - 1;
return (
<span key={entry.key} className="lx-breadcrumbs__entry">
{index > 0 ? <span className="lx-breadcrumbs__sep">/</span> : null}
{entry.path !== undefined && !isLast ? (
<Link to={entry.path} className="lx-breadcrumbs__link">
{entry.label}
</Link>
) : (
<span className={isLast ? 'lx-breadcrumbs__current' : 'lx-breadcrumbs__label'}>
{entry.label}
</span>
)}
</span>
);
})}
</nav>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { IconBack, IconMenu } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { NavLink } from 'react-router';
import type { NavModule } from '@/navigation/nav-config';
export interface SideNavProps {
module: NavModule;
collapsed: boolean;
onToggle: () => void;
}
export function SideNav({ module, collapsed, onToggle }: SideNavProps): ReactNode {
return (
<aside className="lx-sidebar" aria-label="功能导航">
<div className="lx-sidebar__heading">
<span className="lx-sidebar__title">{module.label}</span>
<button
type="button"
className="lx-icon-button lx-sidebar__toggle"
onClick={onToggle}
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
title={collapsed ? '展开侧边栏' : '收起侧边栏'}
>
{collapsed ? <IconMenu size={18} /> : <IconBack size={18} />}
</button>
</div>
<nav className="lx-sidebar__nav">
{module.items.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.key}
to={item.path}
end={item.path === '/'}
className={({ isActive }) =>
isActive ? 'lx-sidebar__item is-active' : 'lx-sidebar__item'
}
title={collapsed ? item.label : undefined}
>
<span className="lx-sidebar__item-icon">
<Icon size={18} />
</span>
<span className="lx-sidebar__item-label">{item.label}</span>
</NavLink>
);
})}
</nav>
<div className="lx-sidebar__footer">
<span className="lx-sidebar__env" title="当前环境:本地开发">
<span className="lx-sidebar__env-dot" aria-hidden="true" />
<span className="lx-sidebar__env-text">本地开发</span>
</span>
</div>
</aside>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { IconChevronDown, IconLogout, IconNotification, IconSearch } from '@lingxi/ui/icons';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router';
import type { NavModule } from '@/navigation/nav-config';
import { ROUTES } from '@/routes';
import { useAuthStore } from '@/stores/auth-store';
export interface TopBarProps {
modules: readonly NavModule[];
activeModuleKey: string;
}
function ModuleTabs({
modules,
activeModuleKey,
}: {
modules: readonly NavModule[];
activeModuleKey: string;
}): ReactNode {
const navigate = useNavigate();
return (
<nav className="lx-topbar__modules" aria-label="模块导航">
{modules.map((module) => {
const active = module.key === activeModuleKey;
return (
<button
key={module.key}
type="button"
className={active ? 'lx-topbar__module is-active' : 'lx-topbar__module'}
aria-current={active ? 'page' : undefined}
onClick={() => {
void navigate(module.path);
}}
>
{module.label}
</button>
);
})}
</nav>
);
}
function UserMenu(): ReactNode {
const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((state) => state.user);
const clearSession = useAuthStore((state) => state.clearSession);
const [openPath, setOpenPath] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const open = openPath === location.pathname;
useEffect(() => {
const onPointerDown = (event: MouseEvent): void => {
if (menuRef.current !== null && !menuRef.current.contains(event.target as Node)) {
setOpenPath(null);
}
};
document.addEventListener('mousedown', onPointerDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
};
}, []);
return (
<div className="lx-user" ref={menuRef}>
<button
type="button"
className="lx-user__trigger"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => {
setOpenPath(open ? null : location.pathname);
}}
>
<span className="lx-user__avatar">{user?.name.slice(0, 1) ?? '灵'}</span>
<span className="lx-user__meta">
<span className="lx-user__name">{user?.name ?? '未登录'}</span>
<span className="lx-user__role">{user?.roleName ?? '访客'}</span>
</span>
<IconChevronDown size={14} className="lx-user__chevron" />
</button>
<UserMenuDropdown
open={open}
email={user?.email ?? '—'}
onLogout={() => {
clearSession();
void navigate(ROUTES.login, { replace: true });
}}
/>
</div>
);
}
function UserMenuDropdown({
open,
email,
onLogout,
}: {
open: boolean;
email: string;
onLogout: () => void;
}): ReactNode {
if (!open) {
return null;
}
return (
<div className="lx-user__menu" role="menu">
<div className="lx-user__menu-header">{email}</div>
<button type="button" role="menuitem" className="lx-user__menu-item" onClick={onLogout}>
<IconLogout size={16} />
退出登录
</button>
</div>
);
}
export function TopBar({ modules, activeModuleKey }: TopBarProps): ReactNode {
return (
<header className="lx-topbar">
<div className="lx-topbar__brand">
<span className="lx-topbar__mark">灵</span>
<span className="lx-topbar__brand-text">
<span className="lx-topbar__name">灵犀</span>
<span className="lx-topbar__tag">素材生产平台</span>
</span>
</div>
<ModuleTabs modules={modules} activeModuleKey={activeModuleKey} />
<div className="lx-topbar__actions">
<button type="button" className="lx-icon-button" aria-label="全局搜索" title="全局搜索">
<IconSearch size={18} />
</button>
<button type="button" className="lx-icon-button" aria-label="任务通知" title="任务通知">
<IconNotification size={18} />
<span className="lx-icon-button__dot" aria-hidden="true" />
</button>
<UserMenu />
</div>
</header>
);
}
+39
View File
@@ -0,0 +1,39 @@
import '@douyinfe/semi-ui-19/react19-adapter';
import '@lingxi/ui/semi.css';
import '@lingxi/ui/theme/tokens.css';
import '@lingxi/ui/styles.css';
import '@/styles/global.css';
import '@/styles/layout.css';
import '@/styles/pages.css';
import { LocaleProvider } from '@douyinfe/semi-ui-19';
import { localeZhCN } from '@lingxi/ui';
import { QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';
import { setUnauthorizedHandler } from '@/api/client';
import { queryClient } from '@/api/query-client';
import { router } from '@/router';
import { ROUTES } from '@/routes';
setUnauthorizedHandler(() => {
void router.navigate(ROUTES.login, { replace: true });
});
const container = document.getElementById('root');
if (container === null) {
throw new Error('找不到应用根节点 #root');
}
createRoot(container).render(
<StrictMode>
<LocaleProvider locale={localeZhCN}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</LocaleProvider>
</StrictMode>,
);
@@ -0,0 +1,41 @@
import type { PermissionCode } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import { filterNavByPermissions, findActiveNav, getBreadcrumbs, NAV_MODULES } from './nav-config';
describe('导航配置', () => {
it('拥有全部权限时展示全部模块与菜单', () => {
const permissions = new Set(
NAV_MODULES.flatMap((module) => module.items.map((item) => item.permission)).filter(
(code): code is PermissionCode => code !== undefined,
),
);
const modules = filterNavByPermissions(NAV_MODULES, permissions);
expect(modules.map((module) => module.key)).toEqual(NAV_MODULES.map((module) => module.key));
});
it('缺少权限点时过滤菜单,模块没有子菜单时整组隐藏', () => {
const modules = filterNavByPermissions(NAV_MODULES, new Set(['script:read']));
const script = modules.find((module) => module.key === 'script');
expect(script?.items.map((item) => item.key)).toEqual(['library']);
expect(modules.some((module) => module.key === 'platform')).toBe(false);
});
it('只有无权限要求的菜单时仍然可见', () => {
const modules = filterNavByPermissions(NAV_MODULES, new Set());
expect(modules.map((module) => module.key)).toEqual(['dashboard']);
});
it('按路径定位当前模块与菜单', () => {
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
expect(active?.module.key).toBe('material');
expect(active?.item?.label).toBe('快筛');
expect(findActiveNav(NAV_MODULES, '/not-exists')).toBeNull();
});
it('面包屑从导航配置派生', () => {
expect(getBreadcrumbs(NAV_MODULES, '/')).toEqual([{ key: 'home', label: '工作台', path: '/' }]);
const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/platform/users');
expect(breadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '平台', '用户与组织']);
});
});
+410
View File
@@ -0,0 +1,410 @@
import { Permission, type PermissionCode } from '@lingxi/shared';
import {
IconAsset,
IconCheck,
IconClock,
IconCopy,
IconDelivery,
IconDocs,
IconExport,
IconFilter,
IconGlobal,
IconGrid,
IconHome,
IconImageAdd,
IconMaterial,
IconPlay,
IconRobot,
IconScript,
IconSettings,
IconShield,
IconStack,
IconTask,
IconUser,
IconVideo,
IconView,
} from '@lingxi/ui/icons';
import type { ComponentType } from 'react';
export type NavIcon = ComponentType<{ size?: number | string; className?: string }>;
export interface NavItem {
key: string;
label: string;
path: string;
icon: NavIcon;
/** 未配置权限点的菜单对所有登录用户可见 */
permission?: PermissionCode;
description?: string;
}
export interface NavModule {
key: string;
label: string;
path: string;
icon: NavIcon;
permission?: PermissionCode;
items: NavItem[];
}
/**
* 顶部模块菜单 + 左侧功能菜单的唯一配置源(A0-FE-03)。
* 新增页面时在这里登记;侧边栏与面包屑都从这里派生,禁止在页面内写死导航。
*/
export const NAV_MODULES: readonly NavModule[] = [
{
key: 'dashboard',
label: '工作台',
path: '/',
icon: IconHome,
items: [
{
key: 'overview',
label: '工作台',
path: '/',
icon: IconHome,
description: '今天的生产任务、待审核脚本与最近素材',
},
],
},
{
key: 'script',
label: '脚本',
path: '/scripts/library',
icon: IconScript,
permission: Permission.SCRIPT_READ,
items: [
{
key: 'library',
label: '脚本库',
path: '/scripts/library',
icon: IconDocs,
permission: Permission.SCRIPT_READ,
description: '按客户、行业与状态检索已生成脚本',
},
{
key: 'framework-cards',
label: '要素卡',
path: '/scripts/framework-cards',
icon: IconTask,
permission: Permission.SCRIPT_WRITE,
description: '维护行业包的要素卡字段与填写规则',
},
{
key: 'templates',
label: '脚本模板',
path: '/scripts/templates',
icon: IconStack,
permission: Permission.SCRIPT_WRITE,
description: '结构模板、镜头模板与提示词模板',
},
{
key: 'batch',
label: '批量生成',
path: '/scripts/batch',
icon: IconRobot,
permission: Permission.SCRIPT_WRITE,
description: '按要素卡批量生成脚本并排队审核',
},
{
key: 'review',
label: '脚本审核',
path: '/scripts/review',
icon: IconCheck,
permission: Permission.SCRIPT_REVIEW,
description: '审核生成结果,标记低质量脚本',
},
],
},
{
key: 'material',
label: '素材',
path: '/material/image-compose',
icon: IconMaterial,
permission: Permission.MATERIAL_READ,
items: [
{
key: 'image-compose',
label: '图片合成',
path: '/material/image-compose',
icon: IconImageAdd,
permission: Permission.MATERIAL_RENDER,
description: 'PSD 套版、抠图与确定性合成',
},
{
key: 'video',
label: '视频生产',
path: '/material/video',
icon: IconVideo,
permission: Permission.MATERIAL_RENDER,
description: '逐镜生成、合成与成片预览',
},
{
key: 'tasks',
label: '生产任务',
path: '/material/tasks',
icon: IconTask,
permission: Permission.MATERIAL_READ,
description: '任务进度、失败原因与重试',
},
{
key: 'quick-review',
label: '快筛',
path: '/material/quick-review',
icon: IconFilter,
permission: Permission.MATERIAL_REVIEW,
description: '按快捷键快速筛选候选素材',
},
{
key: 'preview',
label: '成片预览',
path: '/material/preview',
icon: IconPlay,
permission: Permission.MATERIAL_READ,
description: '播放成片并核对规格与标识',
},
],
},
{
key: 'asset',
label: '资产',
path: '/assets/library',
icon: IconAsset,
permission: Permission.ASSET_READ,
items: [
{
key: 'library',
label: '资产库',
path: '/assets/library',
icon: IconAsset,
permission: Permission.ASSET_READ,
description: '图片、视频、音频与模板资产',
},
{
key: 'templates',
label: '模板库',
path: '/assets/templates',
icon: IconCopy,
permission: Permission.ASSET_READ,
description: 'PSD 模板与版式配置',
},
{
key: 'pools',
label: '人物与场景',
path: '/assets/pools',
icon: IconUser,
permission: Permission.ASSET_READ,
description: '人物、场景与风格池',
},
{
key: 'prompts',
label: '提示词模板',
path: '/assets/prompts',
icon: IconRobot,
permission: Permission.ASSET_READ,
description: '视频与图片生成的提示词版本',
},
],
},
{
key: 'delivery',
label: '交付',
path: '/delivery/export',
icon: IconDelivery,
permission: Permission.DELIVERY_READ,
items: [
{
key: 'export',
label: '导出任务',
path: '/delivery/export',
icon: IconExport,
permission: Permission.DELIVERY_EXPORT,
description: '勾选素材导出并按批次归档',
},
{
key: 'records',
label: '导出记录',
path: '/delivery/records',
icon: IconClock,
permission: Permission.DELIVERY_READ,
description: '导出批次、签名链接与归档状态',
},
{
key: 'manifests',
label: '交付清单',
path: '/delivery/manifests',
icon: IconTask,
permission: Permission.DELIVERY_READ,
description: '交付清单、客户签收与留痕',
},
],
},
{
key: 'platform',
label: '平台',
path: '/platform/users',
icon: IconSettings,
permission: Permission.USER_MANAGE,
items: [
{
key: 'users',
label: '用户与组织',
path: '/platform/users',
icon: IconUser,
permission: Permission.USER_MANAGE,
description: '账号、组织与登录状态',
},
{
key: 'roles',
label: '角色权限',
path: '/platform/roles',
icon: IconShield,
permission: Permission.ROLE_MANAGE,
description: '角色、权限点与职责互斥',
},
{
key: 'customers',
label: '客户',
path: '/platform/customers',
icon: IconGlobal,
permission: Permission.CUSTOMER_MANAGE,
description: '客户、行业归属与数据范围',
},
{
key: 'industries',
label: '行业树',
path: '/platform/industries',
icon: IconStack,
permission: Permission.INDUSTRY_MANAGE,
description: '行业分类树与行业包绑定',
},
{
key: 'dicts',
label: '字典',
path: '/platform/dicts',
icon: IconDocs,
permission: Permission.DICT_MANAGE,
description: '核心字典与业务枚举',
},
{
key: 'audit',
label: '审计日志',
path: '/platform/audit',
icon: IconView,
permission: Permission.AUDIT_READ,
description: '关键操作留痕与导出记录',
},
{
key: 'ai',
label: '模型接入',
path: '/platform/ai',
icon: IconRobot,
permission: Permission.AI_CONFIGURE,
description: '供应商、路由与预算阈值',
},
{
key: 'settings',
label: '系统配置',
path: '/platform/settings',
icon: IconSettings,
permission: Permission.SYSTEM_CONFIGURE,
description: '阈值、上限与功能开关',
},
{
key: 'industry-packs',
label: '行业包',
path: '/platform/industry-packs',
icon: IconGrid,
permission: Permission.INDUSTRY_MANAGE,
description: '行业包版本、发布与灰度',
},
{
key: 'forms',
label: '表单设计器',
path: '/platform/forms',
icon: IconGrid,
permission: Permission.FORM_DESIGN,
description: '受控字段白名单与表单布局',
},
{
key: 'customer-fields',
label: '客户字段',
path: '/platform/customer-fields',
icon: IconTask,
permission: Permission.CUSTOMER_MANAGE,
description: '客户级扩展字段与审批',
},
{
key: 'compliance',
label: '合规规则',
path: '/platform/compliance',
icon: IconShield,
permission: Permission.SYSTEM_CONFIGURE,
description: '禁忌词、极限词与合规规则集',
},
],
},
];
export function filterNavByPermissions(
modules: readonly NavModule[],
permissions: ReadonlySet<string>,
): NavModule[] {
return modules
.map((module) => {
if (module.permission && !permissions.has(module.permission)) {
return null;
}
const items = module.items.filter(
(item) => item.permission === undefined || permissions.has(item.permission),
);
return items.length > 0 ? { ...module, items } : null;
})
.filter((module): module is NavModule => module !== null);
}
export function findNavModule(
modules: readonly NavModule[],
moduleKey: string,
): NavModule | undefined {
return modules.find((module) => module.key === moduleKey);
}
export interface ActiveNav {
module: NavModule;
item: NavItem | undefined;
}
export function findActiveNav(modules: readonly NavModule[], pathname: string): ActiveNav | null {
const normalized = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname;
for (const module of modules) {
const item = module.items.find((candidate) => candidate.path === normalized);
if (item) {
return { module, item };
}
}
return null;
}
export interface BreadcrumbEntry {
key: string;
label: string;
path?: string;
}
export function getBreadcrumbs(modules: readonly NavModule[], pathname: string): BreadcrumbEntry[] {
const active = findActiveNav(modules, pathname);
if (!active) {
return [{ key: 'home', label: '工作台', path: '/' }];
}
const entries: BreadcrumbEntry[] = [{ key: 'home', label: '工作台', path: '/' }];
if (active.module.key !== 'dashboard') {
entries.push({ key: active.module.key, label: active.module.label });
}
if (active.item && active.item.path !== '/' && active.item.key !== active.module.items[0]?.key) {
entries.push({ key: active.item.key, label: active.item.label, path: active.item.path });
} else if (active.item && active.item.path !== '/') {
entries.push({ key: active.item.key, label: active.item.label, path: active.item.path });
}
return entries;
}
+40
View File
@@ -0,0 +1,40 @@
import { IconAlert } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
export interface ErrorLayoutProps {
code: string;
title: string;
description: string;
traceId?: string;
actions?: ReactNode;
}
export function ErrorLayout({
code,
title,
description,
traceId,
actions,
}: ErrorLayoutProps): ReactNode {
return (
<main className="lx-error-page" data-testid={`error-${code}`}>
<div className="lx-error-page__code" aria-hidden="true">
{code}
</div>
<div className="lx-error-page__body">
<span className="lx-error-page__icon">
<IconAlert size={22} />
</span>
<p className="lx-eyebrow">灵犀 · 访问异常</p>
<h1 className="lx-error-page__title">{title}</h1>
<p className="lx-error-page__description">{description}</p>
{traceId !== undefined ? (
<p className="lx-error-page__trace">
trace_id:<code>{traceId}</code>
</p>
) : null}
<div className="lx-error-page__actions">{actions}</div>
</div>
</main>
);
}
@@ -0,0 +1,38 @@
import { Button } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { ROUTES } from '@/routes';
import { ErrorLayout } from './ErrorLayout';
export function ForbiddenPage(): ReactNode {
const navigate = useNavigate();
return (
<ErrorLayout
code="403"
title="没有访问权限"
description="你的账号没有该页面的权限点。如需开通,请联系平台管理员调整角色。"
actions={
<>
<Button
theme="solid"
type="primary"
onClick={() => {
void navigate(ROUTES.home);
}}
>
返回工作台
</Button>
<Button
onClick={() => {
void navigate(-1);
}}
>
返回上一页
</Button>
</>
}
/>
);
}
@@ -0,0 +1,29 @@
import { Button } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { ROUTES } from '@/routes';
import { ErrorLayout } from './ErrorLayout';
export function NotFoundPage(): ReactNode {
const navigate = useNavigate();
return (
<ErrorLayout
code="404"
title="页面不存在"
description="地址可能已变更或输入有误。可以回到工作台,从顶部模块菜单重新进入。"
actions={
<Button
theme="solid"
type="primary"
onClick={() => {
void navigate(ROUTES.home);
}}
>
返回工作台
</Button>
}
/>
);
}
@@ -0,0 +1,41 @@
import { Button } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { ROUTES } from '@/routes';
import { ErrorLayout } from './ErrorLayout';
export function ServerErrorPage(): ReactNode {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const traceId = searchParams.get('trace_id') ?? undefined;
return (
<ErrorLayout
code="500"
title="服务暂时不可用"
description="请求处理失败,请稍后重试。若问题持续出现,请把 trace_id 提供给平台管理员排查。"
traceId={traceId}
actions={
<>
<Button
theme="solid"
type="primary"
onClick={() => {
void navigate(0);
}}
>
重新加载
</Button>
<Button
onClick={() => {
void navigate(ROUTES.home);
}}
>
返回工作台
</Button>
</>
}
/>
);
}
@@ -0,0 +1,29 @@
import { Button } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { ROUTES } from '@/routes';
import { ErrorLayout } from './ErrorLayout';
export function UnauthorizedPage(): ReactNode {
const navigate = useNavigate();
return (
<ErrorLayout
code="401"
title="请先登录"
description="当前页面需要登录后访问。登录成功后会自动回到工作台。"
actions={
<Button
theme="solid"
type="primary"
onClick={() => {
void navigate(ROUTES.login);
}}
>
去登录
</Button>
}
/>
);
}
+373
View File
@@ -0,0 +1,373 @@
import { Button } from '@douyinfe/semi-ui-19';
import {
IconChart,
IconChevronRight,
IconClock,
IconDownload,
IconExport,
IconImageAdd,
IconPlay,
IconScript,
IconTask,
} from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { useAuthStore } from '@/stores/auth-store';
interface ProductionTask {
id: string;
name: string;
kind: string;
progress: number;
status: 'running' | 'review' | 'done';
updatedAt: string;
}
const TASKS: readonly ProductionTask[] = [
{
id: 'task-1',
name: '夏日防晒 · 图片合成批次',
kind: '图片合成',
progress: 68,
status: 'running',
updatedAt: '12 分钟前',
},
{
id: 'task-2',
name: '通勤穿搭 · 视频逐镜生成',
kind: '视频生产',
progress: 42,
status: 'running',
updatedAt: '26 分钟前',
},
{
id: 'task-3',
name: '会员日 · 脚本批量生成',
kind: '脚本',
progress: 100,
status: 'review',
updatedAt: '1 小时前',
},
{
id: 'task-4',
name: '春季新品 · 快筛候选集',
kind: '快筛',
progress: 100,
status: 'done',
updatedAt: '今天 10:24',
},
];
const STATUS_LABEL: Record<ProductionTask['status'], string> = {
running: '进行中',
review: '待审核',
done: '已完成',
};
const QUICK_ACTIONS = [
{
key: 'script',
label: '新建脚本',
hint: '从要素卡开始',
path: '/scripts/batch',
icon: IconScript,
},
{
key: 'image',
label: '图片合成',
hint: 'PSD 套版与抠图',
path: '/material/image-compose',
icon: IconImageAdd,
},
{
key: 'video',
label: '视频生产',
hint: '逐镜生成成片',
path: '/material/video',
icon: IconPlay,
},
{
key: 'export',
label: '导出素材',
hint: '勾选后导出并归档',
path: '/delivery/export',
icon: IconExport,
},
] as const;
const RECENT_ASSETS = [
{ id: 'a1', name: '防晒喷雾 · 主图 01', spec: '1080 × 1920', tone: 1 },
{ id: 'a2', name: '通勤穿搭 · 封面', spec: '1080 × 1920', tone: 2 },
{ id: 'a3', name: '会员日 · 利益点', spec: '1080 × 1920', tone: 3 },
{ id: 'a4', name: '春季新品 · 场景 A', spec: '1080 × 1920', tone: 4 },
{ id: 'a5', name: '防晒喷雾 · 细节', spec: '1080 × 1080', tone: 5 },
{ id: 'a6', name: '通勤穿搭 · 结尾卡', spec: '1080 × 1920', tone: 6 },
] as const;
function HomeHero({ userName }: { userName: string }): ReactNode {
const navigate = useNavigate();
return (
<header className="lx-home__hero">
<div className="lx-home__hero-copy">
<h1>工作台</h1>
<p className="lx-home__hero-meta">
<span>{userName},欢迎回来</span>
<span className="lx-home__hero-dot" aria-hidden="true" />
<span>2026 年 11 月 2 日 周一</span>
<span className="lx-home__hero-dot" aria-hidden="true" />
<span>今日已产出 18 条素材</span>
</p>
</div>
<div className="lx-home__hero-actions">
<Button
icon={<IconExport size={16} />}
onClick={() => {
void navigate('/delivery/export');
}}
>
导出报表
</Button>
<Button
theme="solid"
type="primary"
icon={<IconImageAdd size={16} />}
onClick={() => {
void navigate('/material/image-compose');
}}
>
新建生产任务
</Button>
</div>
</header>
);
}
const METRICS = [
{
key: 'output',
label: '今日产出',
value: '128',
unit: '条',
foot: '较昨日 +12.4%',
trend: '+12.4%',
icon: IconChart,
},
{
key: 'running',
label: '进行中任务',
value: '6',
unit: '个',
foot: '2 个排队中 · 预计 4:12 完成',
trend: '',
icon: IconTask,
},
{
key: 'review',
label: '待审核',
value: '24',
unit: '条',
foot: '8 条已超 2 小时',
trend: '',
icon: IconClock,
},
{
key: 'export',
label: '本周导出',
value: '512',
unit: '条',
foot: '较上周 +8.1%',
trend: '+8.1%',
icon: IconDownload,
},
] as const;
function HomeMetrics(): ReactNode {
return (
<section className="lx-metrics" aria-label="生产概览">
{METRICS.map((metric) => {
const Icon = metric.icon;
return (
<article className="lx-metric" key={metric.key}>
<div className="lx-metric__head">
<span>{metric.label}</span>
<span className="lx-metric__icon">
<Icon size={16} />
</span>
</div>
<p className="lx-metric__value">
{metric.value}
<span className="lx-metric__unit">{metric.unit}</span>
</p>
<p className="lx-metric__foot">
{metric.trend === '' ? (
metric.foot
) : (
<span className="lx-metric__trend">{metric.trend}</span>
)}
{metric.trend === '' ? null : ` · ${metric.foot}`}
</p>
</article>
);
})}
</section>
);
}
function TaskRow({ task }: { task: ProductionTask }): ReactNode {
return (
<div className="lx-table__row" role="row">
<span className="lx-table__name" role="cell">
{task.name}
</span>
<span className="lx-table__muted" role="cell">
{task.kind}
</span>
<span className="lx-table__progress" role="cell">
<span className="lx-progress" aria-hidden="true">
<span className="lx-progress__bar" style={{ width: `${task.progress}%` }} />
</span>
<span className="lx-progress__value">{task.progress}%</span>
</span>
<span role="cell">
<span className={`lx-chip lx-chip--${task.status}`}>{STATUS_LABEL[task.status]}</span>
</span>
<span className="lx-table__muted" role="cell">
{task.updatedAt}
</span>
</div>
);
}
function TaskPanel(): ReactNode {
const navigate = useNavigate();
return (
<section className="lx-panel lx-home__tasks" aria-labelledby="home-tasks-title">
<header className="lx-panel__head">
<div>
<h2 id="home-tasks-title">生产任务</h2>
<p>按最近更新时间排序</p>
</div>
<button
type="button"
className="lx-link-button"
onClick={() => {
void navigate('/material/tasks');
}}
>
查看全部
<IconChevronRight size={14} />
</button>
</header>
<div className="lx-table" role="table" aria-label="进行中的任务">
<div className="lx-table__head" role="row">
<span role="columnheader">任务</span>
<span role="columnheader">类型</span>
<span role="columnheader">进度</span>
<span role="columnheader">状态</span>
<span role="columnheader">更新时间</span>
</div>
{TASKS.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
</section>
);
}
function QuickPanel(): ReactNode {
const navigate = useNavigate();
return (
<aside className="lx-panel lx-home__quick" aria-labelledby="home-quick-title">
<header className="lx-panel__head">
<div>
<h2 id="home-quick-title">快速开始</h2>
<p>常用生产入口</p>
</div>
</header>
<div className="lx-quick-list">
{QUICK_ACTIONS.map((action) => {
const Icon = action.icon;
return (
<button
key={action.key}
type="button"
className="lx-quick-item"
onClick={() => {
void navigate(action.path);
}}
>
<span className="lx-quick-item__icon">
<Icon size={18} />
</span>
<span className="lx-quick-item__copy">
<span className="lx-quick-item__label">{action.label}</span>
<span className="lx-quick-item__hint">{action.hint}</span>
</span>
<IconChevronRight size={16} className="lx-quick-item__arrow" />
</button>
);
})}
</div>
</aside>
);
}
function RecentAssetsPanel(): ReactNode {
const navigate = useNavigate();
return (
<section className="lx-panel lx-home__recent" aria-labelledby="home-recent-title">
<header className="lx-panel__head">
<div>
<h2 id="home-recent-title">最近素材</h2>
<p>今天产出的图片与视频封面</p>
</div>
<button
type="button"
className="lx-link-button"
onClick={() => {
void navigate('/assets/library');
}}
>
打开资产库
<IconChevronRight size={14} />
</button>
</header>
<div className="lx-asset-grid">
{RECENT_ASSETS.map((asset) => (
<button
key={asset.id}
type="button"
className="lx-asset-tile"
onClick={() => {
void navigate('/assets/library');
}}
>
<span className={`lx-asset-tile__preview lx-asset-tile__preview--${asset.tone}`} />
<span className="lx-asset-tile__meta">
<span className="lx-asset-tile__name">{asset.name}</span>
<span className="lx-asset-tile__spec">{asset.spec}</span>
</span>
</button>
))}
</div>
</section>
);
}
export default function HomePage(): ReactNode {
const user = useAuthStore((state) => state.user);
return (
<div className="lx-home">
<HomeHero userName={user?.name ?? '管理员'} />
<HomeMetrics />
<div className="lx-home__grid">
<TaskPanel />
<QuickPanel />
</div>
<RecentAssetsPanel />
</div>
);
}
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
export function LoginBrandPanel(): ReactNode {
return (
<header className="lx-login__topbar">
<div className="lx-login__brand">
<span className="lx-login__mark">灵</span>
<span className="lx-login__wordmark">灵犀</span>
<span className="lx-login__brand-sub">素材生产平台 · LINGXI</span>
</div>
<span className="lx-login__badge">内部系统 · 仅限授权成员</span>
</header>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { Button, Checkbox, Input } from '@douyinfe/semi-ui-19';
import { IconLock, IconMail } from '@lingxi/ui/icons';
import type { ReactNode, SyntheticEvent } from 'react';
export interface LoginFormCardProps {
account: string;
password: string;
submitting: boolean;
onAccountChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onSubmit: (event: SyntheticEvent<HTMLFormElement>) => void;
}
function LoginFormHeader(): ReactNode {
return (
<header className="lx-login__form-head">
<h2>登录灵犀</h2>
<p>使用企业账号登录素材生产平台</p>
</header>
);
}
function LoginFormFooter(): ReactNode {
return (
<footer className="lx-login__form-foot">
<span className="lx-login__form-foot-dot" aria-hidden="true" />
账号由平台管理员开通,如需协助请联系 IT 服务台
</footer>
);
}
function LoginFields({
account,
password,
onAccountChange,
onPasswordChange,
}: Pick<
LoginFormCardProps,
'account' | 'password' | 'onAccountChange' | 'onPasswordChange'
>): ReactNode {
return (
<>
<label className="lx-field">
<span className="lx-field__label">账号</span>
<Input
size="large"
value={account}
onChange={onAccountChange}
placeholder="请输入账号或邮箱"
prefix={<IconMail size={16} />}
autoComplete="username"
/>
</label>
<label className="lx-field">
<span className="lx-field__label">密码</span>
<Input
size="large"
type="password"
value={password}
onChange={onPasswordChange}
placeholder="请输入密码"
prefix={<IconLock size={16} />}
autoComplete="current-password"
/>
</label>
<div className="lx-login__form-meta">
<Checkbox defaultChecked>记住我(7 天)</Checkbox>
<button type="button" className="lx-link-button">
忘记密码?
</button>
</div>
</>
);
}
export function LoginFormCard({
account,
password,
submitting,
onAccountChange,
onPasswordChange,
onSubmit,
}: LoginFormCardProps): ReactNode {
return (
<div className="lx-login__form">
<LoginFormHeader />
<form onSubmit={onSubmit} data-testid="login-form">
<LoginFields
account={account}
password={password}
onAccountChange={onAccountChange}
onPasswordChange={onPasswordChange}
/>
<Button
htmlType="submit"
theme="solid"
type="primary"
size="large"
block
loading={submitting}
data-testid="login-submit"
>
登录
</Button>
</form>
<LoginFormFooter />
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from 'react';
import { LoginBrandPanel } from './LoginBrandPanel';
import { LoginFormCard } from './LoginFormCard';
import { useLoginForm } from './use-login-form';
export default function LoginPage(): ReactNode {
const { account, password, submitting, setAccount, setPassword, handleSubmit } = useLoginForm();
return (
<main className="lx-login">
<LoginBrandPanel />
<section className="lx-login__panel">
<LoginFormCard
account={account}
password={password}
submitting={submitting}
onAccountChange={setAccount}
onPasswordChange={setPassword}
onSubmit={(event) => {
void handleSubmit(event);
}}
/>
</section>
<footer className="lx-login__footer">
<span>© 2026 灵犀 · 内部系统</span>
<span className="lx-login__footer-links">
<span>隐私政策</span>
<span>服务条款</span>
<span>华东一区</span>
</span>
</footer>
</main>
);
}
@@ -0,0 +1,70 @@
import { notifyError, notifyWarning } from '@lingxi/ui';
import { type SyntheticEvent, useState } from 'react';
import { useNavigate } from 'react-router';
import { ApiError, request } from '@/api/client';
import { ROUTES } from '@/routes';
import type { AuthUser } from '@/stores/auth-store';
import { useAuthStore } from '@/stores/auth-store';
interface LoginResponse {
accessToken: string;
refreshToken?: string;
user: AuthUser;
permissions?: string[];
}
const demoEnabled = import.meta.env.VITE_PUBLIC_DEMO_SESSION !== 'false';
export function useLoginForm(): {
account: string;
password: string;
submitting: boolean;
setAccount: (value: string) => void;
setPassword: (value: string) => void;
handleSubmit: (event: SyntheticEvent<HTMLFormElement>) => Promise<void>;
} {
const navigate = useNavigate();
const signInDemo = useAuthStore((state) => state.signInDemo);
const setSession = useAuthStore((state) => state.setSession);
const [account, setAccount] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (event: SyntheticEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
if (account.trim() === '' || password === '') {
notifyWarning('请输入账号和密码');
return;
}
setSubmitting(true);
try {
if (demoEnabled) {
signInDemo();
await navigate(ROUTES.home, { replace: true });
return;
}
const session = await request<LoginResponse>('/auth/login', {
method: 'POST',
auth: false,
body: { email: account.trim(), password },
});
setSession({
accessToken: session.accessToken,
refreshToken: session.refreshToken ?? null,
user: session.user,
permissions: session.permissions,
});
await navigate(ROUTES.home, { replace: true });
} catch (error) {
notifyError(
error instanceof Error ? error.message : '登录失败,请稍后重试',
error instanceof ApiError ? error.traceId : undefined,
);
} finally {
setSubmitting(false);
}
};
return { account, password, submitting, setAccount, setPassword, handleSubmit };
}
@@ -0,0 +1,63 @@
import { PageEmpty } from '@lingxi/ui';
import type { ReactNode } from 'react';
import { Link, useParams } from 'react-router';
import {
filterNavByPermissions,
findNavModule,
NAV_MODULES,
type NavItem,
} from '@/navigation/nav-config';
import { ROUTES } from '@/routes';
import { useAuthStore } from '@/stores/auth-store';
function resolvePage(
moduleKey: string | undefined,
itemKey: string | undefined,
): { moduleLabel: string; item: NavItem } | null {
if (moduleKey === undefined) {
return null;
}
const permissions = new Set(useAuthStore.getState().permissions);
const modules = filterNavByPermissions(NAV_MODULES, permissions);
const module = findNavModule(modules, moduleKey);
if (module === undefined) {
return null;
}
const item = module.items.find((candidate) => candidate.key === itemKey) ?? module.items[0];
return item === undefined ? null : { moduleLabel: module.label, item };
}
export default function WorkspacePage(): ReactNode {
const { moduleKey, itemKey } = useParams<{ moduleKey: string; itemKey?: string }>();
const page = resolvePage(moduleKey, itemKey);
if (page === null) {
return (
<section className="lx-panel lx-workspace-missing">
<p className="lx-eyebrow">404</p>
<h1>页面不存在</h1>
<p>该功能可能已调整或你没有访问权限。</p>
<Link to={ROUTES.home} className="lx-link-button">
返回工作台
</Link>
</section>
);
}
return (
<section className="lx-workspace-page">
<header className="lx-page-header">
<div>
<p className="lx-eyebrow">{page.moduleLabel}</p>
<h1>{page.item.label}</h1>
<p className="lx-page-header__description">{page.item.description}</p>
</div>
</header>
<div className="lx-panel lx-panel--empty">
<PageEmpty />
</div>
</section>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { createBrowserRouter } from 'react-router';
import { AppLayout } from '@/layout/AppLayout';
import { ForbiddenPage } from '@/pages/errors/ForbiddenPage';
import { NotFoundPage } from '@/pages/errors/NotFoundPage';
import { ServerErrorPage } from '@/pages/errors/ServerErrorPage';
import { UnauthorizedPage } from '@/pages/errors/UnauthorizedPage';
import { ROUTES } from '@/routes';
export const router = createBrowserRouter([
{
path: ROUTES.login,
lazy: async () => ({
Component: (await import('@/pages/login/LoginPage')).default,
}),
},
{
path: '/',
element: <AppLayout />,
children: [
{
index: true,
lazy: async () => ({
Component: (await import('@/pages/home/HomePage')).default,
}),
},
{
path: ':moduleKey/:itemKey?',
lazy: async () => ({
Component: (await import('@/pages/workspace/WorkspacePage')).default,
}),
},
],
},
{ path: ROUTES.unauthorized, element: <UnauthorizedPage /> },
{ path: ROUTES.forbidden, element: <ForbiddenPage /> },
{ path: ROUTES.notFound, element: <NotFoundPage /> },
{ path: ROUTES.serverError, element: <ServerErrorPage /> },
{ path: '*', element: <NotFoundPage /> },
]);
+11
View File
@@ -0,0 +1,11 @@
/** 前端路由常量(工程规范 04:路由集中管理,禁止散落字符串) */
export const ROUTES = {
home: '/',
login: '/login',
unauthorized: '/401',
forbidden: '/403',
notFound: '/404',
serverError: '/500',
} as const;
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
+141
View File
@@ -0,0 +1,141 @@
import { PERMISSION_CODES } from '@lingxi/shared';
import { create } from 'zustand';
export interface AuthUser {
id: string;
name: string;
email: string;
roleName?: string;
}
export interface AuthSession {
accessToken: string | null;
refreshToken: string | null;
user: AuthUser | null;
permissions: string[];
}
export interface SetSessionInput {
accessToken: string;
refreshToken?: string | null;
user: AuthUser;
permissions?: readonly string[];
}
export interface AuthState extends AuthSession {
setSession: (input: SetSessionInput) => void;
setTokens: (tokens: { accessToken: string; refreshToken?: string | null }) => void;
clearSession: () => void;
signInDemo: () => void;
hasPermission: (code: string) => boolean;
}
const STORAGE_KEY = 'lingxi.auth.session';
const EMPTY_SESSION: AuthSession = {
accessToken: null,
refreshToken: null,
user: null,
permissions: [],
};
export const DEMO_USER: AuthUser = {
id: '01J0DEMO000000000000000000',
name: '灵犀管理员',
email: 'admin@lingxi.local',
roleName: '平台管理员',
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isAuthUser(value: unknown): value is AuthUser {
return (
isRecord(value) &&
typeof value.id === 'string' &&
typeof value.name === 'string' &&
typeof value.email === 'string'
);
}
function isAuthSession(value: unknown): value is AuthSession {
return (
isRecord(value) &&
(typeof value.accessToken === 'string' || value.accessToken === null) &&
(typeof value.refreshToken === 'string' || value.refreshToken === null) &&
(isAuthUser(value.user) || value.user === null) &&
Array.isArray(value.permissions) &&
value.permissions.every((permission) => typeof permission === 'string')
);
}
function readPersistedSession(): AuthSession {
if (typeof window === 'undefined') {
return EMPTY_SESSION;
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw === null) {
return EMPTY_SESSION;
}
const parsed: unknown = JSON.parse(raw);
return isAuthSession(parsed) ? parsed : EMPTY_SESSION;
} catch {
return EMPTY_SESSION;
}
}
function persistSession(session: AuthSession): void {
if (typeof window === 'undefined') {
return;
}
try {
if (session.accessToken === null) {
window.localStorage.removeItem(STORAGE_KEY);
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
} catch {
// 本地存储不可用(隐私模式等)时退化为内存会话,不影响登录流程
}
}
export const useAuthStore = create<AuthState>()((set, get) => ({
...readPersistedSession(),
setSession: (input) => {
const session: AuthSession = {
accessToken: input.accessToken,
refreshToken: input.refreshToken ?? null,
user: input.user,
permissions: [...(input.permissions ?? PERMISSION_CODES)],
};
persistSession(session);
set(session);
},
setTokens: (tokens) => {
const current = get();
const session: AuthSession = {
...current,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? current.refreshToken,
};
persistSession(session);
set({ accessToken: session.accessToken, refreshToken: session.refreshToken });
},
clearSession: () => {
persistSession(EMPTY_SESSION);
set(EMPTY_SESSION);
},
signInDemo: () => {
const session: AuthSession = {
accessToken: 'demo-access-token',
refreshToken: 'demo-refresh-token',
user: DEMO_USER,
permissions: [...PERMISSION_CODES],
};
persistSession(session);
set(session);
},
hasPermission: (code) => get().permissions.includes(code),
}));
+284
View File
@@ -0,0 +1,284 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: var(--lx-font-sans);
font-size: var(--lx-font-size-md);
line-height: var(--lx-line-height-normal);
color: var(--lx-color-text-primary);
background: var(--lx-color-bg-app);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
h4,
p {
margin: 0;
}
button,
input,
textarea,
select {
font: inherit;
color: inherit;
}
button {
padding: 0;
border: none;
background: none;
cursor: pointer;
}
a {
color: inherit;
text-decoration: none;
}
ul,
ol {
margin: 0;
padding: 0;
list-style: none;
}
::selection {
color: #ffffff;
background: var(--lx-color-brand);
}
:focus-visible {
outline: 2px solid var(--lx-color-focus);
outline-offset: 2px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: var(--lx-radius-pill);
background: var(--lx-color-border-strong);
background-clip: content-box;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* ---------- 基础排版 ---------- */
.lx-eyebrow {
font-size: 11px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.08em;
color: var(--lx-color-text-tertiary);
}
.lx-link-button {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
transition: color var(--lx-motion-fast) var(--lx-ease-out);
}
.lx-link-button:hover {
color: var(--lx-color-brand);
}
/* ---------- 面板 ---------- */
.lx-panel {
display: flex;
flex-direction: column;
gap: var(--lx-space-4);
padding: var(--lx-space-5);
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
background: var(--lx-color-bg-panel);
box-shadow: var(--lx-shadow-panel);
}
.lx-panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--lx-space-4);
}
.lx-panel__head h2 {
font-size: var(--lx-font-size-lg);
font-weight: 600;
line-height: var(--lx-line-height-tight);
}
.lx-panel__head p {
margin-top: 4px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
/* ---------- 标签 / 进度 ---------- */
.lx-chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 8px;
border-radius: var(--lx-radius-sm);
font-size: var(--lx-font-size-xs);
font-weight: 500;
white-space: nowrap;
}
.lx-chip::before {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
content: '';
}
.lx-chip--running {
color: var(--lx-color-brand);
background: var(--lx-color-brand-soft);
}
.lx-chip--review {
color: var(--lx-color-warning);
background: #fff4e8;
}
.lx-chip--done {
color: var(--lx-color-success);
background: #e8f8ec;
}
.lx-chip--failed {
color: var(--lx-color-danger);
background: #ffecec;
}
.lx-progress {
position: relative;
display: block;
width: 96px;
height: 6px;
overflow: hidden;
border-radius: var(--lx-radius-pill);
background: #eef1f5;
}
.lx-progress__bar {
position: absolute;
inset: 0 auto 0 0;
border-radius: inherit;
background: var(--lx-color-brand);
}
.lx-progress__value {
width: 40px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ---------- 表格 ---------- */
.lx-table {
overflow: hidden;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
background: var(--lx-color-bg-panel);
}
.lx-table__head,
.lx-table__row {
display: grid;
grid-template-columns: minmax(240px, 1.6fr) 110px 170px 110px 120px;
align-items: center;
gap: var(--lx-space-4);
padding: 0 var(--lx-space-4);
}
.lx-table__head {
height: 40px;
border-bottom: 1px solid var(--lx-color-border);
background: var(--lx-color-bg-subtle);
font-size: var(--lx-font-size-xs);
font-weight: 500;
color: var(--lx-color-text-tertiary);
}
.lx-table__row {
min-height: 52px;
border-bottom: 1px solid var(--lx-color-border);
font-size: var(--lx-font-size-sm);
transition: background var(--lx-motion-fast) var(--lx-ease-out);
}
.lx-table__row:last-child {
border-bottom: none;
}
.lx-table__row:hover {
background: var(--lx-color-bg-subtle);
}
.lx-table__name {
overflow: hidden;
font-weight: 500;
color: var(--lx-color-text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.lx-table__muted {
color: var(--lx-color-text-secondary);
font-variant-numeric: tabular-nums;
}
.lx-table__progress {
display: flex;
align-items: center;
gap: var(--lx-space-2);
}
/* ---------- 字段 ---------- */
.lx-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.lx-field__label {
font-size: var(--lx-font-size-sm);
font-weight: 500;
color: var(--lx-color-text-primary);
}
.lx-field .semi-input-wrapper {
border-radius: var(--lx-radius-md);
}
+475
View File
@@ -0,0 +1,475 @@
/* 方向 A · 云白蓝:白顶栏 + 左栏 + 浅灰工作区 */
.lx-shell {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
background: var(--lx-color-bg-app);
}
/* ---------- 顶栏 ---------- */
.lx-topbar {
z-index: 20;
display: flex;
flex: 0 0 var(--lx-topbar-height);
align-items: center;
gap: var(--lx-space-6);
height: var(--lx-topbar-height);
padding: 0 var(--lx-space-6) 0 var(--lx-space-5);
border-bottom: 1px solid var(--lx-color-border);
background: var(--lx-color-bg-panel);
}
.lx-topbar__brand {
display: flex;
min-width: 184px;
align-items: center;
gap: 10px;
}
.lx-topbar__mark {
display: inline-flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
border-radius: 8px;
background: linear-gradient(135deg, #1677ff, #4096ff);
color: #ffffff;
font-size: 18px;
font-weight: 700;
line-height: 1;
}
.lx-topbar__name {
font-size: var(--lx-font-size-lg);
font-weight: 600;
line-height: 1.15;
color: var(--lx-color-text-primary);
}
.lx-topbar__tag {
margin-top: 1px;
font-size: 10px;
line-height: 1.2;
color: var(--lx-color-text-tertiary);
}
.lx-topbar__brand-text {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.lx-topbar__modules {
display: flex;
height: 100%;
align-items: stretch;
gap: var(--lx-space-1);
}
.lx-topbar__module {
position: relative;
padding: 0 14px;
font-size: var(--lx-font-size-md);
color: var(--lx-color-text-secondary);
transition: color var(--lx-motion-fast) var(--lx-ease-out);
}
.lx-topbar__module:hover {
color: var(--lx-color-text-primary);
}
.lx-topbar__module.is-active {
color: var(--lx-color-brand);
font-weight: 600;
}
.lx-topbar__module.is-active::after {
position: absolute;
right: 12px;
bottom: 0;
left: 12px;
height: 2px;
border-radius: 2px 2px 0 0;
background: var(--lx-color-brand);
content: '';
}
.lx-topbar__actions {
display: flex;
margin-left: auto;
align-items: center;
gap: var(--lx-space-2);
}
.lx-icon-button {
position: relative;
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: var(--lx-radius-md);
color: var(--lx-color-text-secondary);
transition:
color var(--lx-motion-fast) var(--lx-ease-out),
background var(--lx-motion-fast) var(--lx-ease-out);
}
.lx-icon-button:hover {
color: var(--lx-color-text-primary);
background: var(--lx-color-bg-hover);
}
.lx-icon-button__dot {
position: absolute;
top: 7px;
right: 7px;
width: 6px;
height: 6px;
border: 1.5px solid var(--lx-color-bg-panel);
border-radius: 50%;
background: var(--lx-color-danger);
}
/* ---------- 用户菜单 ---------- */
.lx-user {
position: relative;
}
.lx-user__trigger {
display: flex;
height: 40px;
align-items: center;
gap: 8px;
padding: 0 6px 0 4px;
border-radius: var(--lx-radius-md);
transition: background var(--lx-motion-fast) var(--lx-ease-out);
}
.lx-user__trigger:hover {
background: var(--lx-color-bg-hover);
}
.lx-user__avatar {
display: inline-flex;
width: 30px;
height: 30px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
font-size: var(--lx-font-size-sm);
font-weight: 600;
}
.lx-user__meta {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
line-height: 1.25;
}
.lx-user__name {
font-size: var(--lx-font-size-sm);
font-weight: 500;
color: var(--lx-color-text-primary);
}
.lx-user__role {
font-size: 11px;
color: var(--lx-color-text-tertiary);
}
.lx-user__chevron {
color: var(--lx-color-text-tertiary);
}
.lx-user__menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 208px;
padding: 6px;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
background: var(--lx-color-bg-elevated);
box-shadow: var(--lx-shadow-overlay);
}
.lx-user__menu-header {
padding: 8px 10px;
border-bottom: 1px solid var(--lx-color-border);
margin-bottom: 4px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lx-user__menu-item {
display: flex;
width: 100%;
height: 34px;
align-items: center;
gap: 8px;
padding: 0 10px;
border-radius: var(--lx-radius-sm);
font-size: var(--lx-font-size-sm);
color: var(--lx-color-text-primary);
text-align: left;
}
.lx-user__menu-item:hover {
background: var(--lx-color-bg-hover);
}
/* ---------- 主体 ---------- */
.lx-shell__body {
display: flex;
flex: 1;
min-height: 0;
}
.lx-sidebar {
display: flex;
flex: 0 0 var(--lx-sidebar-width);
width: var(--lx-sidebar-width);
min-height: 0;
flex-direction: column;
border-right: 1px solid var(--lx-color-border);
background: var(--lx-color-bg-panel);
transition: width var(--lx-motion-base) var(--lx-ease-out);
}
.lx-shell--collapsed .lx-sidebar {
flex-basis: var(--lx-sidebar-collapsed-width);
width: var(--lx-sidebar-collapsed-width);
}
.lx-sidebar__heading {
display: flex;
height: 48px;
align-items: center;
justify-content: space-between;
padding: 0 var(--lx-space-4) 0 var(--lx-space-5);
}
.lx-sidebar__title {
font-size: var(--lx-font-size-sm);
font-weight: 600;
color: var(--lx-color-text-primary);
}
.lx-sidebar__toggle {
display: inline-flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
border-radius: var(--lx-radius-sm);
color: var(--lx-color-text-tertiary);
}
.lx-sidebar__toggle:hover {
color: var(--lx-color-text-primary);
background: var(--lx-color-bg-hover);
}
.lx-sidebar__nav {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
padding: 0 10px;
overflow-y: auto;
}
.lx-sidebar__item {
display: flex;
min-height: 38px;
align-items: center;
gap: 10px;
padding: 0 12px;
border-radius: var(--lx-radius-md);
color: var(--lx-color-text-secondary);
font-size: var(--lx-font-size-sm);
transition:
color var(--lx-motion-fast) var(--lx-ease-out),
background var(--lx-motion-fast) var(--lx-ease-out);
}
.lx-sidebar__item:hover {
color: var(--lx-color-text-primary);
background: var(--lx-color-bg-hover);
}
.lx-sidebar__item.is-active {
color: var(--lx-color-brand);
background: var(--lx-color-brand-soft);
font-weight: 500;
}
.lx-sidebar__item-icon {
display: inline-flex;
flex: 0 0 18px;
}
.lx-sidebar__item-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lx-sidebar__footer {
padding: var(--lx-space-4) var(--lx-space-5);
border-top: 1px solid var(--lx-color-border);
}
.lx-sidebar__env {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-sidebar__env-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--lx-color-success);
}
.lx-shell--collapsed .lx-sidebar__title,
.lx-shell--collapsed .lx-sidebar__item-label,
.lx-shell--collapsed .lx-sidebar__env-text {
display: none;
}
.lx-shell--collapsed .lx-sidebar__heading {
justify-content: center;
padding: 0;
}
.lx-shell--collapsed .lx-sidebar__item {
justify-content: center;
padding: 0;
}
.lx-shell--collapsed .lx-sidebar__footer {
padding: var(--lx-space-4) 0;
}
/* ---------- 工作区 ---------- */
.lx-workspace {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.lx-breadcrumbs {
display: flex;
flex: 0 0 44px;
height: 44px;
align-items: center;
gap: 6px;
padding: 0 var(--lx-content-padding);
border-bottom: 1px solid var(--lx-color-border);
background: var(--lx-color-bg-panel);
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-breadcrumbs__entry {
display: inline-flex;
align-items: center;
gap: 6px;
}
.lx-breadcrumbs__link {
color: var(--lx-color-text-tertiary);
}
.lx-breadcrumbs__link:hover {
color: var(--lx-color-brand);
}
.lx-breadcrumbs__current {
color: var(--lx-color-text-primary);
font-weight: 500;
}
.lx-breadcrumbs__sep {
color: #c9cdd4;
}
.lx-page {
flex: 1;
min-height: 0;
padding: var(--lx-content-padding);
overflow-y: auto;
}
@media (width <= 1080px) {
.lx-topbar__tag {
display: none;
}
.lx-sidebar {
flex-basis: var(--lx-sidebar-collapsed-width);
width: var(--lx-sidebar-collapsed-width);
}
.lx-sidebar__title,
.lx-sidebar__item-label,
.lx-sidebar__env-text {
display: none;
}
.lx-sidebar__heading {
justify-content: center;
padding: 0;
}
.lx-sidebar__item {
justify-content: center;
padding: 0;
}
}
@media (width <= 900px) {
.lx-topbar {
gap: var(--lx-space-3);
padding: 0 var(--lx-space-4);
}
.lx-topbar__brand {
min-width: auto;
}
.lx-topbar__tag {
display: none;
}
.lx-user__meta {
display: none;
}
.lx-content-padding,
.lx-page {
padding: var(--lx-space-4);
}
}
+551
View File
@@ -0,0 +1,551 @@
/* 方向 A · 云白蓝:登录页与业务页 */
/* ---------- 登录页 ---------- */
.lx-login {
position: relative;
display: flex;
min-height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 96px var(--lx-space-6) 72px;
background:
radial-gradient(circle at 50% 0%, #eef5ff 0%, transparent 46%), var(--lx-color-bg-app);
}
.lx-login__topbar {
position: absolute;
top: 0;
right: 0;
left: 0;
display: flex;
height: 72px;
align-items: center;
justify-content: space-between;
padding: 0 var(--lx-space-8);
}
.lx-login__brand {
display: flex;
align-items: center;
gap: 10px;
}
.lx-login__mark {
display: inline-flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
border-radius: 8px;
background: linear-gradient(135deg, #1677ff, #4096ff);
color: #ffffff;
font-size: 18px;
font-weight: 700;
}
.lx-login__wordmark {
font-size: var(--lx-font-size-lg);
font-weight: 600;
color: var(--lx-color-text-primary);
}
.lx-login__brand-sub {
font-size: 11px;
color: var(--lx-color-text-tertiary);
letter-spacing: 0.06em;
}
.lx-login__badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 10px;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-sm);
background: var(--lx-color-bg-panel);
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
}
.lx-login__badge::before {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--lx-color-success);
content: '';
}
.lx-login__panel {
width: 100%;
max-width: 400px;
padding: 32px;
border: 1px solid var(--lx-color-border);
border-radius: 8px;
background: var(--lx-color-bg-panel);
box-shadow: 0 8px 28px rgba(31, 35, 41, 0.08);
}
.lx-login__form {
display: flex;
flex-direction: column;
gap: var(--lx-space-5);
}
.lx-login__form-head h2 {
font-size: 24px;
font-weight: 600;
line-height: 1.3;
color: var(--lx-color-text-primary);
}
.lx-login__form-head p {
margin-top: 6px;
font-size: var(--lx-font-size-sm);
color: var(--lx-color-text-tertiary);
}
.lx-login__form form {
display: flex;
flex-direction: column;
gap: var(--lx-space-4);
}
.lx-login__form-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--lx-space-3);
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-login__form-foot {
display: flex;
align-items: flex-start;
gap: 6px;
padding-top: var(--lx-space-4);
border-top: 1px solid var(--lx-color-border);
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-login__form-foot-dot {
width: 5px;
height: 5px;
flex: 0 0 5px;
margin-top: 6px;
border-radius: 50%;
background: var(--lx-color-border-strong);
}
.lx-login__footer {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
height: 56px;
align-items: center;
justify-content: space-between;
padding: 0 var(--lx-space-8);
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-login__footer-links {
display: flex;
gap: var(--lx-space-4);
}
/* ---------- 工作台 ---------- */
.lx-home {
display: flex;
flex-direction: column;
gap: var(--lx-space-4);
}
.lx-home__hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--lx-space-4);
}
.lx-home__hero h1 {
font-size: 24px;
font-weight: 600;
line-height: 1.3;
}
.lx-home__hero-meta {
display: flex;
margin-top: 6px;
align-items: center;
gap: 8px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-home__hero-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--lx-color-border-strong);
}
.lx-home__hero-actions {
display: flex;
gap: var(--lx-space-2);
}
.lx-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--lx-space-4);
}
.lx-metric {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px 20px;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
background: var(--lx-color-bg-panel);
box-shadow: var(--lx-shadow-panel);
}
.lx-metric__head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
}
.lx-metric__icon {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: var(--lx-radius-sm);
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
}
.lx-metric__value {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 28px;
font-weight: 600;
line-height: 1;
color: var(--lx-color-text-primary);
font-variant-numeric: tabular-nums;
}
.lx-metric__unit {
font-size: var(--lx-font-size-xs);
font-weight: 400;
color: var(--lx-color-text-tertiary);
}
.lx-metric__foot {
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-metric__trend {
color: var(--lx-color-success);
}
.lx-home__grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 316px;
align-items: start;
gap: var(--lx-space-4);
}
.lx-home__tasks {
min-width: 0;
}
.lx-home__quick {
gap: var(--lx-space-3);
}
.lx-quick-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--lx-space-2);
}
.lx-quick-item {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 12px;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
background: var(--lx-color-bg-panel);
text-align: left;
transition:
border-color var(--lx-motion-fast) var(--lx-ease-out),
background var(--lx-motion-fast) var(--lx-ease-out);
}
.lx-quick-item:hover {
border-color: var(--lx-color-brand);
background: #f8fbff;
}
.lx-quick-item__icon {
display: inline-flex;
width: 30px;
height: 30px;
flex: 0 0 30px;
align-items: center;
justify-content: center;
border-radius: var(--lx-radius-sm);
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
}
.lx-quick-item__copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.lx-quick-item__label {
font-size: var(--lx-font-size-sm);
font-weight: 500;
color: var(--lx-color-text-primary);
white-space: nowrap;
}
.lx-quick-item__hint {
overflow: hidden;
font-size: 11px;
color: var(--lx-color-text-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}
.lx-quick-item__arrow {
display: none;
}
.lx-home__recent {
min-width: 0;
}
.lx-asset-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: var(--lx-space-3);
}
.lx-asset-tile {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
text-align: left;
}
.lx-asset-tile__preview {
display: block;
aspect-ratio: 3 / 4;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
background: #eef2f7;
}
.lx-asset-tile__preview--1 {
background: linear-gradient(150deg, #dbeafe, #93c5fd 55%, #60a5fa);
}
.lx-asset-tile__preview--2 {
background: linear-gradient(150deg, #e0f2fe, #bae6fd 55%, #7dd3fc);
}
.lx-asset-tile__preview--3 {
background: linear-gradient(150deg, #fef3c7, #fde68a 55%, #fcd34d);
}
.lx-asset-tile__preview--4 {
background: linear-gradient(150deg, #dcfce7, #bbf7d0 55%, #86efac);
}
.lx-asset-tile__preview--5 {
background: linear-gradient(150deg, #fae8ff, #f5d0fe 55%, #e9a8f5);
}
.lx-asset-tile__preview--6 {
background: linear-gradient(150deg, #ffe4e6, #fecdd3 55%, #fda4af);
}
.lx-asset-tile__meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.lx-asset-tile__name {
overflow: hidden;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.lx-asset-tile__spec {
font-size: 11px;
color: var(--lx-color-text-tertiary);
}
/* ---------- 占位工作区 / 错误页 ---------- */
.lx-page-header {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: var(--lx-space-4);
}
.lx-page-header h1 {
font-size: 22px;
font-weight: 600;
}
.lx-page-header p {
font-size: var(--lx-font-size-sm);
color: var(--lx-color-text-tertiary);
}
.lx-panel--empty {
align-items: center;
justify-content: center;
min-height: 320px;
}
.lx-workspace-missing {
display: flex;
min-height: 320px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--lx-space-3);
color: var(--lx-color-text-secondary);
}
.lx-workspace-missing strong {
font-size: 40px;
font-weight: 600;
color: var(--lx-color-text-tertiary);
}
.lx-error-page {
display: flex;
min-height: 70vh;
align-items: center;
justify-content: center;
padding: var(--lx-space-8);
}
.lx-error-page__body {
display: flex;
max-width: 480px;
flex-direction: column;
align-items: center;
text-align: center;
}
.lx-error-page__icon {
display: inline-flex;
width: 56px;
height: 56px;
align-items: center;
justify-content: center;
border-radius: 14px;
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
}
.lx-error-page__code {
margin-top: var(--lx-space-4);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.12em;
color: var(--lx-color-text-tertiary);
}
.lx-error-page__title {
margin-top: 6px;
font-size: 24px;
font-weight: 600;
}
.lx-error-page__description {
margin-top: 8px;
color: var(--lx-color-text-secondary);
}
.lx-error-page__trace {
margin-top: 12px;
padding: 6px 10px;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-sm);
background: var(--lx-color-bg-panel);
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-error-page__trace code {
font-family: var(--lx-font-mono);
color: var(--lx-color-text-secondary);
}
.lx-error-page__actions {
display: flex;
margin-top: var(--lx-space-5);
gap: var(--lx-space-2);
}
@media (width <= 1200px) {
.lx-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lx-home__grid {
grid-template-columns: minmax(0, 1fr);
}
.lx-asset-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (width <= 720px) {
.lx-metrics {
grid-template-columns: minmax(0, 1fr);
}
.lx-asset-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lx-home__hero {
flex-direction: column;
align-items: flex-start;
}
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_PUBLIC_API_BASE_URL?: string;
readonly VITE_PUBLIC_DEMO_SESSION?: string;
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "node"],
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "vite.config.ts", "vitest.config.ts"]
}
+27
View File
@@ -0,0 +1,27 @@
import { fileURLToPath, URL } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true,
},
preview: {
host: '127.0.0.1',
port: 4173,
strictPort: true,
},
build: {
target: 'es2022',
sourcemap: true,
},
});
+29
View File
@@ -0,0 +1,29 @@
import { fileURLToPath, URL } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
environment: 'jsdom',
include: ['src/**/*.spec.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'json-summary'],
include: ['src/api/**/*.ts', 'src/stores/**/*.ts', 'src/navigation/**/*.ts'],
exclude: ['src/api/generated/**', 'src/**/*.spec.{ts,tsx}'],
thresholds: {
lines: 70,
functions: 70,
statements: 70,
branches: 60,
},
},
},
});
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@lingxi/worker",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@lingxi/shared": "workspace:*",
"dotenv": "18.0.9",
"ioredis": "5.11.1",
"pino": "10.4.0",
"zod": "3.25.76"
},
"devDependencies": {
"@types/node": "24.19.2",
"tsx": "4.23.15",
"typescript": "5.9.3"
}
}
+40
View File
@@ -0,0 +1,40 @@
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
REDIS_URL: z
.string()
.min(1)
.refine(
(value) => value.startsWith('redis://') || value.startsWith('rediss://'),
'REDIS_URL 必须是 redis:// 连接串',
),
QUEUE: z.string().min(1).default('default'),
WORKER_CONCURRENCY: z.coerce.number().int().min(1).max(64).default(2),
});
export interface WorkerConfig {
nodeEnv: 'development' | 'test' | 'production';
logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
redisUrl: string;
queue: string;
concurrency: number;
}
export function loadWorkerConfig(source: NodeJS.ProcessEnv = process.env): WorkerConfig {
const result = envSchema.safeParse(source);
if (!result.success) {
const details = result.error.issues
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
.join('; ');
throw new Error(`Worker 环境变量校验失败:${details}`);
}
return {
nodeEnv: result.data.NODE_ENV,
logLevel: result.data.LOG_LEVEL,
redisUrl: result.data.REDIS_URL,
queue: result.data.QUEUE,
concurrency: result.data.WORKER_CONCURRENCY,
};
}
+62
View File
@@ -0,0 +1,62 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { config as loadDotenvFile } from 'dotenv';
import Redis from 'ioredis';
import pino from 'pino';
import { loadWorkerConfig } from './config';
function loadDotenv(): void {
for (const file of ['.env.local', '.env']) {
const path = resolve(process.cwd(), file);
if (existsSync(path)) {
loadDotenvFile({ path });
}
}
}
async function main(): Promise<void> {
loadDotenv();
const config = loadWorkerConfig();
const logger = pino({ level: config.logLevel, base: { service: 'lingxi-worker' } });
const redis = new Redis(config.redisUrl, {
lazyConnect: true,
maxRetriesPerRequest: null,
enableOfflineQueue: false,
});
redis.on('error', (error: Error) => {
logger.error({ err: error }, 'redis connection error');
});
await redis.connect();
await redis.ping();
logger.info({ queue: config.queue, concurrency: config.concurrency }, 'worker 已启动,等待任务');
// A0 只验证进程与依赖可用;BullMQ 消费者在 M1 批次接入(QUEUE 环境变量决定队列)
const heartbeat = setInterval(() => {
logger.debug({ queue: config.queue }, 'worker heartbeat');
}, 30_000);
heartbeat.unref();
const shutdown = async (signal: string): Promise<void> => {
clearInterval(heartbeat);
logger.info({ signal }, 'worker 正在退出');
await redis.quit();
};
process.on('SIGINT', () => {
void shutdown('SIGINT');
});
process.on('SIGTERM', () => {
void shutdown('SIGTERM');
});
}
void main().catch((error: unknown) => {
process.stderr.write(
`Worker 启动失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
);
process.exitCode = 1;
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"declarationMap": false,
"sourceMap": true
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022"],
"types": ["node"],
"noEmit": true
},
"include": ["src"]
}