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

+27
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
/**
* @lingxi/ai-sdk 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。
* 本包在 A0 阶段仅用于固定仓库结构与依赖方向。
*/
export const PACKAGE_NAME = '@lingxi/ai-sdk';
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": []
},
"include": ["src"]
}
+27
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
/**
* @lingxi/form-engine 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。
* 本包在 A0 阶段仅用于固定仓库结构与依赖方向。
*/
export const PACKAGE_NAME = '@lingxi/form-engine';
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": []
},
"include": ["src"]
}
+27
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
/**
* @lingxi/render 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。
* 本包在 A0 阶段仅用于固定仓库结构与依赖方向。
*/
export const PACKAGE_NAME = '@lingxi/render';
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": []
},
"include": ["src"]
}
+34
View File
@@ -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"
}
}
+16
View File
@@ -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('');
});
});
+32
View File
@@ -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,
};
}
}
+54
View File
@@ -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);
});
});
+106
View File
@@ -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;
}
+9
View File
@@ -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';
+47
View File
@@ -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);
});
});
+107
View File
@@ -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}」生成序号`);
}
+42
View File
@@ -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();
});
});
+78
View File
@@ -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 };
}
+53
View File
@@ -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');
});
});
+39
View File
@@ -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();
});
});
+26
View File
@@ -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;
+31
View File
@@ -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);
});
});
+23
View File
@@ -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));
}
+36
View File
@@ -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);
});
});
+56
View File
@@ -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));
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true,
"types": []
},
"include": ["src", "vitest.config.ts"]
}
+15
View File
@@ -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 },
},
},
});
+58
View File
@@ -0,0 +1,58 @@
{
"name": "@lingxi/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./icons": {
"types": "./dist/icons.d.ts",
"import": "./dist/icons.js",
"require": "./dist/icons.cjs"
},
"./theme/tokens.css": "./dist/theme/tokens.css",
"./styles.css": "./dist/styles.css",
"./semi.css": "./dist/semi.css"
},
"typesVersions": {
"*": {
"icons": [
"dist/icons.d.ts"
],
"*": [
"dist/index.d.ts"
]
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.ts src/icons.ts --format esm,cjs --dts --clean --sourcemap && node scripts/copy-assets.mjs",
"dev": "tsup src/index.ts src/icons.ts --format esm,cjs --dts --watch",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"dependencies": {
"@douyinfe/semi-ui-19": "2.104.0",
"@remixicon/react": "4.9.0"
},
"devDependencies": {
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"tsup": "8.5.1",
"typescript": "5.9.3"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { cp, mkdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
await mkdir(resolve(root, 'dist/theme'), { recursive: true });
await cp(resolve(root, 'src/theme/tokens.css'), resolve(root, 'dist/theme/tokens.css'));
await cp(resolve(root, 'src/styles/components.css'), resolve(root, 'dist/styles.css'));
await cp(
resolve(root, 'node_modules/@douyinfe/semi-ui-19/dist/css/semi.min.css'),
resolve(root, 'dist/semi.css'),
);
+50
View File
@@ -0,0 +1,50 @@
import { Table } from '@douyinfe/semi-ui-19';
import type { TableProps } from '@douyinfe/semi-ui-19/lib/es/table/interface';
import type { ReactNode } from 'react';
import { PageEmpty } from './PageEmpty.js';
export interface DataTableProps<T extends Record<string, unknown>> {
columns: NonNullable<TableProps<T>['columns']>;
dataSource: T[];
loading?: boolean;
rowKey?: TableProps<T>['rowKey'];
pagination?: TableProps<T>['pagination'];
empty?: ReactNode;
size?: TableProps<T>['size'];
scroll?: TableProps<T>['scroll'];
className?: string;
onRow?: TableProps<T>['onRow'];
}
/**
* 列表表格统一封装:加载态 + 空态 + 行 key 约定。
* 业务页只负责列定义与数据,不重复写 Empty / loading。
*/
export function DataTable<T extends Record<string, unknown>>({
columns,
dataSource,
loading = false,
rowKey = 'id',
pagination = false,
empty,
size = 'middle',
scroll,
className,
onRow,
}: DataTableProps<T>): ReactNode {
return (
<Table<T>
className={`lx-data-table${className === undefined ? '' : ` ${className}`}`}
columns={columns}
dataSource={dataSource}
loading={loading}
rowKey={rowKey}
pagination={pagination}
size={size}
scroll={scroll}
onRow={onRow}
empty={empty ?? <PageEmpty />}
/>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { Button } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { IconError, IconRefresh } from '../icons.js';
export interface ErrorStateProps {
title?: string;
message?: string;
/** 服务端返回的 trace_id;500 类错误必须展示(工程规范 04) */
traceId?: string;
onRetry?: () => void;
retryText?: string;
className?: string;
}
/** 统一错误态:说明 + trace_id + 重试 */
export function ErrorState({
title = '加载失败',
message = '请稍后重试;若持续失败,请把 trace_id 提供给管理员。',
traceId,
onRetry,
retryText = '重新加载',
className,
}: ErrorStateProps): ReactNode {
return (
<div className={`lx-error-state${className === undefined ? '' : ` ${className}`}`}>
<span className="lx-error-state__icon" aria-hidden="true">
<IconError size={32} />
</span>
<h3 className="lx-error-state__title">{title}</h3>
<p className="lx-error-state__message">{message}</p>
{traceId === undefined || traceId.length === 0 ? null : (
<p className="lx-error-state__trace">trace_id: {traceId}</p>
)}
{onRetry === undefined ? null : (
<Button icon={<IconRefresh />} onClick={onRetry} className="lx-error-state__retry">
{retryText}
</Button>
)}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { Modal } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
export interface FormModalProps {
visible: boolean;
title: ReactNode;
children: ReactNode;
onCancel: () => void;
onOk: () => void;
confirmLoading?: boolean;
okText?: string;
cancelText?: string;
width?: number | string;
className?: string;
}
/** 表单弹窗统一封装:确定 / 取消 / 提交中状态 */
export function FormModal({
visible,
title,
children,
onCancel,
onOk,
confirmLoading = false,
okText = '确定',
cancelText = '取消',
width = 560,
className,
}: FormModalProps): ReactNode {
return (
<Modal
visible={visible}
title={title}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText={okText}
cancelText={cancelText}
width={width}
className={`lx-form-modal${className === undefined ? '' : ` ${className}`}`}
closeOnEsc
maskClosable={false}
>
{children}
</Modal>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Empty } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { IconAsset } from '../icons.js';
export interface PageEmptyProps {
title?: ReactNode;
description?: ReactNode;
action?: ReactNode;
className?: string;
}
/** 统一空态:标题 + 说明 + 可选主操作 */
export function PageEmpty({
title = '暂无数据',
description,
action,
className,
}: PageEmptyProps): ReactNode {
return (
<div className={`lx-empty${className === undefined ? '' : ` ${className}`}`}>
<Empty image={<IconAsset size={40} />} title={title} description={description}>
{action === undefined ? null : <div className="lx-empty__action">{action}</div>}
</Empty>
</div>
);
}
@@ -0,0 +1,27 @@
import { Skeleton } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
export interface PageSkeletonProps {
loading?: boolean;
rows?: number;
children?: ReactNode;
className?: string;
}
/** 统一加载骨架:页面级加载用段落占位,避免布局跳动 */
export function PageSkeleton({
loading = true,
rows = 6,
children,
className,
}: PageSkeletonProps): ReactNode {
return (
<Skeleton
loading={loading}
placeholder={<Skeleton.Paragraph rows={rows} />}
className={`lx-page-skeleton${className === undefined ? '' : ` ${className}`}`}
>
{children}
</Skeleton>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { Button, Form, Space } from '@douyinfe/semi-ui-19';
import type { ReactNode } from 'react';
import { IconRefresh, IconSearch } from '../icons.js';
export interface QueryFormProps {
children: ReactNode;
/** 查询动作;由页面自行发起请求 */
onSubmit: () => void;
onReset?: () => void;
loading?: boolean;
/** 额外的右侧操作(如「新建」) */
extra?: ReactNode;
className?: string;
}
/** 列表页查询表单:字段一行内排列,查询 / 重置固位;筛选行不超过两行(设计约束) */
export function QueryForm({
children,
onSubmit,
onReset,
loading = false,
extra,
className,
}: QueryFormProps): ReactNode {
return (
<Form
className={`lx-query-form${className === undefined ? '' : ` ${className}`}`}
layout="horizontal"
onSubmit={() => {
onSubmit();
}}
>
<div className="lx-query-form__fields">{children}</div>
<Space className="lx-query-form__actions" spacing={8}>
<Button
htmlType="submit"
theme="solid"
type="primary"
icon={<IconSearch />}
loading={loading}
>
查询
</Button>
<Button
icon={<IconRefresh />}
onClick={() => {
onReset?.();
}}
>
重置
</Button>
{extra}
</Space>
</Form>
);
}
+6
View File
@@ -0,0 +1,6 @@
export { DataTable } from './DataTable.js';
export { ErrorState } from './ErrorState.js';
export { FormModal } from './FormModal.js';
export { PageEmpty } from './PageEmpty.js';
export { PageSkeleton } from './PageSkeleton.js';
export { QueryForm } from './QueryForm.js';
+63
View File
@@ -0,0 +1,63 @@
/**
* Remix Icon 统一出口(AGENTS §3.3:业务代码只能从这里按语义名导入)
* 约定:对外只暴露 Icon* 语义名;新增图标时在这里做一次映射,不要在业务代码里直接引图标库。
*/
export {
RiAddLine as IconAdd,
RiAlertLine as IconAlert,
RiAppsLine as IconApps,
RiFolderOpenLine as IconAsset,
RiArrowLeftLine as IconBack,
RiCalendarLine as IconCalendar,
RiBarChartLine as IconChart,
RiCheckLine as IconCheck,
RiArrowDownSLine as IconChevronDown,
RiArrowRightSLine as IconChevronRight,
RiTimeLine as IconClock,
RiCloseLine as IconClose,
RiFileCopyLine as IconCopy,
RiDashboardLine as IconDashboard,
RiDeleteBinLine as IconDelete,
RiSendPlaneLine as IconDelivery,
RiBookOpenLine as IconDocs,
RiDownloadLine as IconDownload,
RiDragMoveLine as IconDrag,
RiEditLine as IconEdit,
RiErrorWarningLine as IconError,
RiExportLine as IconExport,
RiFilter3Line as IconFilter,
RiGlobalLine as IconGlobal,
RiLayoutGridLine as IconGrid,
RiHome5Line as IconHome,
RiImageAddLine as IconImageAdd,
RiLoader4Line as IconLoading,
RiLockLine as IconLock,
RiLogoutBoxRLine as IconLogout,
RiMailLine as IconMail,
RiImageLine as IconMaterial,
RiMenuLine as IconMenu,
RiMessage3Line as IconMessage,
RiMoreLine as IconMore,
RiMusicLine as IconMusic,
RiNotification3Line as IconNotification,
RiPaletteLine as IconPalette,
RiPauseLine as IconPause,
RiPlayLine as IconPlay,
RiRefreshLine as IconRefresh,
RiRobotLine as IconRobot,
RiSaveLine as IconSave,
RiFileTextLine as IconScript,
RiSearchLine as IconSearch,
RiServerLine as IconServer,
RiSettings3Line as IconSettings,
RiShareLine as IconShare,
RiShieldKeyholeLine as IconShield,
RiStackLine as IconStack,
RiStarLine as IconStar,
RiTaskLine as IconTask,
RiThumbUpLine as IconThumbUp,
RiUploadLine as IconUpload,
RiUserLine as IconUser,
RiVideoLine as IconVideo,
RiEyeLine as IconView,
} from '@remixicon/react';
+4
View File
@@ -0,0 +1,4 @@
export * from './components/index.js';
export * from './locale.js';
export * from './notify.js';
export * from './theme/tokens.js';
+5
View File
@@ -0,0 +1,5 @@
import type { Locale } from '@douyinfe/semi-ui-19/lib/es/locale/interface';
import zhCN from '@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN';
/** 全站默认中文语言包(Semi LocaleProvider) */
export const localeZhCN: Locale = zhCN;
+19
View File
@@ -0,0 +1,19 @@
import { Toast } from '@douyinfe/semi-ui-19';
/** 统一成功提示 */
export function notifySuccess(content: string): void {
Toast.success({ content, duration: 2 });
}
/** 统一错误提示;带 trace_id 时一并展示,便于用户报障(工程规范 04) */
export function notifyError(message: string, traceId?: string): void {
Toast.error({
content:
traceId === undefined || traceId.length === 0 ? message : `${message}(trace: ${traceId})`,
duration: 4,
});
}
export function notifyWarning(content: string): void {
Toast.warning({ content, duration: 3 });
}
+100
View File
@@ -0,0 +1,100 @@
/* 灵犀 UI 包组件样式:只使用 tokens.css 中的语义变量,不写颜色字面量 */
.lx-empty {
padding: 48px 0;
text-align: center;
}
.lx-empty__action {
margin-top: var(--lx-space-4);
}
.lx-error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--lx-space-2);
padding: 64px var(--lx-space-6);
text-align: center;
}
.lx-error-state__icon {
color: var(--lx-color-danger);
display: inline-flex;
}
.lx-error-state__title {
margin: var(--lx-space-2) 0 0;
color: var(--lx-color-text-primary);
font-size: var(--lx-font-size-lg);
line-height: var(--lx-line-height-tight);
}
.lx-error-state__message {
margin: 0;
max-width: 42ch;
color: var(--lx-color-text-secondary);
font-size: var(--lx-font-size-sm);
}
.lx-error-state__trace {
margin: 0;
color: var(--lx-color-text-tertiary);
font-family: var(--lx-font-mono);
font-size: var(--lx-font-size-xs);
}
.lx-error-state__retry {
margin-top: var(--lx-space-3);
}
.lx-page-skeleton {
padding: var(--lx-space-4) 0;
}
.lx-query-form {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--lx-space-4);
margin-bottom: var(--lx-space-4);
}
.lx-query-form__fields {
display: flex;
flex: 1 1 auto;
flex-wrap: wrap;
gap: var(--lx-space-2) var(--lx-space-4);
min-width: 0;
}
.lx-query-form__actions {
flex: 0 0 auto;
}
.lx-data-table .semi-table-container {
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
overflow: hidden;
}
.lx-data-table .semi-table-thead > .semi-table-row > .semi-table-row-head {
background: var(--lx-color-bg-panel);
color: var(--lx-color-text-secondary);
font-size: var(--lx-font-size-xs);
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.lx-data-table .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
border-bottom: 1px solid var(--lx-color-border);
}
.lx-data-table .semi-table-tbody > .semi-table-row:hover > .semi-table-row-cell {
background: var(--lx-color-bg-hover);
}
.lx-form-modal .semi-modal-content {
background: var(--lx-color-bg-elevated);
}
+114
View File
@@ -0,0 +1,114 @@
:root {
/* 方向 A · 云白蓝(Cloud Blue):传统企业后台骨架 + 现代浅色质感 */
--lx-color-bg-app: #f5f7fa;
--lx-color-bg-panel: #ffffff;
--lx-color-bg-elevated: #ffffff;
--lx-color-bg-subtle: #fafbfc;
--lx-color-bg-hover: #f2f4f7;
--lx-color-bg-active: #e8f1ff;
--lx-color-border: #e5e8ef;
--lx-color-border-strong: #d0d5dd;
--lx-color-text-primary: #1f2329;
--lx-color-text-secondary: #646a73;
--lx-color-text-tertiary: #8f959e;
--lx-color-text-inverse: #ffffff;
--lx-color-brand: #1677ff;
--lx-color-brand-hover: #4096ff;
--lx-color-brand-pressed: #0958d9;
--lx-color-brand-soft: #e8f1ff;
--lx-color-on-brand: #ffffff;
--lx-color-focus: #1677ff;
--lx-color-success: #00b42a;
--lx-color-warning: #ff7d00;
--lx-color-danger: #f53f3f;
--lx-color-info: #1677ff;
--lx-color-chart-1: #1677ff;
--lx-color-chart-2: #00b42a;
--lx-color-chart-3: #ff7d00;
--lx-color-chart-4: #722ed1;
--lx-color-chart-5: #eb2f96;
/* 字体 */
--lx-font-sans:
'PingFang SC', 'HarmonyOS Sans SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
--lx-font-mono: 'SF Mono', 'JetBrains Mono', 'Roboto Mono', ui-monospace, monospace;
--lx-font-size-xs: 12px;
--lx-font-size-sm: 13px;
--lx-font-size-md: 14px;
--lx-font-size-lg: 16px;
--lx-font-size-xl: 20px;
--lx-font-size-display: 28px;
--lx-line-height-tight: 1.25;
--lx-line-height-normal: 1.55;
/* 间距(4px 基线) */
--lx-space-1: 4px;
--lx-space-2: 8px;
--lx-space-3: 12px;
--lx-space-4: 16px;
--lx-space-5: 20px;
--lx-space-6: 24px;
--lx-space-8: 32px;
--lx-space-10: 40px;
/* 结构尺寸:顶部模块菜单 + 左侧功能菜单 */
--lx-topbar-height: 56px;
--lx-sidebar-width: 216px;
--lx-sidebar-collapsed-width: 56px;
--lx-content-padding: 24px;
--lx-radius-sm: 4px;
--lx-radius-md: 6px;
--lx-radius-lg: 10px;
--lx-radius-pill: 999px;
--lx-shadow-panel: 0 1px 2px rgba(31, 35, 41, 0.04);
--lx-shadow-overlay: 0 12px 32px rgba(31, 35, 41, 0.14);
--lx-shadow-focus: 0 0 0 3px rgba(22, 119, 255, 0.16);
--lx-motion-fast: 120ms;
--lx-motion-base: 180ms;
--lx-ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Semi Design 变量桥接:业务侧只使用 --lx-*,Semi 组件跟随同一套 token */
body[theme-mode='light'] {
--semi-color-primary: var(--lx-color-brand);
--semi-color-primary-hover: var(--lx-color-brand-hover);
--semi-color-primary-active: var(--lx-color-brand-pressed);
--semi-color-primary-disabled: var(--lx-color-border-strong);
--semi-color-primary-light-default: var(--lx-color-brand-soft);
--semi-color-primary-light-hover: #dbe9ff;
--semi-color-primary-light-active: #cfe1ff;
--semi-color-bg-0: var(--lx-color-bg-app);
--semi-color-bg-1: var(--lx-color-bg-panel);
--semi-color-bg-2: var(--lx-color-bg-panel);
--semi-color-bg-3: var(--lx-color-bg-panel);
--semi-color-bg-4: var(--lx-color-bg-hover);
--semi-color-text-0: var(--lx-color-text-primary);
--semi-color-text-1: var(--lx-color-text-primary);
--semi-color-text-2: var(--lx-color-text-secondary);
--semi-color-text-3: var(--lx-color-text-tertiary);
--semi-color-border: var(--lx-color-border);
--semi-color-fill-0: var(--lx-color-bg-hover);
--semi-color-fill-1: #eef1f5;
--semi-color-fill-2: var(--lx-color-bg-active);
--semi-color-danger: var(--lx-color-danger);
--semi-color-success: var(--lx-color-success);
--semi-color-warning: var(--lx-color-warning);
--semi-color-info: var(--lx-color-info);
--semi-color-focus-border: var(--lx-color-focus);
--semi-color-shadow: var(--lx-shadow-overlay);
}
+15
View File
@@ -0,0 +1,15 @@
/**
* 主题 token 的 JS 副本(供 JS 计算场景使用,如 ECharts / Canvas)
* 注意:颜色字面量只允许出现在 packages/ui/src/theme/(工程规范 03 §3.2)。
*/
export const themeTokens = {
colorBrand: '#f5a524',
colorBrandHover: '#ffb84d',
colorSuccess: '#3fb950',
colorWarning: '#d29922',
colorDanger: '#f85149',
colorInfo: '#58a6ff',
chartColors: ['#f5a524', '#58a6ff', '#3fb950', '#d29922', '#a371f7'],
} as const;
export type ThemeTokens = typeof themeTokens;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true,
"types": []
},
"include": ["src"]
}