- 建立 pnpm workspace + Turborepo 单仓、ESLint 9/Prettier/commitlint/husky 门禁 - NestJS + Fastify API:统一错误体、trace_id、Zod 管道、/health 真实依赖探活、OpenAPI 3.1 契约 - Prisma 基线:outbox_events/idempotency_keys/system_configs 与首版迁移、幂等种子 - shared 契约包:错误码、权限点、ULID、游标分页、命名/时间工具 - ui 包与 Web 壳层:Semi + Remix 图标统一出口、Cloud Blue 视觉、登录/工作台/错误页 - worker 骨架、Playwright 冒烟、Gitea CI、OpenAPI 快照与 oasdiff 回退脚本 - 补充 A0 实施报告与本地开发说明
This commit is contained in:
1 parent
9c3ab9768a
commit
04f49690f4
155 files changed
+18402
-28
No files matched your search
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@lingxi/ai-sdk",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "8.5.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* @lingxi/ai-sdk 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。
|
||||
* 本包在 A0 阶段仅用于固定仓库结构与依赖方向。
|
||||
*/
|
||||
export const PACKAGE_NAME = '@lingxi/ai-sdk';
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@lingxi/form-engine",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "8.5.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* @lingxi/form-engine 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。
|
||||
* 本包在 A0 阶段仅用于固定仓库结构与依赖方向。
|
||||
*/
|
||||
export const PACKAGE_NAME = '@lingxi/form-engine';
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@lingxi/render",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "8.5.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* @lingxi/render 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。
|
||||
* 本包在 A0 阶段仅用于固定仓库结构与依赖方向。
|
||||
*/
|
||||
export const PACKAGE_NAME = '@lingxi/render';
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@lingxi/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap",
|
||||
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "3.2.7",
|
||||
"tsup": "8.5.1",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "3.2.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 跨端共享常量(A0)
|
||||
* 约束:packages/shared 不依赖任何业务包(AGENTS §5)。
|
||||
*/
|
||||
export const API_PREFIX = '/api/v1';
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
export const MAX_PAGE_SIZE = 100;
|
||||
|
||||
export const ULID_LENGTH = 26;
|
||||
|
||||
/** 任务提交必须携带的幂等键请求头(AGENTS §3.13) */
|
||||
export const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
|
||||
|
||||
/** 请求追踪 ID 请求/响应头 */
|
||||
export const REQUEST_ID_HEADER = 'x-request-id';
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ApiError } from './api-error.js';
|
||||
import { ErrorCode } from './codes.js';
|
||||
|
||||
describe('ApiError', () => {
|
||||
it('使用默认文案并按错误码推导 HTTP 状态', () => {
|
||||
const error = new ApiError({ code: ErrorCode.NOT_FOUND, traceId: 'trace-001' });
|
||||
expect(error.message).toBe('资源不存在或已删除');
|
||||
expect(error.status).toBe(404);
|
||||
expect(error.name).toBe('ApiError');
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('toBody 输出统一错误体(trace_id 为 snake_case)', () => {
|
||||
const error = new ApiError({
|
||||
code: ErrorCode.COMPLIANCE_BLOCKED,
|
||||
message: '触发极限词:最',
|
||||
details: { words: ['最'] },
|
||||
traceId: 'trace-abc',
|
||||
});
|
||||
expect(error.toBody()).toEqual({
|
||||
code: 42202,
|
||||
message: '触发极限词:最',
|
||||
details: { words: ['最'] },
|
||||
trace_id: 'trace-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('details 缺省时不写入错误体', () => {
|
||||
const body = new ApiError({ code: ErrorCode.INTERNAL_ERROR }).toBody();
|
||||
expect(body).not.toHaveProperty('details');
|
||||
expect(body.trace_id).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ApiErrorBody } from '../schemas/api.js';
|
||||
import { ERROR_MESSAGES, type ErrorCode } from './codes.js';
|
||||
|
||||
/**
|
||||
* 前后端共用的 API 错误对象。
|
||||
* - 服务端:AllExceptionsFilter 用它把异常序列化成统一错误体;
|
||||
* - 前端:请求层把错误体还原成 ApiError,保留 trace_id 便于用户报障。
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
readonly status: number;
|
||||
readonly details?: unknown;
|
||||
readonly traceId: string;
|
||||
|
||||
constructor(params: { code: ErrorCode; message?: string; details?: unknown; traceId?: string }) {
|
||||
super(params.message ?? ERROR_MESSAGES[params.code]);
|
||||
this.name = 'ApiError';
|
||||
this.code = params.code;
|
||||
this.status = Math.floor(params.code / 100);
|
||||
this.details = params.details;
|
||||
this.traceId = params.traceId ?? '';
|
||||
}
|
||||
|
||||
toBody(): ApiErrorBody {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
...(this.details === undefined ? {} : { details: this.details }),
|
||||
trace_id: this.traceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ERROR_HTTP_STATUS,
|
||||
ERROR_MESSAGES,
|
||||
ErrorCode,
|
||||
httpStatusOf,
|
||||
isErrorCode,
|
||||
} from './codes.js';
|
||||
|
||||
describe('ErrorCode', () => {
|
||||
it('错误码段与工程规范 04 一致', () => {
|
||||
expect(ErrorCode.PARAM_INVALID).toBe(40001);
|
||||
expect(ErrorCode.UNAUTHENTICATED).toBe(40101);
|
||||
expect(ErrorCode.TOKEN_EXPIRED).toBe(40102);
|
||||
expect(ErrorCode.PERMISSION_DENIED).toBe(40301);
|
||||
expect(ErrorCode.OUT_OF_SCOPE).toBe(40302);
|
||||
expect(ErrorCode.NOT_FOUND).toBe(40401);
|
||||
expect(ErrorCode.VERSION_CONFLICT).toBe(40901);
|
||||
expect(ErrorCode.STATE_NOT_ALLOWED).toBe(40903);
|
||||
expect(ErrorCode.COMPLIANCE_BLOCKED).toBe(42202);
|
||||
expect(ErrorCode.COST_OVER_BUDGET).toBe(42203);
|
||||
expect(ErrorCode.RATE_LIMITED).toBe(42901);
|
||||
expect(ErrorCode.INTERNAL_ERROR).toBe(50001);
|
||||
expect(ErrorCode.DEPENDENCY_UNAVAILABLE).toBe(50002);
|
||||
expect(ErrorCode.TIMEOUT).toBe(50003);
|
||||
});
|
||||
|
||||
it('每个错误码都有 HTTP 状态与默认提示', () => {
|
||||
for (const code of Object.values(ErrorCode)) {
|
||||
expect(ERROR_HTTP_STATUS[code]).toBeGreaterThanOrEqual(400);
|
||||
expect(ERROR_HTTP_STATUS[code]).toBeLessThan(600);
|
||||
expect(ERROR_MESSAGES[code].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('依赖不可用映射 503、超时映射 504', () => {
|
||||
expect(ERROR_HTTP_STATUS[ErrorCode.DEPENDENCY_UNAVAILABLE]).toBe(503);
|
||||
expect(ERROR_HTTP_STATUS[ErrorCode.TIMEOUT]).toBe(504);
|
||||
});
|
||||
|
||||
it('isErrorCode 只认可登记的错误码', () => {
|
||||
expect(isErrorCode(40001)).toBe(true);
|
||||
expect(isErrorCode(40099)).toBe(false);
|
||||
expect(isErrorCode(200)).toBe(false);
|
||||
});
|
||||
|
||||
it('httpStatusOf 从百位推导,非法码回退 500', () => {
|
||||
expect(httpStatusOf(42203)).toBe(422);
|
||||
expect(httpStatusOf(40101)).toBe(401);
|
||||
expect(httpStatusOf(123)).toBe(500);
|
||||
expect(httpStatusOf(70001)).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 统一错误码(docs/工程规范/04-API与路由规范.md §4)
|
||||
* API-14:新增错误码必须集中登记在这里,禁止业务代码写裸数字。
|
||||
*/
|
||||
export const ErrorCode = {
|
||||
/** 400xx 参数校验 */
|
||||
PARAM_INVALID: 40001,
|
||||
|
||||
/** 401xx 认证 */
|
||||
UNAUTHENTICATED: 40101,
|
||||
TOKEN_EXPIRED: 40102,
|
||||
SIGNATURE_INVALID: 40103,
|
||||
|
||||
/** 403xx 授权 */
|
||||
PERMISSION_DENIED: 40301,
|
||||
OUT_OF_SCOPE: 40302,
|
||||
SPECIAL_PERMISSION_REQUIRED: 40303,
|
||||
SOD_VIOLATION: 40304,
|
||||
|
||||
/** 404xx 不存在 */
|
||||
NOT_FOUND: 40401,
|
||||
|
||||
/** 409xx 冲突 */
|
||||
VERSION_CONFLICT: 40901,
|
||||
DUPLICATE_SUBMIT: 40902,
|
||||
STATE_NOT_ALLOWED: 40903,
|
||||
|
||||
/** 422xx 业务规则 */
|
||||
BUSINESS_RULE_VIOLATION: 42201,
|
||||
COMPLIANCE_BLOCKED: 42202,
|
||||
COST_OVER_BUDGET: 42203,
|
||||
|
||||
/** 429xx 限流 */
|
||||
RATE_LIMITED: 42901,
|
||||
CONCURRENCY_LIMITED: 42902,
|
||||
|
||||
/** 500xx 系统 */
|
||||
INTERNAL_ERROR: 50001,
|
||||
DEPENDENCY_UNAVAILABLE: 50002,
|
||||
TIMEOUT: 50003,
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
||||
|
||||
/** 错误码 → HTTP 状态码(4xx/5xx 段由百位决定) */
|
||||
export const ERROR_HTTP_STATUS: Readonly<Record<ErrorCode, number>> = {
|
||||
[ErrorCode.PARAM_INVALID]: 400,
|
||||
[ErrorCode.UNAUTHENTICATED]: 401,
|
||||
[ErrorCode.TOKEN_EXPIRED]: 401,
|
||||
[ErrorCode.SIGNATURE_INVALID]: 401,
|
||||
[ErrorCode.PERMISSION_DENIED]: 403,
|
||||
[ErrorCode.OUT_OF_SCOPE]: 403,
|
||||
[ErrorCode.SPECIAL_PERMISSION_REQUIRED]: 403,
|
||||
[ErrorCode.SOD_VIOLATION]: 403,
|
||||
[ErrorCode.NOT_FOUND]: 404,
|
||||
[ErrorCode.VERSION_CONFLICT]: 409,
|
||||
[ErrorCode.DUPLICATE_SUBMIT]: 409,
|
||||
[ErrorCode.STATE_NOT_ALLOWED]: 409,
|
||||
[ErrorCode.BUSINESS_RULE_VIOLATION]: 422,
|
||||
[ErrorCode.COMPLIANCE_BLOCKED]: 422,
|
||||
[ErrorCode.COST_OVER_BUDGET]: 422,
|
||||
[ErrorCode.RATE_LIMITED]: 429,
|
||||
[ErrorCode.CONCURRENCY_LIMITED]: 429,
|
||||
[ErrorCode.INTERNAL_ERROR]: 500,
|
||||
[ErrorCode.DEPENDENCY_UNAVAILABLE]: 503,
|
||||
[ErrorCode.TIMEOUT]: 504,
|
||||
};
|
||||
|
||||
/** 默认中文提示(前端兜底展示;服务端可按场景覆盖 message) */
|
||||
export const ERROR_MESSAGES: Readonly<Record<ErrorCode, string>> = {
|
||||
[ErrorCode.PARAM_INVALID]: '参数缺失或格式错误',
|
||||
[ErrorCode.UNAUTHENTICATED]: '请先登录',
|
||||
[ErrorCode.TOKEN_EXPIRED]: '登录已过期,请重新登录',
|
||||
[ErrorCode.SIGNATURE_INVALID]: '签名校验失败',
|
||||
[ErrorCode.PERMISSION_DENIED]: '没有操作权限',
|
||||
[ErrorCode.OUT_OF_SCOPE]: '数据范围之外',
|
||||
[ErrorCode.SPECIAL_PERMISSION_REQUIRED]: '缺少特殊权限',
|
||||
[ErrorCode.SOD_VIOLATION]: '违反职责互斥,不能执行该操作',
|
||||
[ErrorCode.NOT_FOUND]: '资源不存在或已删除',
|
||||
[ErrorCode.VERSION_CONFLICT]: '数据已被他人修改,请刷新后重试',
|
||||
[ErrorCode.DUPLICATE_SUBMIT]: '请勿重复提交',
|
||||
[ErrorCode.STATE_NOT_ALLOWED]: '当前状态不允许该操作',
|
||||
[ErrorCode.BUSINESS_RULE_VIOLATION]: '业务校验未通过',
|
||||
[ErrorCode.COMPLIANCE_BLOCKED]: '内容触发合规拦截',
|
||||
[ErrorCode.COST_OVER_BUDGET]: '成本超出预算',
|
||||
[ErrorCode.RATE_LIMITED]: '请求过于频繁,请稍后再试',
|
||||
[ErrorCode.CONCURRENCY_LIMITED]: '并发任务已达上限',
|
||||
[ErrorCode.INTERNAL_ERROR]: '服务内部错误',
|
||||
[ErrorCode.DEPENDENCY_UNAVAILABLE]: '依赖服务不可用',
|
||||
[ErrorCode.TIMEOUT]: '请求超时',
|
||||
};
|
||||
|
||||
const ERROR_CODE_VALUES: ReadonlySet<number> = new Set(Object.values(ErrorCode));
|
||||
|
||||
export function isErrorCode(value: number): value is ErrorCode {
|
||||
return ERROR_CODE_VALUES.has(value);
|
||||
}
|
||||
|
||||
/** 数字错误码对应的 HTTP 状态;优先查表(502/503/504 等细分),非法码回退 500 */
|
||||
export function httpStatusOf(code: number): number {
|
||||
if (isErrorCode(code)) {
|
||||
return ERROR_HTTP_STATUS[code];
|
||||
}
|
||||
const segment = Math.floor(code / 100);
|
||||
return segment >= 400 && segment <= 599 ? segment : 500;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from './constants.js';
|
||||
export * from './errors/api-error.js';
|
||||
export * from './errors/codes.js';
|
||||
export * from './naming.js';
|
||||
export * from './permissions.js';
|
||||
export * from './schemas/api.js';
|
||||
export * from './schemas/common.js';
|
||||
export * from './time.js';
|
||||
export * from './ulid.js';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { dedupeName, sanitizeFilename } from './naming.js';
|
||||
|
||||
describe('sanitizeFilename', () => {
|
||||
it('替换路径分隔符与非法字符,保留中文', () => {
|
||||
expect(sanitizeFilename('素材/标签:01*?.png')).toBe('素材_标签_01__.png');
|
||||
});
|
||||
|
||||
it('去除首尾空格与点,空名回退', () => {
|
||||
expect(sanitizeFilename(' ..脚本草稿 .. ')).toBe('脚本草稿');
|
||||
expect(sanitizeFilename(' ')).toBe('未命名');
|
||||
expect(sanitizeFilename('***', { fallback: 'file' })).toBe('file');
|
||||
});
|
||||
|
||||
it('Windows 保留名追加下划线', () => {
|
||||
expect(sanitizeFilename('CON')).toBe('CON_');
|
||||
expect(sanitizeFilename('lpt1')).toBe('lpt1_');
|
||||
});
|
||||
|
||||
it('超长时保留扩展名', () => {
|
||||
const name = sanitizeFilename(`${'a'.repeat(200)}.png`, { maxLength: 20 });
|
||||
expect(name).toHaveLength(20);
|
||||
expect(name.endsWith('.png')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedupeName(R-01 同名自动加序号)', () => {
|
||||
it('不冲突时原样返回', () => {
|
||||
expect(dedupeName('脚本-01', new Set())).toBe('脚本-01');
|
||||
});
|
||||
|
||||
it('冲突时追加两位序号', () => {
|
||||
const existing = new Set(['素材.png', '素材-02.png']);
|
||||
expect(dedupeName('素材.png', existing)).toBe('素材-03.png');
|
||||
});
|
||||
|
||||
it('带序号的名字基于原始基名递增', () => {
|
||||
const existing = new Set(['素材-02.png', '素材-03.png']);
|
||||
expect(dedupeName('素材-03.png', existing)).toBe('素材-04.png');
|
||||
});
|
||||
|
||||
it('序号耗尽时抛错', () => {
|
||||
const existing = new Set(['a', ...Array.from({ length: 3 }, (_, i) => `a-0${String(i + 2)}`)]);
|
||||
expect(() => dedupeName('a', existing, 3)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 命名工具(A0-SH-01):文件名清洗 + 同名自动加序号(PRD R-01)
|
||||
* 浏览器 / Node 通用,不依赖 node:path。
|
||||
*/
|
||||
|
||||
const ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|]/g;
|
||||
const RESERVED_FILENAMES = new Set([
|
||||
'CON',
|
||||
'PRN',
|
||||
'AUX',
|
||||
'NUL',
|
||||
'COM1',
|
||||
'COM2',
|
||||
'COM3',
|
||||
'COM4',
|
||||
'COM5',
|
||||
'COM6',
|
||||
'COM7',
|
||||
'COM8',
|
||||
'COM9',
|
||||
'LPT1',
|
||||
'LPT2',
|
||||
'LPT3',
|
||||
'LPT4',
|
||||
'LPT5',
|
||||
'LPT6',
|
||||
'LPT7',
|
||||
'LPT8',
|
||||
'LPT9',
|
||||
]);
|
||||
|
||||
const MAX_FILENAME_LENGTH = 120;
|
||||
|
||||
export interface SanitizeFilenameOptions {
|
||||
maxLength?: number;
|
||||
replacement?: string;
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
/** 去掉控制字符(\u0000-\u001f、\u007f),保留常规空白 */
|
||||
function stripControlChars(input: string): string {
|
||||
let output = '';
|
||||
for (const char of input) {
|
||||
const code = char.codePointAt(0) ?? 0;
|
||||
if (code >= 0x20 && code !== 0x7f) {
|
||||
output += char;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** 清洗文件名:去除非法字符 / 控制字符 / 首尾空格与点,中文原样保留 */
|
||||
export function sanitizeFilename(input: string, options: SanitizeFilenameOptions = {}): string {
|
||||
const maxLength = options.maxLength ?? MAX_FILENAME_LENGTH;
|
||||
const replacement = options.replacement ?? '_';
|
||||
const fallback = options.fallback ?? '未命名';
|
||||
|
||||
let name = stripControlChars(input.normalize('NFC'))
|
||||
.replace(ILLEGAL_FILENAME_CHARS, replacement)
|
||||
.trim();
|
||||
name = name.replace(/^[.\s]+|[.\s]+$/g, '');
|
||||
|
||||
if (name.length === 0 || name.replaceAll(replacement, '').trim().length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
if (RESERVED_FILENAMES.has(name.toUpperCase())) {
|
||||
name = `${name}_`;
|
||||
}
|
||||
if (name.length > maxLength) {
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
const extension = dotIndex > 0 ? name.slice(dotIndex) : '';
|
||||
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
|
||||
const keep = Math.max(1, maxLength - extension.length);
|
||||
name = `${base.slice(0, keep)}${extension}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
const SEQUENCE_PATTERN = /^(.*)-(\d{2,})$/;
|
||||
|
||||
function splitExtension(name: string): { stem: string; extension: string } {
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
if (dotIndex <= 0 || dotIndex === name.length - 1) {
|
||||
return { stem: name, extension: '' };
|
||||
}
|
||||
return { stem: name.slice(0, dotIndex), extension: name.slice(dotIndex) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 同名自动加序号(R-01):从 base-02 开始尝试到 maxAttempts,全部占用则抛错。
|
||||
* 已带序号的名字会基于其原始基名继续递增;扩展名保持不变。
|
||||
*/
|
||||
export function dedupeName(name: string, existing: ReadonlySet<string>, maxAttempts = 999): string {
|
||||
if (!existing.has(name)) {
|
||||
return name;
|
||||
}
|
||||
const { stem, extension } = splitExtension(name);
|
||||
const matched = SEQUENCE_PATTERN.exec(stem);
|
||||
const root = matched?.[1] ?? stem;
|
||||
for (let sequence = 2; sequence <= maxAttempts; sequence += 1) {
|
||||
const candidate = `${root}-${String(sequence).padStart(2, '0')}${extension}`;
|
||||
if (!existing.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new RangeError(`同名文件过多,无法为「${name}」生成序号`);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isPermissionCode,
|
||||
parsePermission,
|
||||
Permission,
|
||||
PERMISSION_CODES,
|
||||
SPECIAL_PERMISSIONS,
|
||||
} from './permissions.js';
|
||||
|
||||
describe('权限点常量', () => {
|
||||
it('全部符合 模块:动作 命名且模块为小写', () => {
|
||||
for (const code of PERMISSION_CODES) {
|
||||
expect(code).toMatch(/^[a-z][a-z_]*:[a-z][a-z_]*$/);
|
||||
expect(parsePermission(code)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('特殊权限必须是已登记的权限点', () => {
|
||||
for (const code of SPECIAL_PERMISSIONS) {
|
||||
expect(PERMISSION_CODES).toContain(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('isPermissionCode 只认可登记值', () => {
|
||||
expect(isPermissionCode(Permission.SCRIPT_READ)).toBe(true);
|
||||
expect(isPermissionCode('script:delete_all')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePermission', () => {
|
||||
it('拆分模块与动作', () => {
|
||||
expect(parsePermission('material:render')).toEqual({ module: 'material', action: 'render' });
|
||||
});
|
||||
|
||||
it('非法格式返回 null', () => {
|
||||
expect(parsePermission('scriptread')).toBeNull();
|
||||
expect(parsePermission(':read')).toBeNull();
|
||||
expect(parsePermission('script:')).toBeNull();
|
||||
expect(parsePermission('a:b:c')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 权限点常量骨架(A0-SH-01)
|
||||
* 命名规范:<模块>:<动作>,模块取后端八模块语义(identity / client / catalog / industry /
|
||||
* script / media / delivery / platform),全小写;动作使用动词或名词短语。
|
||||
* A1/A2 接入真实角色-权限表后,这里只增不改;删除/改名需评审(AGENTS §10)。
|
||||
*/
|
||||
export const Permission = {
|
||||
// 脚本
|
||||
SCRIPT_READ: 'script:read',
|
||||
SCRIPT_WRITE: 'script:write',
|
||||
SCRIPT_REVIEW: 'script:review',
|
||||
// 素材
|
||||
MATERIAL_READ: 'material:read',
|
||||
MATERIAL_WRITE: 'material:write',
|
||||
MATERIAL_RENDER: 'material:render',
|
||||
MATERIAL_REVIEW: 'material:review',
|
||||
// 资产
|
||||
ASSET_READ: 'asset:read',
|
||||
ASSET_WRITE: 'asset:write',
|
||||
// 导出交付
|
||||
DELIVERY_READ: 'delivery:read',
|
||||
DELIVERY_EXPORT: 'delivery:export',
|
||||
// 客户
|
||||
CUSTOMER_READ: 'customer:read',
|
||||
CUSTOMER_MANAGE: 'customer:manage',
|
||||
CUSTOMER_FIELD_APPROVE: 'customer_field:approve',
|
||||
// 组织与角色
|
||||
USER_MANAGE: 'user:manage',
|
||||
ROLE_MANAGE: 'role:manage',
|
||||
// 平台配置
|
||||
INDUSTRY_MANAGE: 'industry:manage',
|
||||
INDUSTRY_PUBLISH: 'industry:publish',
|
||||
DICT_MANAGE: 'dict:manage',
|
||||
FORM_DESIGN: 'form:design',
|
||||
AI_CONFIGURE: 'ai:configure',
|
||||
SYSTEM_CONFIGURE: 'system:configure',
|
||||
// 审计
|
||||
AUDIT_READ: 'audit:read',
|
||||
AUDIT_EXPORT: 'audit:export',
|
||||
} as const;
|
||||
|
||||
export type PermissionCode = (typeof Permission)[keyof typeof Permission];
|
||||
|
||||
export const PERMISSION_CODES: readonly PermissionCode[] = Object.values(Permission);
|
||||
|
||||
/** 需要额外单独校验的特殊权限(技术架构 §6.2「关键特殊权限」) */
|
||||
export const SPECIAL_PERMISSIONS: readonly PermissionCode[] = [
|
||||
Permission.DELIVERY_EXPORT,
|
||||
Permission.AI_CONFIGURE,
|
||||
Permission.AUDIT_EXPORT,
|
||||
Permission.INDUSTRY_PUBLISH,
|
||||
Permission.CUSTOMER_FIELD_APPROVE,
|
||||
];
|
||||
|
||||
const PERMISSION_CODE_SET: ReadonlySet<string> = new Set(PERMISSION_CODES);
|
||||
|
||||
export function isPermissionCode(value: string): value is PermissionCode {
|
||||
return PERMISSION_CODE_SET.has(value);
|
||||
}
|
||||
|
||||
export interface ParsedPermission {
|
||||
module: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/** 拆分权限点为 { module, action };格式不合法返回 null */
|
||||
export function parsePermission(code: string): ParsedPermission | null {
|
||||
const separator = code.indexOf(':');
|
||||
if (separator <= 0 || separator === code.length - 1) {
|
||||
return null;
|
||||
}
|
||||
const module = code.slice(0, separator);
|
||||
const action = code.slice(separator + 1);
|
||||
if (module.includes(':') || action.includes(':')) {
|
||||
return null;
|
||||
}
|
||||
return { module, action };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '../constants.js';
|
||||
import { apiErrorBodySchema, cursorPaginationQuerySchema, listResponseSchema } from './api.js';
|
||||
|
||||
describe('apiErrorBodySchema', () => {
|
||||
it('接受统一错误体', () => {
|
||||
const parsed = apiErrorBodySchema.parse({
|
||||
code: 40001,
|
||||
message: '参数缺失',
|
||||
details: { field: 'name' },
|
||||
trace_id: 'trace-1',
|
||||
});
|
||||
expect(parsed.code).toBe(40001);
|
||||
});
|
||||
|
||||
it('拒绝缺少 trace_id 的错误体', () => {
|
||||
expect(() => apiErrorBodySchema.parse({ code: 40001, message: 'x' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursorPaginationQuerySchema', () => {
|
||||
it('缺省 limit 为默认页大小', () => {
|
||||
expect(cursorPaginationQuerySchema.parse({})).toEqual({ limit: DEFAULT_PAGE_SIZE });
|
||||
expect(DEFAULT_PAGE_SIZE).toBe(20);
|
||||
});
|
||||
|
||||
it('字符串 limit 会被转成数字(query 参数场景)', () => {
|
||||
expect(cursorPaginationQuerySchema.parse({ limit: '50', cursor: 'abc' })).toEqual({
|
||||
limit: 50,
|
||||
cursor: 'abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('拒绝越界 limit', () => {
|
||||
expect(() => cursorPaginationQuerySchema.parse({ limit: 0 })).toThrow();
|
||||
expect(() => cursorPaginationQuerySchema.parse({ limit: MAX_PAGE_SIZE + 1 })).toThrow();
|
||||
expect(() => cursorPaginationQuerySchema.parse({ limit: 'abc' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listResponseSchema', () => {
|
||||
it('列表信封为 { items, next_cursor }', () => {
|
||||
const schema = listResponseSchema(apiErrorBodySchema);
|
||||
const parsed = schema.parse({ items: [], next_cursor: null });
|
||||
expect(parsed).toEqual({ items: [], next_cursor: null });
|
||||
});
|
||||
|
||||
it('next_cursor 可为字符串', () => {
|
||||
const schema = listResponseSchema(apiErrorBodySchema);
|
||||
expect(schema.parse({ items: [], next_cursor: 'cursor-2' }).next_cursor).toBe('cursor-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '../constants.js';
|
||||
|
||||
/** 统一错误体(工程规范 04 §4):{ code, message, details, trace_id } */
|
||||
export const apiErrorBodySchema = z.object({
|
||||
code: z.number().int().positive(),
|
||||
message: z.string().min(1),
|
||||
details: z.unknown().optional(),
|
||||
trace_id: z.string().min(1),
|
||||
});
|
||||
|
||||
export type ApiErrorBody = z.infer<typeof apiErrorBodySchema>;
|
||||
|
||||
/**
|
||||
* 游标分页查询参数:query 用 snake_case(C-01)
|
||||
* cursor:上一页返回的 next_cursor;limit:默认 20、上限 100
|
||||
*/
|
||||
export const cursorPaginationQuerySchema = z.object({
|
||||
cursor: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
|
||||
});
|
||||
|
||||
export type CursorPaginationQuery = z.infer<typeof cursorPaginationQuerySchema>;
|
||||
|
||||
/** 列表响应统一信封(C-02):{ items, next_cursor },单资源不套 data */
|
||||
export interface ListResponse<T> {
|
||||
items: T[];
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export function listResponseSchema<T extends z.ZodTypeAny>(
|
||||
itemSchema: T,
|
||||
): z.ZodObject<{ items: z.ZodArray<T>; next_cursor: z.ZodNullable<z.ZodString> }> {
|
||||
return z.object({
|
||||
items: z.array(itemSchema),
|
||||
next_cursor: z.string().nullable(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ulid } from '../ulid.js';
|
||||
import { idempotencyKeySchema, isoDateTimeSchema, traceIdSchema, ulidSchema } from './common.js';
|
||||
|
||||
describe('common schemas', () => {
|
||||
it('ulidSchema 接受应用侧生成的 ULID', () => {
|
||||
expect(ulidSchema.parse(ulid())).toHaveLength(26);
|
||||
expect(() => ulidSchema.parse('not-a-ulid')).toThrow();
|
||||
});
|
||||
|
||||
it('isoDateTimeSchema 要求带时区', () => {
|
||||
expect(isoDateTimeSchema.parse('2026-11-02T08:00:00.000Z')).toBeTruthy();
|
||||
expect(() => isoDateTimeSchema.parse('2026-11-02 08:00:00')).toThrow();
|
||||
});
|
||||
|
||||
it('traceIdSchema 限制长度', () => {
|
||||
expect(traceIdSchema.parse('trace-12345678')).toBe('trace-12345678');
|
||||
expect(() => traceIdSchema.parse('short')).toThrow();
|
||||
});
|
||||
|
||||
it('idempotencyKeySchema 只接受可见 ASCII', () => {
|
||||
expect(idempotencyKeySchema.parse('idem-key-0001')).toBe('idem-key-0001');
|
||||
expect(() => idempotencyKeySchema.parse('幂等键-0001')).toThrow();
|
||||
expect(() => idempotencyKeySchema.parse('short')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ULID_PATTERN } from '../ulid.js';
|
||||
|
||||
/** 26 位 ULID 主键 */
|
||||
export const ulidSchema = z.string().regex(ULID_PATTERN, '必须是 26 位大写 ULID');
|
||||
|
||||
export const idSchema = ulidSchema;
|
||||
|
||||
/** ISO 8601 时间(必须带时区,A0 统一用 UTC Z) */
|
||||
export const isoDateTimeSchema = z.string().datetime({ offset: true });
|
||||
|
||||
export const sortOrderSchema = z.enum(['asc', 'desc']);
|
||||
|
||||
/** 请求追踪 ID(与 Pino requestId 同源) */
|
||||
export const traceIdSchema = z.string().min(8).max(64);
|
||||
|
||||
/** 任务提交幂等键:8–128 位可见 ASCII */
|
||||
export const idempotencyKeySchema = z
|
||||
.string()
|
||||
.min(8)
|
||||
.max(128)
|
||||
.regex(/^[\x21-\x7e]+$/, '幂等键必须是可见 ASCII 字符');
|
||||
|
||||
/** 客户 ID:ULID */
|
||||
export const customerIdSchema = ulidSchema;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { addSeconds, isIsoDateString, nowIso, toIsoString } from './time.js';
|
||||
|
||||
describe('time utils', () => {
|
||||
it('nowIso 返回 UTC ISO 字符串', () => {
|
||||
expect(isIsoDateString(nowIso())).toBe(true);
|
||||
});
|
||||
|
||||
it('toIsoString 支持 Date / 时间戳 / 字符串', () => {
|
||||
const date = new Date('2026-11-02T08:00:00.000Z');
|
||||
expect(toIsoString(date)).toBe('2026-11-02T08:00:00.000Z');
|
||||
expect(toIsoString(date.getTime())).toBe('2026-11-02T08:00:00.000Z');
|
||||
expect(toIsoString('2026-11-02T08:00:00.000Z')).toBe('2026-11-02T08:00:00.000Z');
|
||||
});
|
||||
|
||||
it('非法时间抛错', () => {
|
||||
expect(() => toIsoString('not-a-date')).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('addSeconds 不修改原对象', () => {
|
||||
const date = new Date('2026-11-02T08:00:00.000Z');
|
||||
expect(addSeconds(date, 90).toISOString()).toBe('2026-11-02T08:01:30.000Z');
|
||||
expect(date.toISOString()).toBe('2026-11-02T08:00:00.000Z');
|
||||
});
|
||||
|
||||
it('isIsoDateString 拒绝非 UTC 格式', () => {
|
||||
expect(isIsoDateString('2026-11-02T08:00:00+08:00')).toBe(false);
|
||||
expect(isIsoDateString('2026-13-02T08:00:00.000Z')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 时间工具:统一 ISO 8601(UTC)字符串,跨端序列化口径一致 */
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function toIsoString(value: Date | number | string): string {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new TypeError(`无法解析时间:${String(value)}`);
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function addSeconds(date: Date, seconds: number): Date {
|
||||
return new Date(date.getTime() + seconds * 1000);
|
||||
}
|
||||
|
||||
const ISO_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
|
||||
|
||||
export function isIsoDateString(value: string): boolean {
|
||||
return ISO_PATTERN.test(value) && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isUlid, ulid, ULID_PATTERN, ulidToDate, ulidToTimestamp } from './ulid.js';
|
||||
|
||||
describe('ulid', () => {
|
||||
it('生成 26 位 Crockford Base32 且不含 I/L/O/U', () => {
|
||||
const id = ulid();
|
||||
expect(id).toHaveLength(26);
|
||||
expect(ULID_PATTERN.test(id)).toBe(true);
|
||||
expect(id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
|
||||
});
|
||||
|
||||
it('时间戳可从 ID 还原', () => {
|
||||
const timestamp = 1_800_000_000_000;
|
||||
const id = ulid(timestamp);
|
||||
expect(ulidToTimestamp(id)).toBe(timestamp);
|
||||
expect(ulidToDate(id).getTime()).toBe(timestamp);
|
||||
});
|
||||
|
||||
it('同一毫秒内生成不重复', () => {
|
||||
const ids = new Set(Array.from({ length: 1000 }, () => ulid(1_800_000_000_000)));
|
||||
expect(ids.size).toBe(1000);
|
||||
});
|
||||
|
||||
it('isUlid 拒绝非法输入', () => {
|
||||
expect(isUlid(ulid())).toBe(true);
|
||||
expect(isUlid('01ARZ3NDEKTSV4RRFFQ69G5FA')).toBe(false);
|
||||
expect(isUlid('01ARZ3NDEKTSV4RRFFQ69G5FAV'.toLowerCase())).toBe(false);
|
||||
expect(isUlid('O1ARZ3NDEKTSV4RRFFQ69G5FAV')).toBe(false);
|
||||
});
|
||||
|
||||
it('解析非法 ULID 抛错', () => {
|
||||
expect(() => ulidToTimestamp('bad')).toThrow(TypeError);
|
||||
expect(() => ulid(-1)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* ULID 生成与解析(Crockford Base32,26 字符 = 48bit 时间 + 80bit 随机)
|
||||
* 主键统一由应用侧生成 char(26) ULID(工程规范 05)。
|
||||
*/
|
||||
import { ULID_LENGTH } from './constants.js';
|
||||
|
||||
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||
const ENCODING_LEN = 32;
|
||||
|
||||
export const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
||||
|
||||
function randomBytes(length: number): Uint8Array {
|
||||
const bytes = new Uint8Array(length);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** 生成 ULID;同一毫秒内依靠 80bit 随机数避免碰撞 */
|
||||
export function ulid(timestamp: number = Date.now()): string {
|
||||
if (!Number.isInteger(timestamp) || timestamp < 0 || timestamp > 0xffffffffffff) {
|
||||
throw new RangeError('ULID 时间戳必须是 48bit 非负整数');
|
||||
}
|
||||
let time = timestamp;
|
||||
const timeChars: string[] = new Array<string>(10);
|
||||
for (let i = 9; i >= 0; i -= 1) {
|
||||
timeChars[i] = ENCODING[time % ENCODING_LEN] ?? '0';
|
||||
time = Math.floor(time / ENCODING_LEN);
|
||||
}
|
||||
const randomChars = Array.from(randomBytes(16), (byte) => ENCODING[byte & 31] ?? '0');
|
||||
return `${timeChars.join('')}${randomChars.join('')}`;
|
||||
}
|
||||
|
||||
export function isUlid(value: string): boolean {
|
||||
return value.length === ULID_LENGTH && ULID_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/** 解析 ULID 前 10 位得到毫秒时间戳 */
|
||||
export function ulidToTimestamp(id: string): number {
|
||||
if (!isUlid(id)) {
|
||||
throw new TypeError(`不是合法 ULID:${id}`);
|
||||
}
|
||||
let timestamp = 0;
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const char = id[i] ?? '';
|
||||
const value = ENCODING.indexOf(char);
|
||||
if (value < 0) {
|
||||
throw new TypeError(`ULID 含非法字符:${char}`);
|
||||
}
|
||||
timestamp = timestamp * ENCODING_LEN + value;
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
export function ulidToDate(id: string): Date {
|
||||
return new Date(ulidToTimestamp(id));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.spec.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov', 'json-summary'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.spec.ts', 'src/index.ts', 'src/**/index.ts'],
|
||||
thresholds: { lines: 85, functions: 85, statements: 85, branches: 80 },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@lingxi/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./icons": {
|
||||
"types": "./dist/icons.d.ts",
|
||||
"import": "./dist/icons.js",
|
||||
"require": "./dist/icons.cjs"
|
||||
},
|
||||
"./theme/tokens.css": "./dist/theme/tokens.css",
|
||||
"./styles.css": "./dist/styles.css",
|
||||
"./semi.css": "./dist/semi.css"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"icons": [
|
||||
"dist/icons.d.ts"
|
||||
],
|
||||
"*": [
|
||||
"dist/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts src/icons.ts --format esm,cjs --dts --clean --sourcemap && node scripts/copy-assets.mjs",
|
||||
"dev": "tsup src/index.ts src/icons.ts --format esm,cjs --dts --watch",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-ui-19": "2.104.0",
|
||||
"@remixicon/react": "4.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"tsup": "8.5.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cp, mkdir } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
await mkdir(resolve(root, 'dist/theme'), { recursive: true });
|
||||
await cp(resolve(root, 'src/theme/tokens.css'), resolve(root, 'dist/theme/tokens.css'));
|
||||
await cp(resolve(root, 'src/styles/components.css'), resolve(root, 'dist/styles.css'));
|
||||
await cp(
|
||||
resolve(root, 'node_modules/@douyinfe/semi-ui-19/dist/css/semi.min.css'),
|
||||
resolve(root, 'dist/semi.css'),
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Table } from '@douyinfe/semi-ui-19';
|
||||
import type { TableProps } from '@douyinfe/semi-ui-19/lib/es/table/interface';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { PageEmpty } from './PageEmpty.js';
|
||||
|
||||
export interface DataTableProps<T extends Record<string, unknown>> {
|
||||
columns: NonNullable<TableProps<T>['columns']>;
|
||||
dataSource: T[];
|
||||
loading?: boolean;
|
||||
rowKey?: TableProps<T>['rowKey'];
|
||||
pagination?: TableProps<T>['pagination'];
|
||||
empty?: ReactNode;
|
||||
size?: TableProps<T>['size'];
|
||||
scroll?: TableProps<T>['scroll'];
|
||||
className?: string;
|
||||
onRow?: TableProps<T>['onRow'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表表格统一封装:加载态 + 空态 + 行 key 约定。
|
||||
* 业务页只负责列定义与数据,不重复写 Empty / loading。
|
||||
*/
|
||||
export function DataTable<T extends Record<string, unknown>>({
|
||||
columns,
|
||||
dataSource,
|
||||
loading = false,
|
||||
rowKey = 'id',
|
||||
pagination = false,
|
||||
empty,
|
||||
size = 'middle',
|
||||
scroll,
|
||||
className,
|
||||
onRow,
|
||||
}: DataTableProps<T>): ReactNode {
|
||||
return (
|
||||
<Table<T>
|
||||
className={`lx-data-table${className === undefined ? '' : ` ${className}`}`}
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
loading={loading}
|
||||
rowKey={rowKey}
|
||||
pagination={pagination}
|
||||
size={size}
|
||||
scroll={scroll}
|
||||
onRow={onRow}
|
||||
empty={empty ?? <PageEmpty />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Button } from '@douyinfe/semi-ui-19';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { IconError, IconRefresh } from '../icons.js';
|
||||
|
||||
export interface ErrorStateProps {
|
||||
title?: string;
|
||||
message?: string;
|
||||
/** 服务端返回的 trace_id;500 类错误必须展示(工程规范 04) */
|
||||
traceId?: string;
|
||||
onRetry?: () => void;
|
||||
retryText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 统一错误态:说明 + trace_id + 重试 */
|
||||
export function ErrorState({
|
||||
title = '加载失败',
|
||||
message = '请稍后重试;若持续失败,请把 trace_id 提供给管理员。',
|
||||
traceId,
|
||||
onRetry,
|
||||
retryText = '重新加载',
|
||||
className,
|
||||
}: ErrorStateProps): ReactNode {
|
||||
return (
|
||||
<div className={`lx-error-state${className === undefined ? '' : ` ${className}`}`}>
|
||||
<span className="lx-error-state__icon" aria-hidden="true">
|
||||
<IconError size={32} />
|
||||
</span>
|
||||
<h3 className="lx-error-state__title">{title}</h3>
|
||||
<p className="lx-error-state__message">{message}</p>
|
||||
{traceId === undefined || traceId.length === 0 ? null : (
|
||||
<p className="lx-error-state__trace">trace_id: {traceId}</p>
|
||||
)}
|
||||
{onRetry === undefined ? null : (
|
||||
<Button icon={<IconRefresh />} onClick={onRetry} className="lx-error-state__retry">
|
||||
{retryText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Modal } from '@douyinfe/semi-ui-19';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface FormModalProps {
|
||||
visible: boolean;
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
onCancel: () => void;
|
||||
onOk: () => void;
|
||||
confirmLoading?: boolean;
|
||||
okText?: string;
|
||||
cancelText?: string;
|
||||
width?: number | string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 表单弹窗统一封装:确定 / 取消 / 提交中状态 */
|
||||
export function FormModal({
|
||||
visible,
|
||||
title,
|
||||
children,
|
||||
onCancel,
|
||||
onOk,
|
||||
confirmLoading = false,
|
||||
okText = '确定',
|
||||
cancelText = '取消',
|
||||
width = 560,
|
||||
className,
|
||||
}: FormModalProps): ReactNode {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={title}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
confirmLoading={confirmLoading}
|
||||
okText={okText}
|
||||
cancelText={cancelText}
|
||||
width={width}
|
||||
className={`lx-form-modal${className === undefined ? '' : ` ${className}`}`}
|
||||
closeOnEsc
|
||||
maskClosable={false}
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Empty } from '@douyinfe/semi-ui-19';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { IconAsset } from '../icons.js';
|
||||
|
||||
export interface PageEmptyProps {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 统一空态:标题 + 说明 + 可选主操作 */
|
||||
export function PageEmpty({
|
||||
title = '暂无数据',
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: PageEmptyProps): ReactNode {
|
||||
return (
|
||||
<div className={`lx-empty${className === undefined ? '' : ` ${className}`}`}>
|
||||
<Empty image={<IconAsset size={40} />} title={title} description={description}>
|
||||
{action === undefined ? null : <div className="lx-empty__action">{action}</div>}
|
||||
</Empty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Skeleton } from '@douyinfe/semi-ui-19';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface PageSkeletonProps {
|
||||
loading?: boolean;
|
||||
rows?: number;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 统一加载骨架:页面级加载用段落占位,避免布局跳动 */
|
||||
export function PageSkeleton({
|
||||
loading = true,
|
||||
rows = 6,
|
||||
children,
|
||||
className,
|
||||
}: PageSkeletonProps): ReactNode {
|
||||
return (
|
||||
<Skeleton
|
||||
loading={loading}
|
||||
placeholder={<Skeleton.Paragraph rows={rows} />}
|
||||
className={`lx-page-skeleton${className === undefined ? '' : ` ${className}`}`}
|
||||
>
|
||||
{children}
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Button, Form, Space } from '@douyinfe/semi-ui-19';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { IconRefresh, IconSearch } from '../icons.js';
|
||||
|
||||
export interface QueryFormProps {
|
||||
children: ReactNode;
|
||||
/** 查询动作;由页面自行发起请求 */
|
||||
onSubmit: () => void;
|
||||
onReset?: () => void;
|
||||
loading?: boolean;
|
||||
/** 额外的右侧操作(如「新建」) */
|
||||
extra?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 列表页查询表单:字段一行内排列,查询 / 重置固位;筛选行不超过两行(设计约束) */
|
||||
export function QueryForm({
|
||||
children,
|
||||
onSubmit,
|
||||
onReset,
|
||||
loading = false,
|
||||
extra,
|
||||
className,
|
||||
}: QueryFormProps): ReactNode {
|
||||
return (
|
||||
<Form
|
||||
className={`lx-query-form${className === undefined ? '' : ` ${className}`}`}
|
||||
layout="horizontal"
|
||||
onSubmit={() => {
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="lx-query-form__fields">{children}</div>
|
||||
<Space className="lx-query-form__actions" spacing={8}>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconSearch />}
|
||||
loading={loading}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
icon={<IconRefresh />}
|
||||
onClick={() => {
|
||||
onReset?.();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
{extra}
|
||||
</Space>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { DataTable } from './DataTable.js';
|
||||
export { ErrorState } from './ErrorState.js';
|
||||
export { FormModal } from './FormModal.js';
|
||||
export { PageEmpty } from './PageEmpty.js';
|
||||
export { PageSkeleton } from './PageSkeleton.js';
|
||||
export { QueryForm } from './QueryForm.js';
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Remix Icon 统一出口(AGENTS §3.3:业务代码只能从这里按语义名导入)
|
||||
* 约定:对外只暴露 Icon* 语义名;新增图标时在这里做一次映射,不要在业务代码里直接引图标库。
|
||||
*/
|
||||
export {
|
||||
RiAddLine as IconAdd,
|
||||
RiAlertLine as IconAlert,
|
||||
RiAppsLine as IconApps,
|
||||
RiFolderOpenLine as IconAsset,
|
||||
RiArrowLeftLine as IconBack,
|
||||
RiCalendarLine as IconCalendar,
|
||||
RiBarChartLine as IconChart,
|
||||
RiCheckLine as IconCheck,
|
||||
RiArrowDownSLine as IconChevronDown,
|
||||
RiArrowRightSLine as IconChevronRight,
|
||||
RiTimeLine as IconClock,
|
||||
RiCloseLine as IconClose,
|
||||
RiFileCopyLine as IconCopy,
|
||||
RiDashboardLine as IconDashboard,
|
||||
RiDeleteBinLine as IconDelete,
|
||||
RiSendPlaneLine as IconDelivery,
|
||||
RiBookOpenLine as IconDocs,
|
||||
RiDownloadLine as IconDownload,
|
||||
RiDragMoveLine as IconDrag,
|
||||
RiEditLine as IconEdit,
|
||||
RiErrorWarningLine as IconError,
|
||||
RiExportLine as IconExport,
|
||||
RiFilter3Line as IconFilter,
|
||||
RiGlobalLine as IconGlobal,
|
||||
RiLayoutGridLine as IconGrid,
|
||||
RiHome5Line as IconHome,
|
||||
RiImageAddLine as IconImageAdd,
|
||||
RiLoader4Line as IconLoading,
|
||||
RiLockLine as IconLock,
|
||||
RiLogoutBoxRLine as IconLogout,
|
||||
RiMailLine as IconMail,
|
||||
RiImageLine as IconMaterial,
|
||||
RiMenuLine as IconMenu,
|
||||
RiMessage3Line as IconMessage,
|
||||
RiMoreLine as IconMore,
|
||||
RiMusicLine as IconMusic,
|
||||
RiNotification3Line as IconNotification,
|
||||
RiPaletteLine as IconPalette,
|
||||
RiPauseLine as IconPause,
|
||||
RiPlayLine as IconPlay,
|
||||
RiRefreshLine as IconRefresh,
|
||||
RiRobotLine as IconRobot,
|
||||
RiSaveLine as IconSave,
|
||||
RiFileTextLine as IconScript,
|
||||
RiSearchLine as IconSearch,
|
||||
RiServerLine as IconServer,
|
||||
RiSettings3Line as IconSettings,
|
||||
RiShareLine as IconShare,
|
||||
RiShieldKeyholeLine as IconShield,
|
||||
RiStackLine as IconStack,
|
||||
RiStarLine as IconStar,
|
||||
RiTaskLine as IconTask,
|
||||
RiThumbUpLine as IconThumbUp,
|
||||
RiUploadLine as IconUpload,
|
||||
RiUserLine as IconUser,
|
||||
RiVideoLine as IconVideo,
|
||||
RiEyeLine as IconView,
|
||||
} from '@remixicon/react';
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './components/index.js';
|
||||
export * from './locale.js';
|
||||
export * from './notify.js';
|
||||
export * from './theme/tokens.js';
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Locale } from '@douyinfe/semi-ui-19/lib/es/locale/interface';
|
||||
import zhCN from '@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN';
|
||||
|
||||
/** 全站默认中文语言包(Semi LocaleProvider) */
|
||||
export const localeZhCN: Locale = zhCN;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Toast } from '@douyinfe/semi-ui-19';
|
||||
|
||||
/** 统一成功提示 */
|
||||
export function notifySuccess(content: string): void {
|
||||
Toast.success({ content, duration: 2 });
|
||||
}
|
||||
|
||||
/** 统一错误提示;带 trace_id 时一并展示,便于用户报障(工程规范 04) */
|
||||
export function notifyError(message: string, traceId?: string): void {
|
||||
Toast.error({
|
||||
content:
|
||||
traceId === undefined || traceId.length === 0 ? message : `${message}(trace: ${traceId})`,
|
||||
duration: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export function notifyWarning(content: string): void {
|
||||
Toast.warning({ content, duration: 3 });
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/* 灵犀 UI 包组件样式:只使用 tokens.css 中的语义变量,不写颜色字面量 */
|
||||
.lx-empty {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lx-empty__action {
|
||||
margin-top: var(--lx-space-4);
|
||||
}
|
||||
|
||||
.lx-error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--lx-space-2);
|
||||
padding: 64px var(--lx-space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lx-error-state__icon {
|
||||
color: var(--lx-color-danger);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.lx-error-state__title {
|
||||
margin: var(--lx-space-2) 0 0;
|
||||
color: var(--lx-color-text-primary);
|
||||
font-size: var(--lx-font-size-lg);
|
||||
line-height: var(--lx-line-height-tight);
|
||||
}
|
||||
|
||||
.lx-error-state__message {
|
||||
margin: 0;
|
||||
max-width: 42ch;
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-size: var(--lx-font-size-sm);
|
||||
}
|
||||
|
||||
.lx-error-state__trace {
|
||||
margin: 0;
|
||||
color: var(--lx-color-text-tertiary);
|
||||
font-family: var(--lx-font-mono);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
}
|
||||
|
||||
.lx-error-state__retry {
|
||||
margin-top: var(--lx-space-3);
|
||||
}
|
||||
|
||||
.lx-page-skeleton {
|
||||
padding: var(--lx-space-4) 0;
|
||||
}
|
||||
|
||||
.lx-query-form {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--lx-space-4);
|
||||
margin-bottom: var(--lx-space-4);
|
||||
}
|
||||
|
||||
.lx-query-form__fields {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lx-space-2) var(--lx-space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lx-query-form__actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.lx-data-table .semi-table-container {
|
||||
border: 1px solid var(--lx-color-border);
|
||||
border-radius: var(--lx-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lx-data-table .semi-table-thead > .semi-table-row > .semi-table-row-head {
|
||||
background: var(--lx-color-bg-panel);
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lx-data-table .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
|
||||
border-bottom: 1px solid var(--lx-color-border);
|
||||
}
|
||||
|
||||
.lx-data-table .semi-table-tbody > .semi-table-row:hover > .semi-table-row-cell {
|
||||
background: var(--lx-color-bg-hover);
|
||||
}
|
||||
|
||||
.lx-form-modal .semi-modal-content {
|
||||
background: var(--lx-color-bg-elevated);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
:root {
|
||||
/* 方向 A · 云白蓝(Cloud Blue):传统企业后台骨架 + 现代浅色质感 */
|
||||
--lx-color-bg-app: #f5f7fa;
|
||||
--lx-color-bg-panel: #ffffff;
|
||||
--lx-color-bg-elevated: #ffffff;
|
||||
--lx-color-bg-subtle: #fafbfc;
|
||||
--lx-color-bg-hover: #f2f4f7;
|
||||
--lx-color-bg-active: #e8f1ff;
|
||||
--lx-color-border: #e5e8ef;
|
||||
--lx-color-border-strong: #d0d5dd;
|
||||
|
||||
--lx-color-text-primary: #1f2329;
|
||||
--lx-color-text-secondary: #646a73;
|
||||
--lx-color-text-tertiary: #8f959e;
|
||||
--lx-color-text-inverse: #ffffff;
|
||||
|
||||
--lx-color-brand: #1677ff;
|
||||
--lx-color-brand-hover: #4096ff;
|
||||
--lx-color-brand-pressed: #0958d9;
|
||||
--lx-color-brand-soft: #e8f1ff;
|
||||
--lx-color-on-brand: #ffffff;
|
||||
--lx-color-focus: #1677ff;
|
||||
|
||||
--lx-color-success: #00b42a;
|
||||
--lx-color-warning: #ff7d00;
|
||||
--lx-color-danger: #f53f3f;
|
||||
--lx-color-info: #1677ff;
|
||||
|
||||
--lx-color-chart-1: #1677ff;
|
||||
--lx-color-chart-2: #00b42a;
|
||||
--lx-color-chart-3: #ff7d00;
|
||||
--lx-color-chart-4: #722ed1;
|
||||
--lx-color-chart-5: #eb2f96;
|
||||
|
||||
/* 字体 */
|
||||
--lx-font-sans:
|
||||
'PingFang SC', 'HarmonyOS Sans SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', sans-serif;
|
||||
--lx-font-mono: 'SF Mono', 'JetBrains Mono', 'Roboto Mono', ui-monospace, monospace;
|
||||
|
||||
--lx-font-size-xs: 12px;
|
||||
--lx-font-size-sm: 13px;
|
||||
--lx-font-size-md: 14px;
|
||||
--lx-font-size-lg: 16px;
|
||||
--lx-font-size-xl: 20px;
|
||||
--lx-font-size-display: 28px;
|
||||
|
||||
--lx-line-height-tight: 1.25;
|
||||
--lx-line-height-normal: 1.55;
|
||||
|
||||
/* 间距(4px 基线) */
|
||||
--lx-space-1: 4px;
|
||||
--lx-space-2: 8px;
|
||||
--lx-space-3: 12px;
|
||||
--lx-space-4: 16px;
|
||||
--lx-space-5: 20px;
|
||||
--lx-space-6: 24px;
|
||||
--lx-space-8: 32px;
|
||||
--lx-space-10: 40px;
|
||||
|
||||
/* 结构尺寸:顶部模块菜单 + 左侧功能菜单 */
|
||||
--lx-topbar-height: 56px;
|
||||
--lx-sidebar-width: 216px;
|
||||
--lx-sidebar-collapsed-width: 56px;
|
||||
--lx-content-padding: 24px;
|
||||
|
||||
--lx-radius-sm: 4px;
|
||||
--lx-radius-md: 6px;
|
||||
--lx-radius-lg: 10px;
|
||||
--lx-radius-pill: 999px;
|
||||
|
||||
--lx-shadow-panel: 0 1px 2px rgba(31, 35, 41, 0.04);
|
||||
--lx-shadow-overlay: 0 12px 32px rgba(31, 35, 41, 0.14);
|
||||
--lx-shadow-focus: 0 0 0 3px rgba(22, 119, 255, 0.16);
|
||||
|
||||
--lx-motion-fast: 120ms;
|
||||
--lx-motion-base: 180ms;
|
||||
--lx-ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Semi Design 变量桥接:业务侧只使用 --lx-*,Semi 组件跟随同一套 token */
|
||||
body[theme-mode='light'] {
|
||||
--semi-color-primary: var(--lx-color-brand);
|
||||
--semi-color-primary-hover: var(--lx-color-brand-hover);
|
||||
--semi-color-primary-active: var(--lx-color-brand-pressed);
|
||||
--semi-color-primary-disabled: var(--lx-color-border-strong);
|
||||
--semi-color-primary-light-default: var(--lx-color-brand-soft);
|
||||
--semi-color-primary-light-hover: #dbe9ff;
|
||||
--semi-color-primary-light-active: #cfe1ff;
|
||||
|
||||
--semi-color-bg-0: var(--lx-color-bg-app);
|
||||
--semi-color-bg-1: var(--lx-color-bg-panel);
|
||||
--semi-color-bg-2: var(--lx-color-bg-panel);
|
||||
--semi-color-bg-3: var(--lx-color-bg-panel);
|
||||
--semi-color-bg-4: var(--lx-color-bg-hover);
|
||||
|
||||
--semi-color-text-0: var(--lx-color-text-primary);
|
||||
--semi-color-text-1: var(--lx-color-text-primary);
|
||||
--semi-color-text-2: var(--lx-color-text-secondary);
|
||||
--semi-color-text-3: var(--lx-color-text-tertiary);
|
||||
|
||||
--semi-color-border: var(--lx-color-border);
|
||||
--semi-color-fill-0: var(--lx-color-bg-hover);
|
||||
--semi-color-fill-1: #eef1f5;
|
||||
--semi-color-fill-2: var(--lx-color-bg-active);
|
||||
|
||||
--semi-color-danger: var(--lx-color-danger);
|
||||
--semi-color-success: var(--lx-color-success);
|
||||
--semi-color-warning: var(--lx-color-warning);
|
||||
--semi-color-info: var(--lx-color-info);
|
||||
|
||||
--semi-color-focus-border: var(--lx-color-focus);
|
||||
--semi-color-shadow: var(--lx-shadow-overlay);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 主题 token 的 JS 副本(供 JS 计算场景使用,如 ECharts / Canvas)
|
||||
* 注意:颜色字面量只允许出现在 packages/ui/src/theme/(工程规范 03 §3.2)。
|
||||
*/
|
||||
export const themeTokens = {
|
||||
colorBrand: '#f5a524',
|
||||
colorBrandHover: '#ffb84d',
|
||||
colorSuccess: '#3fb950',
|
||||
colorWarning: '#d29922',
|
||||
colorDanger: '#f85149',
|
||||
colorInfo: '#58a6ff',
|
||||
chartColors: ['#f5a524', '#58a6ff', '#3fb950', '#d29922', '#a371f7'],
|
||||
} as const;
|
||||
|
||||
export type ThemeTokens = typeof themeTokens;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in new issue
Block a user