feat(web): A4 数据字典与系统配置页

背景:用户选定方案 A 主从分栏,A4 需要落地字典与配置页。\n改动:新增 /system/dicts、/system/dicts/:type、/system/settings;主从类型列表 + 三层合并 + 树/词表切换 + 条目/类型弹层;6 组配置 + 命名预览 + 阈值二次确认 + 变更记录;客户字段上限改读 effective config;路由/样式/OpenAPI 类型同步。\n影响面:管理中心新增两个菜单页;客户页上限显示改为配置读取(fallback 共享常量)。\n回滚:回滚本提交并恢复 AppLayout / routes / main 样式 import。\n验证:web typecheck/lint/build;单测 154;浏览器自检 1440/1024 无溢出。
This commit is contained in:
Stev_Wang committed 2026-11-04 11:49:32 +08:00
1 parent e4330a43e4
commit 3b0a99d91f
36 files changed
+7691 -15

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
import type { SystemConfig, SystemConfigChange } from '@lingxi/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { setUnauthorizedHandler } from './client';
import {
fetchEffectiveSystemConfigs,
fetchSystemConfigHistory,
fetchSystemConfigs,
updateSystemConfigs,
} from './configs';
const NOW = '2026-11-04T10:00:00.000Z';
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(status === 204 ? null : JSON.stringify(body), {
status,
headers: status === 204 ? undefined : { 'Content-Type': 'application/json' },
});
const config: SystemConfig = {
key: 'limit.customer.base_field',
group: 'limits',
label: '客户基础字段上限',
description: '每客户基础字段的最大数量',
impact: '前后端共用;修改后新申请按新上限校验',
type: 'number',
value: 10,
valueType: 'number',
unit: '个',
version: 3,
updatedAt: NOW,
updatedBy: '江岚',
editable: true,
isPublic: true,
min: 1,
max: 50,
step: null,
maxLength: null,
};
const change: SystemConfigChange = {
key: 'limit.customer.base_field',
label: '客户基础字段上限',
version: 3,
oldValue: 8,
newValue: 10,
reason: '在系统配置页调整',
operator: '江岚',
changedAt: NOW,
result: '已生效',
};
function lastCall(): { url: string; init: RequestInit | undefined } {
const fetchMock = vi.mocked(globalThis.fetch);
const call = fetchMock.mock.calls.at(-1);
const input = call?.[0];
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : (input?.url ?? '');
return { url, init: call?.[1] };
}
function lastBody(): unknown {
const body = lastCall().init?.body;
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
}
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
useAuthStore.getState().clearSession();
setUnauthorizedHandler(null);
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: ID, name: '江岚', account: 'jiang.lan' },
permissions: ['system:configure'],
});
});
describe('系统配置接口封装', () => {
it('全量配置 / 生效配置', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [config] }))
.mockResolvedValueOnce(jsonResponse({ items: [{ key: config.key, value: 10 }] }));
const list = await fetchSystemConfigs();
expect(list.items).toHaveLength(1);
expect(lastCall().url).toContain('/system/configs');
const effective = await fetchEffectiveSystemConfigs();
expect(effective.items[0]?.value).toBe(10);
expect(lastCall().url).toContain('/system/configs/effective');
});
it('批量保存:PUT changes 带 expectedVersion(乐观锁)', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse({ items: [config] }));
await updateSystemConfigs({
changes: [{ key: 'limit.customer.base_field', value: 12, expectedVersion: 3 }],
});
expect(lastCall().init?.method).toBe('PUT');
expect(lastBody()).toEqual({
changes: [{ key: 'limit.customer.base_field', value: 12, expectedVersion: 3 }],
});
});
it('变更记录:query 用 snake_case(config_key / limit)', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
jsonResponse({ items: [change], next_cursor: null }),
);
const history = await fetchSystemConfigHistory({
config_key: 'limit.customer.base_field',
limit: 50,
});
expect(history.items[0]?.version).toBe(3);
expect(lastCall().url).toContain('/system/configs/history?');
expect(lastCall().url).toContain('config_key=');
expect(lastCall().url).toContain('limit=50');
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* A4 系统配置接口(唯一出处)。
* 依据:packages/shared/src/schemas/config.ts、PRD R-26、技术架构 §5.7。
*
* `system_configs` 是阈值 / 上限唯一来源:页面保存走 PUT changes + expectedVersion 乐观锁;
* 前端上限读取走 /system/configs/effective(仅登录,只读)。响应 / 入参全部 shared Zod parse。
*/
import {
type EffectiveSystemConfig,
effectiveSystemConfigsResponseSchema,
type SystemConfig,
type SystemConfigChange,
type SystemConfigHistoryQuery,
systemConfigHistoryQuerySchema,
systemConfigHistoryResponseSchema,
systemConfigListResponseSchema,
type UpdateSystemConfigsInput,
updateSystemConfigsInputSchema,
} from '@lingxi/shared';
import { request } from './client';
import { ENDPOINTS } from './endpoints';
function searchParams(entries: Record<string, string | number | undefined>): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(entries)) {
if (value !== undefined && value !== '') params.set(key, String(value));
}
const search = params.toString();
return search === '' ? '' : `?${search}`;
}
/** 全量配置(含非公开项,需 system:configure) */
export async function fetchSystemConfigs(): Promise<{ items: SystemConfig[] }> {
const payload: unknown = await request(ENDPOINTS.systemConfigs);
return systemConfigListResponseSchema.parse(payload);
}
/** 生效配置(仅登录;前端上限读取,无需 system:configure) */
export async function fetchEffectiveSystemConfigs(): Promise<{
items: EffectiveSystemConfig[];
}> {
const payload: unknown = await request(ENDPOINTS.systemConfigsEffective);
return effectiveSystemConfigsResponseSchema.parse(payload);
}
/** 批量保存:changes 带 expectedVersion(不一致返回 40901) */
export async function updateSystemConfigs(
input: UpdateSystemConfigsInput,
): Promise<{ items: SystemConfig[] }> {
const body = updateSystemConfigsInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.systemConfigs, { method: 'PUT', body });
return systemConfigListResponseSchema.parse(payload);
}
/** 变更记录(含前后值、操作人、时间、原因与结果) */
export async function fetchSystemConfigHistory(
query: Partial<SystemConfigHistoryQuery> = {},
): Promise<{ items: SystemConfigChange[]; next_cursor: string | null }> {
const parsed = systemConfigHistoryQuerySchema.parse(query);
const payload: unknown = await request(
`${ENDPOINTS.systemConfigsHistory}${searchParams({
config_key: parsed.config_key,
limit: parsed.limit,
})}`,
);
return systemConfigHistoryResponseSchema.parse(payload);
}
+209
View File
@@ -0,0 +1,209 @@
import type {
DictItem,
DictItemListResponse,
DictTypeDetail,
DictTypeSummary,
} from '@lingxi/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { setUnauthorizedHandler } from './client';
import {
createDictItem,
createDictType,
deleteDictType,
fetchDictItemList,
fetchDictTypeDetail,
fetchDictTypeList,
setDictTypeStatus,
updateDictItem,
updateDictType,
} from './dicts';
const NOW = '2026-11-04T10:00:00.000Z';
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(status === 204 ? null : JSON.stringify(body), {
status,
headers: status === 204 ? undefined : { 'Content-Type': 'application/json' },
});
const typeSummary: DictTypeSummary = {
code: 'script_direction',
name: '脚本方向',
group: 'content',
kind: 'list',
description: '脚本“说什么”的方向',
industryScoped: true,
builtin: true,
status: 'active',
sortOrder: 60,
itemCount: 11,
activeItemCount: 11,
industryCount: 4,
updatedAt: NOW,
updatedBy: '江岚',
};
const item: DictItem = {
id: ID,
typeCode: 'script_direction',
code: 'oral',
name: '口播',
parentCode: null,
parentName: null,
industryId: null,
industryPath: null,
sortOrder: 10,
status: 'active',
remark: '',
source: 'generic',
overridden: false,
refCount: 15,
updatedAt: NOW,
updatedBy: '江岚',
};
const typeDetail: DictTypeDetail = { ...typeSummary, items: [item] };
const itemList: DictItemListResponse = {
items: [item],
next_cursor: null,
layers: [
{ source: 'generic', industryId: null, label: '通用', count: 5 },
{ source: 'parent', industryId: ID, label: '父行业', count: 2 },
{ source: 'current', industryId: ID, label: '本行业', count: 1 },
],
effectiveCount: 7,
overriddenCount: 1,
disabledCount: 0,
};
function lastCall(): { url: string; init: RequestInit | undefined } {
const fetchMock = vi.mocked(globalThis.fetch);
const call = fetchMock.mock.calls.at(-1);
const input = call?.[0];
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : (input?.url ?? '');
return { url, init: call?.[1] };
}
function lastBody(): unknown {
const body = lastCall().init?.body;
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
}
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
useAuthStore.getState().clearSession();
setUnauthorizedHandler(null);
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: ID, name: '江岚', account: 'jiang.lan' },
permissions: ['dict:manage'],
});
});
describe('数据字典接口封装', () => {
it('类型列表:query 用 snake_case(keyword / group / status / limit / cursor)', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
jsonResponse({ items: [typeSummary], next_cursor: 'cursor-2' }),
);
const result = await fetchDictTypeList({
keyword: '脚本',
group: 'content',
status: 'active',
limit: 100,
cursor: 'cursor-1',
});
expect(result.next_cursor).toBe('cursor-2');
expect(lastCall().url).toContain('/dicts?keyword=');
expect(lastCall().url).toContain('group=content');
expect(lastCall().url).toContain('status=active');
expect(lastCall().url).toContain('limit=100');
expect(lastCall().url).toContain('cursor=cursor-1');
});
it('类型详情 / 新建 / 更新 / 状态 / 删除', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse(typeDetail))
.mockResolvedValueOnce(jsonResponse(typeDetail, 201))
.mockResolvedValueOnce(jsonResponse(typeDetail))
.mockResolvedValueOnce(jsonResponse(typeDetail))
.mockResolvedValueOnce(jsonResponse(undefined, 204));
await fetchDictTypeDetail('script_direction');
expect(lastCall().url).toContain('/dicts/script_direction');
await createDictType({
code: 'delivery_slot',
name: '投放位置',
group: 'platform',
kind: 'list',
industryScoped: false,
description: '',
sortOrder: 10,
});
expect(lastCall().init?.method).toBe('POST');
expect(lastBody()).toMatchObject({ code: 'delivery_slot', industryScoped: false });
await updateDictType('script_direction', { name: '脚本方向2' });
expect(lastCall().init?.method).toBe('PATCH');
expect(lastBody()).toEqual({ name: '脚本方向2' });
await setDictTypeStatus('script_direction', { status: 'disabled' });
expect(lastCall().url).toContain('/dicts/script_direction/status');
expect(lastBody()).toEqual({ status: 'disabled' });
await deleteDictType('script_direction');
expect(lastCall().init?.method).toBe('DELETE');
});
it('条目列表:industry_id 只在传入行业视角时出现', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse(itemList))
.mockResolvedValueOnce(jsonResponse(itemList));
await fetchDictItemList('script_direction', { industry_id: ID });
expect(lastCall().url).toContain('/dicts/script_direction/items?industry_id=');
await fetchDictItemList('script_direction');
expect(lastCall().url).not.toContain('industry_id');
});
it('新建 / 编辑条目:body 用 camelCase,编辑不提交编码与行业', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse(item, 201))
.mockResolvedValueOnce(jsonResponse(item));
await createDictItem('script_direction', {
code: 'oral',
name: '口播',
parentCode: null,
industryId: null,
sortOrder: 10,
status: 'active',
remark: '',
});
expect(lastCall().init?.method).toBe('POST');
expect(lastBody()).toMatchObject({ code: 'oral', parentCode: null, industryId: null });
await updateDictItem('script_direction', ID, {
name: '口播(新)',
sortOrder: 20,
status: 'disabled',
remark: '说明',
});
expect(lastCall().url).toContain(`/dicts/script_direction/items/${ID}`);
expect(lastCall().init?.method).toBe('PATCH');
expect(lastBody()).toEqual({
name: '口播(新)',
sortOrder: 20,
status: 'disabled',
remark: '说明',
});
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* A4 数据字典接口(唯一出处)。
* 依据:packages/shared/src/schemas/dict.ts、PRD R-10、技术架构 §6.3.7。
*
* 约定:query 用 snake_case(行业视角 `industry_id`);写接口 body 用 camelCase(C-01)。
* 所有响应 / 入参都用 shared Zod schema parse,契约漂移在请求层直接暴露。
*/
import {
type CreateDictItemInput,
createDictItemInputSchema,
type CreateDictTypeInput,
createDictTypeInputSchema,
type DictItem,
type DictItemListQuery,
dictItemListQuerySchema,
type DictItemListResponse,
dictItemListResponseSchema,
dictItemSchema,
type DictTypeDetail,
dictTypeDetailSchema,
type DictTypeListQuery,
dictTypeListQuerySchema,
type DictTypeSummary,
dictTypeSummarySchema,
listResponseSchema,
type SetDictTypeStatusInput,
setDictTypeStatusInputSchema,
type UpdateDictItemInput,
updateDictItemInputSchema,
type UpdateDictTypeInput,
updateDictTypeInputSchema,
} from '@lingxi/shared';
import { request } from './client';
import { ENDPOINTS } from './endpoints';
const dictTypeListResponseSchema = listResponseSchema(dictTypeSummarySchema);
function searchParams(entries: Record<string, string | number | undefined>): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(entries)) {
if (value !== undefined && value !== '') params.set(key, String(value));
}
const search = params.toString();
return search === '' ? '' : `?${search}`;
}
/** 字典类型列表:keyword / group / status / limit / cursor(query snake_case) */
export async function fetchDictTypeList(
query: Partial<DictTypeListQuery> = {},
): Promise<{ items: DictTypeSummary[]; next_cursor: string | null }> {
const parsed = dictTypeListQuerySchema.parse(query);
const payload: unknown = await request(
`${ENDPOINTS.dicts}${searchParams({
keyword: parsed.keyword,
group: parsed.group,
status: parsed.status,
limit: parsed.limit,
cursor: parsed.cursor,
})}`,
);
return dictTypeListResponseSchema.parse(payload);
}
export async function fetchDictTypeDetail(typeCode: string): Promise<DictTypeDetail> {
const payload: unknown = await request(ENDPOINTS.dict(typeCode));
return dictTypeDetailSchema.parse(payload);
}
export async function createDictType(input: CreateDictTypeInput): Promise<DictTypeDetail> {
const body = createDictTypeInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.dicts, { method: 'POST', body });
return dictTypeDetailSchema.parse(payload);
}
export async function updateDictType(
typeCode: string,
input: UpdateDictTypeInput,
): Promise<DictTypeDetail> {
const body = updateDictTypeInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.dict(typeCode), { method: 'PATCH', body });
return dictTypeDetailSchema.parse(payload);
}
export async function setDictTypeStatus(
typeCode: string,
input: SetDictTypeStatusInput,
): Promise<DictTypeDetail> {
const body = setDictTypeStatusInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.dictStatus(typeCode), {
method: 'PATCH',
body,
});
return dictTypeDetailSchema.parse(payload);
}
export async function deleteDictType(typeCode: string): Promise<void> {
await request<unknown>(ENDPOINTS.dict(typeCode), { method: 'DELETE' });
}
/** 生效条目列表(三层合并结果):不传 industry_id 表示仅通用层 */
export async function fetchDictItemList(
typeCode: string,
query: Partial<DictItemListQuery> = {},
): Promise<DictItemListResponse> {
const parsed = dictItemListQuerySchema.parse(query);
const payload: unknown = await request(
`${ENDPOINTS.dictItems(typeCode)}${searchParams({ industry_id: parsed.industry_id })}`,
);
return dictItemListResponseSchema.parse(payload);
}
export async function createDictItem(
typeCode: string,
input: CreateDictItemInput,
): Promise<DictItem> {
const body = createDictItemInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.dictItems(typeCode), { method: 'POST', body });
return dictItemSchema.parse(payload);
}
export async function updateDictItem(
typeCode: string,
itemId: string,
input: UpdateDictItemInput,
): Promise<DictItem> {
const body = updateDictItemInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.dictItem(typeCode, itemId), {
method: 'PATCH',
body,
});
return dictItemSchema.parse(payload);
}
+12
View File
@@ -4,6 +4,8 @@
* 后端若调整路径,只改这里;联调时以 `apps/web/src/api/generated/openapi.d.ts` 为准。
*/
const USERS = '/users';
const DICTS = '/dicts';
const SYSTEM_CONFIGS = '/system/configs';
export const ENDPOINTS = {
login: '/auth/login',
@@ -52,4 +54,14 @@ export const ENDPOINTS = {
`/customers/${encodeURIComponent(customerId)}/fields`,
customerFieldApprove: (fieldId: string): string =>
`/customer-fields/${encodeURIComponent(fieldId)}/approve`,
// A4 dict / system config
dicts: DICTS,
dict: (typeCode: string): string => `${DICTS}/${encodeURIComponent(typeCode)}`,
dictStatus: (typeCode: string): string => `${DICTS}/${encodeURIComponent(typeCode)}/status`,
dictItems: (typeCode: string): string => `${DICTS}/${encodeURIComponent(typeCode)}/items`,
dictItem: (typeCode: string, itemId: string): string =>
`${DICTS}/${encodeURIComponent(typeCode)}/items/${encodeURIComponent(itemId)}`,
systemConfigs: SYSTEM_CONFIGS,
systemConfigsEffective: `${SYSTEM_CONFIGS}/effective`,
systemConfigsHistory: `${SYSTEM_CONFIGS}/history`,
} as const;
+362
View File
@@ -627,6 +627,147 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/dicts': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 字典类型列表(分组顺序 + 排序;含条目 / 行业层统计) */
get: operations['DictsController_list'];
put?: never;
/** 新建字典类型(编码唯一;builtin=false) */
post: operations['DictsController_create'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/dicts/{code}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 字典类型详情(不存在 40401) */
get: operations['DictsController_detail'];
put?: never;
post?: never;
/** 删除自定义字典类型(内置 / 有子条目时不可删) */
delete: operations['DictsController_remove'];
options?: never;
head?: never;
/** 更新字典类型(编码 / 形态 / 行业作用域不可改) */
patch: operations['DictsController_update'];
trace?: never;
};
'/api/v1/dicts/{code}/status': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** 启用 / 停用字典类型(内置类型允许停用) */
patch: operations['DictsController_setStatus'];
trace?: never;
};
'/api/v1/dicts/{code}/items': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 生效条目列表(industry_id 三层合并;非行业类型忽略该参数) */
get: operations['DictsController_listItems'];
put?: never;
/** 新建字典条目(编码 / 父级 / 行业校验;编码创建后不可改) */
post: operations['DictsController_createItem'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/dicts/{code}/items/{itemId}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** 更新字典条目(仅名称 / 排序 / 启停 / 备注) */
patch: operations['DictsController_updateItem'];
trace?: never;
};
'/api/v1/system/configs': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 全量系统配置(定义元数据 + 当前值 / 版本) */
get: operations['SystemConfigController_list'];
/** 批量保存配置(乐观锁 expectedVersion;写版本历史与审计) */
put: operations['SystemConfigController_update'];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/system/configs/effective': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 生效配置(仅登录;只返回 public 项,前端上限读取入口) */
get: operations['SystemConfigController_effective'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/system/configs/history': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 配置变更记录(按变更时间倒序;操作人为用户姓名) */
get: operations['SystemConfigController_history'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/health': {
parameters: {
query?: never;
@@ -1559,6 +1700,227 @@ export interface operations {
};
};
};
DictsController_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DictsController_create: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DictsController_detail: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DictsController_remove: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DictsController_update: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DictsController_setStatus: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DictsController_listItems: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DictsController_createItem: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DictsController_updateItem: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SystemConfigController_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SystemConfigController_update: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SystemConfigController_effective: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SystemConfigController_history: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
HealthController_check: {
parameters: {
query?: never;
+9
View File
@@ -26,4 +26,13 @@ export const queryKeys = {
customerGroup: (groupId: string) => ['customer-groups', 'detail', groupId] as const,
customerFields: (query: object) => ['customer-fields', 'list', query] as const,
customerFieldsRoot: ['customer-fields'] as const,
// A4 dict / system config
dicts: ['dicts'] as const,
dictTypeList: (query: object) => ['dicts', 'list', query] as const,
dictType: (typeCode: string) => ['dicts', 'detail', typeCode] as const,
dictItems: (typeCode: string, industryId: string | null) =>
['dicts', 'items', typeCode, industryId ?? 'all'] as const,
systemConfigs: ['system-configs'] as const,
systemConfigsEffective: ['system-configs', 'effective'] as const,
systemConfigHistory: (query: object) => ['system-configs', 'history', query] as const,
};
+108
View File
@@ -0,0 +1,108 @@
/**
* A4 系统配置查询与写操作。
*
* R-26:阈值 / 上限只存系统配置一处。`useEffectiveConfigsQuery` 供业务页读取前端上限,
* 读取失败或未登录时由调用方回退到 shared 常量(仅作展示兜底,后端仍做最终校验)。
*/
import {
CUSTOMER_BASE_FIELD_LIMIT,
CUSTOMER_GEN_FIELD_LIMIT,
type SystemConfig,
type SystemConfigChange,
type SystemConfigHistoryQuery,
type SystemConfigValue,
type UpdateSystemConfigsInput,
} from '@lingxi/shared';
import {
useMutation,
type UseMutationResult,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import {
fetchEffectiveSystemConfigs,
fetchSystemConfigHistory,
fetchSystemConfigs,
updateSystemConfigs,
} from '@/api/configs';
import { queryKeys } from '@/api/query-keys';
export function useSystemConfigsQuery(): UseQueryResult<{ items: SystemConfig[] }> {
return useQuery({
queryKey: queryKeys.systemConfigs,
queryFn: fetchSystemConfigs,
staleTime: 10_000,
});
}
/** 生效配置:key → value(仅登录可读,失败不阻塞业务页) */
export function useEffectiveConfigsQuery(): UseQueryResult<Record<string, SystemConfigValue>> {
return useQuery({
queryKey: queryKeys.systemConfigsEffective,
queryFn: fetchEffectiveSystemConfigs,
select: (data) => {
const values: Record<string, SystemConfigValue> = {};
for (const item of data.items) {
values[item.key] = item.value;
}
return values;
},
staleTime: 60_000,
retry: 1,
});
}
/** 读取单个数值型生效配置;缺失 / 非数字时回退 shared 常量 */
export function useEffectiveNumberConfig(key: string, fallback: number): number {
const effective = useEffectiveConfigsQuery();
const value = effective.data?.[key];
return typeof value === 'number' ? value : fallback;
}
export interface CustomerFieldLimits {
baseFieldLimit: number;
genFieldLimit: number;
}
/** 客户基础字段 / 进入生成字段上限(A3 客户页与表单校验共用,R-26) */
export function useCustomerFieldLimits(): CustomerFieldLimits {
const effective = useEffectiveConfigsQuery();
const base = effective.data?.['limit.customer.base_field'];
const gen = effective.data?.['limit.customer.gen_field'];
return {
baseFieldLimit: typeof base === 'number' ? base : CUSTOMER_BASE_FIELD_LIMIT,
genFieldLimit: typeof gen === 'number' ? gen : CUSTOMER_GEN_FIELD_LIMIT,
};
}
export function useSystemConfigHistoryQuery(
query: Partial<SystemConfigHistoryQuery> = {},
options: { enabled?: boolean } = {},
): UseQueryResult<{ items: SystemConfigChange[]; next_cursor: string | null }> {
return useQuery({
queryKey: queryKeys.systemConfigHistory(query),
queryFn: () => fetchSystemConfigHistory(query),
enabled: options.enabled ?? true,
staleTime: 5_000,
});
}
export function useUpdateSystemConfigsMutation(): UseMutationResult<
{ items: SystemConfig[] },
Error,
UpdateSystemConfigsInput
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateSystemConfigs,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.systemConfigs }),
queryClient.invalidateQueries({ queryKey: queryKeys.systemConfigsEffective }),
queryClient.invalidateQueries({ queryKey: ['system-configs', 'history'] }),
]);
},
});
}
+160
View File
@@ -0,0 +1,160 @@
/**
* A4 数据字典查询与写操作(TanStack Query 唯一入口)。
* 字典变更只影响新数据(R-10),写操作成功后失效整棵 `dicts` 缓存,保证类型统计与条目列表同步。
*/
import type {
CreateDictItemInput,
CreateDictTypeInput,
DictItem,
DictItemListResponse,
DictTypeDetail,
DictTypeListQuery,
DictTypeSummary,
SetDictTypeStatusInput,
UpdateDictItemInput,
UpdateDictTypeInput,
} from '@lingxi/shared';
import {
keepPreviousData,
useMutation,
type UseMutationResult,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import {
createDictItem,
createDictType,
deleteDictType,
fetchDictItemList,
fetchDictTypeDetail,
fetchDictTypeList,
setDictTypeStatus,
updateDictItem,
updateDictType,
} from '@/api/dicts';
import { queryKeys } from '@/api/query-keys';
export function useDictTypesQuery(
query: Partial<DictTypeListQuery> = {},
): UseQueryResult<{ items: DictTypeSummary[]; next_cursor: string | null }> {
return useQuery({
queryKey: queryKeys.dictTypeList(query),
queryFn: () => fetchDictTypeList(query),
placeholderData: keepPreviousData,
staleTime: 10_000,
});
}
export function useDictTypeDetailQuery(typeCode: string | null): UseQueryResult<DictTypeDetail> {
return useQuery({
queryKey: queryKeys.dictType(typeCode ?? ''),
queryFn: () => fetchDictTypeDetail(typeCode ?? ''),
enabled: typeCode !== null && typeCode !== '',
staleTime: 10_000,
});
}
/** 生效条目(三层合并):industryId 为 null 时只取通用层 */
export function useDictItemsQuery(
typeCode: string | null,
industryId: string | null,
options: { enabled?: boolean } = {},
): UseQueryResult<DictItemListResponse> {
return useQuery({
queryKey: queryKeys.dictItems(typeCode ?? '', industryId),
queryFn: () =>
fetchDictItemList(typeCode ?? '', industryId === null ? {} : { industry_id: industryId }),
enabled: (options.enabled ?? true) && typeCode !== null && typeCode !== '',
placeholderData: keepPreviousData,
staleTime: 10_000,
});
}
function useInvalidateDicts(): () => Promise<void> {
const queryClient = useQueryClient();
return async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.dicts });
};
}
export function useCreateDictTypeMutation(): UseMutationResult<
DictTypeDetail,
Error,
CreateDictTypeInput
> {
const invalidate = useInvalidateDicts();
return useMutation({
mutationFn: createDictType,
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateDictTypeMutation(): UseMutationResult<
DictTypeDetail,
Error,
{ typeCode: string; input: UpdateDictTypeInput }
> {
const invalidate = useInvalidateDicts();
return useMutation({
mutationFn: ({ typeCode, input }) => updateDictType(typeCode, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useSetDictTypeStatusMutation(): UseMutationResult<
DictTypeDetail,
Error,
{ typeCode: string; input: SetDictTypeStatusInput }
> {
const invalidate = useInvalidateDicts();
return useMutation({
mutationFn: ({ typeCode, input }) => setDictTypeStatus(typeCode, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useDeleteDictTypeMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateDicts();
return useMutation({
mutationFn: deleteDictType,
onSuccess: async () => {
await invalidate();
},
});
}
export function useCreateDictItemMutation(): UseMutationResult<
DictItem,
Error,
{ typeCode: string; input: CreateDictItemInput }
> {
const invalidate = useInvalidateDicts();
return useMutation({
mutationFn: ({ typeCode, input }) => createDictItem(typeCode, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateDictItemMutation(): UseMutationResult<
DictItem,
Error,
{ typeCode: string; itemId: string; input: UpdateDictItemInput }
> {
const invalidate = useInvalidateDicts();
return useMutation({
mutationFn: ({ typeCode, itemId, input }) => updateDictItem(typeCode, itemId, input),
onSuccess: async () => {
await invalidate();
},
});
}
+5 -2
View File
@@ -11,12 +11,15 @@ import { PageBreadcrumbs } from './PageBreadcrumbs';
import { SideNav } from './SideNav';
import { TopBar } from './TopBar';
/** 用户管理、部门管理为「满高主从布局」,由页面自己控制内部滚动 */
/** 用户管理、部门管理、数据字典、系统配置为「满高主从布局」,由页面自己控制内部滚动 */
function isFlushRoute(pathname: string): boolean {
return (
pathname === ROUTES.system.users ||
pathname.startsWith(`${ROUTES.system.users}/`) ||
pathname === ROUTES.system.departments
pathname === ROUTES.system.departments ||
pathname === ROUTES.system.dicts ||
pathname.startsWith(`${ROUTES.system.dicts}/`) ||
pathname === ROUTES.system.settings
);
}
+2
View File
@@ -10,6 +10,8 @@ import '@/styles/roles.css';
import '@/styles/role-editor.css';
import '@/styles/departments.css';
import '@/styles/client.css';
import '@/styles/dicts.css';
import '@/styles/settings.css';
import { LocaleProvider } from '@douyinfe/semi-ui-19';
import { localeZhCN } from '@lingxi/ui';
@@ -12,6 +12,7 @@ import {
useCustomerFieldsQuery,
useCustomersQuery,
} from '@/hooks/queries/use-client';
import { useCustomerFieldLimits } from '@/hooks/queries/use-configs';
const FIELD_TYPE_LABELS: Record<string, string> = {
text: '文本',
@@ -187,6 +188,7 @@ function RejectModal({
/** 客户字段审批(选定方案 A:全宽表格 + 审批弹窗) */
export function CustomerFieldsPage(): ReactNode {
const { genFieldLimit } = useCustomerFieldLimits();
const [status, setStatus] = useState<'all' | 'pending' | 'active' | 'rejected'>('all');
const fields = useCustomerFieldsQuery({ limit: 100, status });
const allFields = useCustomerFieldsQuery({ limit: 100, status: 'all' });
@@ -329,7 +331,7 @@ export function CustomerFieldsPage(): ReactNode {
</table>
)}
<div className="lx-client-tablefoot">
审批与变更写入审计 · 未审批字段不进入生成 · 每客户进入生成字段 ≤ 5 个
审批与变更写入审计 · 未审批字段不进入生成 · 每客户进入生成字段 ≤ {genFieldLimit} 个
</div>
</section>
{applyOpen ? (
@@ -18,6 +18,7 @@ import {
useTransferCustomerMutation,
useUpdateCustomerMutation,
} from '@/hooks/queries/use-client';
import { useCustomerFieldLimits } from '@/hooks/queries/use-configs';
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
import { ROUTES } from '@/routes';
@@ -113,9 +114,11 @@ function TransferModal({
function BaseFieldModal({
customer,
limit,
onClose,
}: {
customer: CustomerDetail;
limit: number;
onClose: () => void;
}): ReactNode {
const update = useUpdateCustomerMutation();
@@ -147,7 +150,7 @@ function BaseFieldModal({
</div>
</div>
<div className="lx-client-hint">
基础字段仅用于检索与报表,永不进入生成;每客户最多 10 个。
基础字段仅用于检索与报表,永不进入生成;每客户最多 {limit} 个。
</div>
</Modal>
);
@@ -155,9 +158,11 @@ function BaseFieldModal({
function ApplyFieldModal({
customer,
limit,
onClose,
}: {
customer: CustomerDetail;
limit: number;
onClose: () => void;
}): ReactNode {
const apply = useApplyCustomerFieldMutation();
@@ -225,7 +230,7 @@ function ApplyFieldModal({
{error}
</div>
) : null}
<div className="lx-client-hint">进入生成字段需审批后生效;每客户最多 5 个。</div>
<div className="lx-client-hint">进入生成字段需审批后生效;每客户最多 {limit} 个。</div>
</Modal>
);
}
@@ -285,6 +290,7 @@ export function CustomerDetailPage(): ReactNode {
const navigate = useNavigate();
const detail = useCustomerDetailQuery(id ?? null);
const fields = useCustomerFieldsOfQuery(id ?? null);
const { baseFieldLimit, genFieldLimit } = useCustomerFieldLimits();
const setStatus = useSetCustomerStatusMutation();
const update = useUpdateCustomerMutation();
const [modal, setModal] = useState<'transfer' | 'base' | 'field' | 'groups' | null>(null);
@@ -505,7 +511,7 @@ export function CustomerDetailPage(): ReactNode {
<Can permission={Permission.CUSTOMER_MANAGE}>
<Button
size="small"
disabled={!canWrite || customer.baseFields.length >= 10}
disabled={!canWrite || customer.baseFields.length >= baseFieldLimit}
onClick={() => {
setModal('base');
}}
@@ -538,8 +544,8 @@ export function CustomerDetailPage(): ReactNode {
))}
</div>
<div className="lx-client-hint">
自由键值对,仅用于检索与报表,永不进入生成;每客户 ≤ 10 个(
{customer.baseFields.length}/10)。
自由键值对,仅用于检索与报表,永不进入生成;每客户 ≤ {baseFieldLimit} 个(
{customer.baseFields.length}/{baseFieldLimit})。
</div>
</Section>
@@ -551,7 +557,7 @@ export function CustomerDetailPage(): ReactNode {
size="small"
theme="solid"
type="primary"
disabled={!canWrite || customer.genFieldCount >= 5}
disabled={!canWrite || customer.genFieldCount >= genFieldLimit}
onClick={() => {
setModal('field');
}}
@@ -576,7 +582,8 @@ export function CustomerDetailPage(): ReactNode {
))}
</div>
<div className="lx-client-hint">
需申请 → 审批 → 生效;每客户 ≤ 5 个({customer.genFieldCount}/5)。
需申请 → 审批 → 生效;每客户 ≤ {genFieldLimit} 个({customer.genFieldCount}/
{genFieldLimit})。
</div>
</Section>
@@ -620,11 +627,15 @@ export function CustomerDetailPage(): ReactNode {
<h3>服务状态</h3>
<div className="lx-client-kv__item">
<span>进入生成字段</span>
<b>{customer.genFieldCount} / 5</b>
<b>
{customer.genFieldCount} / {genFieldLimit}
</b>
</div>
<div className="lx-client-kv__item">
<span>基础字段</span>
<b>{customer.baseFieldCount} / 10</b>
<b>
{customer.baseFieldCount} / {baseFieldLimit}
</b>
</div>
<div className="lx-client-kv__item">
<span>客户组</span>
@@ -706,6 +717,7 @@ export function CustomerDetailPage(): ReactNode {
{modal === 'base' ? (
<BaseFieldModal
customer={customer}
limit={baseFieldLimit}
onClose={() => {
setModal(null);
}}
@@ -714,6 +726,7 @@ export function CustomerDetailPage(): ReactNode {
{modal === 'field' ? (
<ApplyFieldModal
customer={customer}
limit={genFieldLimit}
onClose={() => {
setModal(null);
}}
@@ -13,6 +13,7 @@ import {
useCustomersQuery,
useIndustriesQuery,
} from '@/hooks/queries/use-client';
import { useCustomerFieldLimits } from '@/hooks/queries/use-configs';
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
import { customerDetailPath, customerNewPath, ROUTES } from '@/routes';
@@ -34,6 +35,7 @@ export function CustomersPage(): ReactNode {
const [ownerUserId, setOwnerUserId] = useState<string | undefined>();
const [groupId, setGroupId] = useState<string | undefined>();
const { baseFieldLimit, genFieldLimit } = useCustomerFieldLimits();
const industries = useIndustriesQuery({ limit: 100 });
const groups = useCustomerGroupsQuery();
const users = useUserCandidatesQuery(true);
@@ -225,13 +227,15 @@ export function CustomersPage(): ReactNode {
<td>{customer.groups.map((group) => group.name).join('、') || '—'}</td>
<td>
<span
className={`lx-client-count${customer.genFieldCount >= 5 ? ' is-full' : ''}`}
className={`lx-client-count${customer.genFieldCount >= genFieldLimit ? ' is-full' : ''}`}
>
{customer.genFieldCount}/5
{customer.genFieldCount}/{genFieldLimit}
</span>
</td>
<td>
<span className="lx-client-count">{customer.baseFieldCount}/10</span>
<span className="lx-client-count">
{customer.baseFieldCount}/{baseFieldLimit}
</span>
</td>
<td className="lx-client-muted">
{customer.updatedAt.slice(0, 19).replace('T', ' ')}
@@ -0,0 +1,461 @@
/* eslint-disable max-lines-per-function, complexity -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
import { Button, RadioGroup, Select, Tag } from '@douyinfe/semi-ui-19';
import {
DICT_GROUPS,
type DictItem,
type DictItemListResponse,
type DictTypeDetail,
} from '@lingxi/shared';
import { ErrorState, PageEmpty } from '@lingxi/ui';
import { IconAdd, IconAlert, IconEdit, IconPause, IconPlay, IconView } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import {
dictItemViewStats,
dictScopeChips,
dictTypeKindLabel,
formatDictDateTime,
} from './dict-utils';
import { DictGroupIcon } from './DictGroupIcon';
import { DictItemsView } from './DictItemsView';
export interface DictScopeOption {
value: string | null;
label: string;
}
const EMPTY_ITEMS: DictItemListResponse = {
items: [],
next_cursor: null,
layers: [],
effectiveCount: 0,
overriddenCount: 0,
disabledCount: 0,
};
function groupNameOf(groupKey: DictTypeDetail['group']): string {
return DICT_GROUPS.find((group) => group.key === groupKey)?.name ?? groupKey;
}
function DetailSkeleton(): ReactNode {
return (
<div className="lx-dict-detail-skeleton" aria-hidden="true">
<div className="lx-dict-detail-skeleton__head">
<span className="lx-dict-detail-skeleton__avatar" />
<span className="lx-dict-detail-skeleton__lines">
<span />
<span />
<span />
</span>
</div>
<span className="lx-dict-detail-skeleton__bar" />
<div className="lx-dict-detail-skeleton__rows">
{Array.from({ length: 6 }, (_value, index) => (
<span key={index} />
))}
</div>
</div>
);
}
function DetailHeader({
type,
readOnly,
typeDisabled,
onAddItem,
onEditType,
onToggleType,
}: {
type: DictTypeDetail;
readOnly: boolean;
typeDisabled: boolean;
onAddItem: () => void;
onEditType: () => void;
onToggleType: () => void;
}): ReactNode {
return (
<div className="lx-dict-head">
<span className={`lx-dict-head__avatar${typeDisabled ? ' is-gray' : ''}`}>
<DictGroupIcon group={type.group} size={22} />
</span>
<div className="lx-dict-head__main">
<div className="lx-dict-head__name">
<b>{type.name}</b>
<Tag className={type.builtin ? 'lx-dict-tag is-blue' : 'lx-dict-tag'} size="small">
{type.builtin ? '内置' : '自定义'}
</Tag>
<Tag className="lx-dict-tag" size="small">
{dictTypeKindLabel(type.kind)}
</Tag>
<Tag className={type.industryScoped ? 'lx-dict-tag is-blue' : 'lx-dict-tag'} size="small">
{type.industryScoped ? '行业作用域' : '全平台'}
</Tag>
<Tag
className={type.status === 'active' ? 'lx-dict-tag is-green' : 'lx-dict-tag is-grey'}
size="small"
>
{type.status === 'active' ? '启用' : '停用'}
</Tag>
</div>
<div className="lx-dict-head__meta">
<code>{type.code}</code>
<i>·</i>
<span>{groupNameOf(type.group)}</span>
<i>·</i>
<span>{type.itemCount} 个条目</span>
<i>·</i>
<span>{type.industryCount} 个行业覆盖</span>
</div>
<div className="lx-dict-head__meta">
<span>排序 {type.sortOrder}</span>
<i>·</i>
<span>
最近更新 {type.updatedBy ?? '—'} {formatDictDateTime(type.updatedAt)}
</span>
</div>
{type.description.trim() === '' ? null : (
<p className="lx-dict-head__desc">{type.description}</p>
)}
</div>
<div className="lx-dict-head__actions">
{readOnly ? (
<span className="lx-dict-readonly-chip">
<IconView size={14} />
只读查看
</span>
) : (
<>
<Button
size="small"
type="primary"
theme="solid"
icon={<IconAdd size={14} />}
disabled={typeDisabled}
onClick={onAddItem}
>
新增条目
</Button>
<Button
size="small"
icon={<IconEdit size={14} />}
disabled={typeDisabled}
onClick={onEditType}
>
编辑类型
</Button>
{typeDisabled ? (
<Button size="small" icon={<IconPlay size={14} />} onClick={onToggleType}>
启用类型
</Button>
) : (
<Button
size="small"
type="danger"
theme="light"
icon={<IconPause size={14} />}
onClick={onToggleType}
>
停用类型
</Button>
)}
</>
)}
</div>
</div>
);
}
function ScopeBar({
type,
industryId,
options,
items,
onIndustryChange,
}: {
type: DictTypeDetail;
industryId: string | null;
options: readonly DictScopeOption[];
items: DictItemListResponse;
onIndustryChange: (industryId: string | null) => void;
}): ReactNode {
if (!type.industryScoped) {
return (
<div className="lx-dict-scope">
<span className="lx-dict-scope__label">行业视角</span>
<span className="lx-dict-scope__unsupported">
<IconAlert size={14} />
全平台通用 · 该类型不支持行业扩展
</span>
<span className="lx-dict-layer-chip is-on">
通用 <b>{items.effectiveCount}</b>
</span>
<span className="lx-dict-scope__hint">
<IconAlert size={13} />
行业包只覆盖差异项
</span>
</div>
);
}
const chips = dictScopeChips(items, { scoped: industryId !== null });
return (
<div className="lx-dict-scope">
<span className="lx-dict-scope__label">行业视角</span>
<Select
className="lx-dict-scope__select"
size="small"
value={industryId ?? 'all'}
optionList={[
{ value: 'all', label: '全平台' },
...options.map((option) => ({ value: option.value ?? 'all', label: option.label })),
]}
onChange={(value) => {
onIndustryChange(typeof value === 'string' && value !== 'all' ? value : null);
}}
/>
<span className="lx-dict-scope__layers">
{chips.map((chip, index) => (
<span key={chip.source} className="lx-dict-layer-wrap">
{index > 0 ? <i className="lx-dict-layer-plus">+</i> : null}
<span className={`lx-dict-layer-chip${chip.count > 0 ? ' is-on' : ''}`}>
{chip.label} <b>{chip.count}</b>
</span>
</span>
))}
<i className="lx-dict-layer-plus">=</i>
<span className="lx-dict-layer-chip is-total">
生效 <b>{items.effectiveCount}</b>
</span>
</span>
<span className="lx-dict-scope__hint">
<IconAlert size={13} />
同编码以更具体层覆盖
</span>
</div>
);
}
function ViewRow({
view,
items,
onViewChange,
}: {
view: 'tree' | 'list';
items: DictItemListResponse;
onViewChange: (view: 'tree' | 'list') => void;
}): ReactNode {
const stats = dictItemViewStats(items);
return (
<div className="lx-dict-viewrow">
<RadioGroup
type="button"
buttonSize="small"
value={view}
options={[
{ value: 'tree', label: `树视图 ${stats.total}` },
{ value: 'list', label: `词表视图 ${stats.total}` },
]}
onChange={(event) => {
const next = String(event.target.value);
if (next === 'tree' || next === 'list') onViewChange(next);
}}
/>
<span className="lx-dict-viewrow__hint">
生效 <b>{stats.active}</b> 项 · 继承 {stats.inherited} · 覆盖 {stats.overridden}
{stats.disabled > 0 ? ` · 停用 ${stats.disabled}` : ''}
</span>
{stats.overridden > 0 ? (
<span className="lx-dict-override-pill">{stats.overridden} 项被更具体层覆盖</span>
) : null}
</div>
);
}
function EmptyItems({
type,
readOnly,
industryLabel,
onAddItem,
}: {
type: DictTypeDetail;
readOnly: boolean;
industryLabel: string;
onAddItem: () => void;
}): ReactNode {
return (
<div className="lx-dict-empty">
<PageEmpty
title="当前视角下暂无条目"
description={`「${industryLabel}」视角没有生效条目;可切换行业视角或新增条目。`}
action={
readOnly || type.status === 'disabled' ? undefined : (
<Button type="primary" theme="solid" icon={<IconAdd size={14} />} onClick={onAddItem}>
新增条目
</Button>
)
}
/>
</div>
);
}
export interface DictDetailPanelProps {
type: DictTypeDetail | undefined;
typeLoading: boolean;
typeError: Error | null;
items: DictItemListResponse | undefined;
itemsLoading: boolean;
itemsError: Error | null;
view: 'tree' | 'list';
industryId: string | null;
industryOptions: readonly DictScopeOption[];
readOnly: boolean;
onRetry: () => void;
onViewChange: (view: 'tree' | 'list') => void;
onIndustryChange: (industryId: string | null) => void;
onAddItem: () => void;
onEditType: () => void;
onToggleType: () => void;
onViewItem: (item: DictItem) => void;
onEditItem: (item: DictItem) => void;
onToggleItem: (item: DictItem) => void;
}
/** 右栏:类型头 + 行业作用域 + 树/词表切换 + 条目区 + 底部 R-10 提示 */
export function DictDetailPanel({
type,
typeLoading,
typeError,
items,
itemsLoading,
itemsError,
view,
industryId,
industryOptions,
readOnly,
onRetry,
onViewChange,
onIndustryChange,
onAddItem,
onEditType,
onToggleType,
onViewItem,
onEditItem,
onToggleItem,
}: DictDetailPanelProps): ReactNode {
const response = items ?? EMPTY_ITEMS;
const industryLabel = useMemo(() => {
if (industryId === null) return '全平台';
return industryOptions.find((option) => option.value === industryId)?.label ?? industryId;
}, [industryId, industryOptions]);
if (typeLoading) {
return (
<section className="lx-dict-detail">
<DetailSkeleton />
</section>
);
}
if (typeError !== null) {
return (
<section className="lx-dict-detail">
<ErrorState
title="字典详情加载失败"
message={typeError.message}
onRetry={onRetry}
className="lx-dict-detail__state"
/>
</section>
);
}
if (type === undefined) {
return (
<section className="lx-dict-detail">
<PageEmpty
title="选择左侧字典类型查看详情"
description="支持树视图与词表视图、行业三层合并与条目维护。"
/>
</section>
);
}
const typeDisabled = type.status === 'disabled';
return (
<section className="lx-dict-detail">
{typeDisabled ? (
<div className="lx-dict-banner">
<IconPause size={15} />
<div>
<b>该字典类型已停用</b>
<span>
停用后新数据不可选该类型条目;历史引用保留快照,历史统计按快照解释(R-10)。
</span>
</div>
</div>
) : null}
{readOnly ? (
<div className="lx-dict-banner is-muted">
<IconView size={15} />
<div>
<b>当前账号无字典写权限(dict:manage)</b>
<span>仅可查看类型、切换视图与行业视角,不可新增或修改条目。</span>
</div>
</div>
) : null}
<DetailHeader
type={type}
readOnly={readOnly}
typeDisabled={typeDisabled}
onAddItem={onAddItem}
onEditType={onEditType}
onToggleType={onToggleType}
/>
<ScopeBar
type={type}
industryId={industryId}
options={industryOptions}
items={response}
onIndustryChange={onIndustryChange}
/>
<ViewRow view={view} items={response} onViewChange={onViewChange} />
<div className="lx-dict-items">
{itemsLoading ? <DetailSkeleton /> : null}
{!itemsLoading && itemsError !== null ? (
<ErrorState
title="字典条目加载失败"
message={itemsError.message}
onRetry={onRetry}
className="lx-dict-detail__state"
/>
) : null}
{!itemsLoading && itemsError === null && response.items.length === 0 ? (
<EmptyItems
type={type}
readOnly={readOnly}
industryLabel={industryLabel}
onAddItem={onAddItem}
/>
) : null}
{!itemsLoading && itemsError === null && response.items.length > 0 ? (
<DictItemsView
items={response.items}
view={view}
readOnly={readOnly}
typeDisabled={typeDisabled}
onView={onViewItem}
onEdit={onEditItem}
onToggle={onToggleItem}
/>
) : null}
</div>
<div className="lx-dict-footer">
<span className="lx-dict-footer__lock">编码创建后不可修改</span>
<span className="lx-dict-footer__sep">·</span>
<span>变更只影响新数据,历史统计按快照解释(R-10)</span>
<span className="lx-dict-footer__right">
生效 {response.effectiveCount} 项 · 停用 {response.disabledCount} 项
</span>
</div>
</section>
);
}
@@ -0,0 +1,16 @@
import type { DictGroupKey } from '@lingxi/shared';
import { IconScript, IconShield, IconStack, IconUser } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
export function DictGroupIcon({
group,
size = 16,
}: {
group: DictGroupKey;
size?: number;
}): ReactNode {
if (group === 'content') return <IconScript size={size} />;
if (group === 'platform') return <IconStack size={size} />;
if (group === 'quality') return <IconShield size={size} />;
return <IconUser size={size} />;
}
@@ -0,0 +1,310 @@
/* eslint-disable max-lines-per-function, complexity -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
import { Button, Input, InputNumber, Select, SideSheet, Switch } from '@douyinfe/semi-ui-19';
import type { DictItem, DictTypeDetail } from '@lingxi/shared';
import { notifySuccess } from '@lingxi/ui';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { ApiError } from '@/api/client';
import { useCreateDictItemMutation, useUpdateDictItemMutation } from '@/hooks/queries/use-dicts';
import {
type DictItemFormErrors,
type DictItemFormState,
validateCreateDictItem,
validateUpdateDictItem,
} from './dict-utils';
import type { DictScopeOption } from './DictDetailPanel';
export type DictItemSheetMode = 'create' | 'edit' | 'view';
interface DictItemSheetProps {
mode: DictItemSheetMode;
type: DictTypeDetail;
item: DictItem | null;
items: readonly DictItem[];
industryOptions: readonly DictScopeOption[];
defaultIndustryId: string | null;
onClose: () => void;
onSaved: () => void;
}
function initialForm(item: DictItem | null, defaultIndustryId: string | null): DictItemFormState {
if (item === null) {
return {
name: '',
code: '',
parentCode: '',
industryId: defaultIndustryId ?? '',
sortOrder: '10',
status: 'active',
remark: '',
};
}
return {
name: item.name,
code: item.code,
parentCode: item.parentCode ?? '',
industryId: item.industryId ?? '',
sortOrder: String(item.sortOrder),
status: item.status,
remark: item.remark,
};
}
function Field({
label,
required,
hint,
error,
full,
children,
}: {
label: string;
required?: boolean;
hint?: ReactNode;
error?: string;
full?: boolean;
children: ReactNode;
}): ReactNode {
return (
<label className={`lx-dict-field${full === true ? ' is-full' : ''}`}>
<span className="lx-dict-field__label">
{label}
{required === true ? <i>*</i> : null}
{hint === undefined ? null : <span className="lx-dict-field__hint">{hint}</span>}
</span>
{children}
{error === undefined ? null : <span className="lx-dict-field__error">{error}</span>}
</label>
);
}
function apiCodeMessage(cause: unknown): string | null {
return cause instanceof ApiError && cause.code === 40902 ? cause.message : null;
}
/** 新增 / 编辑 / 查看条目:编辑时编码、父级、所属行业只读(创建后不可改) */
export function DictItemSheet({
mode,
type,
item,
items,
industryOptions,
defaultIndustryId,
onClose,
onSaved,
}: DictItemSheetProps): ReactNode {
const readOnly = mode === 'view';
const isTree = type.kind === 'tree';
const [form, setForm] = useState<DictItemFormState>(() => initialForm(item, defaultIndustryId));
const [errors, setErrors] = useState<DictItemFormErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const createItem = useCreateDictItemMutation();
const updateItem = useUpdateDictItemMutation();
const industryList = useMemo(
() => [
{ value: '', label: '通用(全平台)' },
...industryOptions.map((option) => ({ value: option.value ?? '', label: option.label })),
],
[industryOptions],
);
const parentList = useMemo(() => {
const roots = items.filter(
(candidate) =>
candidate.parentCode === null &&
candidate.code !== item?.code &&
candidate.status === 'active',
);
return [
{ value: '', label: '— 作为一级条目 —' },
...roots.map((candidate) => ({ value: candidate.code, label: candidate.name })),
];
}, [items, item?.code]);
const patch = (values: Partial<DictItemFormState>): void => {
setForm((current) => ({ ...current, ...values }));
setErrors({});
setSubmitError(null);
};
const submit = async (): Promise<void> => {
if (readOnly) return;
if (mode === 'create') {
const result = validateCreateDictItem(form);
if (result.input === null) {
setErrors(result.errors);
return;
}
try {
await createItem.mutateAsync({ typeCode: type.code, input: result.input });
notifySuccess('条目已创建');
onSaved();
} catch (cause) {
const message = apiCodeMessage(cause);
if (message !== null) setErrors({ code: message });
else setSubmitError(cause instanceof Error ? cause.message : '创建失败');
}
return;
}
if (item === null) return;
const result = validateUpdateDictItem(form);
if (result.input === null) {
setErrors(result.errors);
return;
}
try {
await updateItem.mutateAsync({ typeCode: type.code, itemId: item.id, input: result.input });
notifySuccess('条目已更新');
onSaved();
} catch (cause) {
setSubmitError(cause instanceof Error ? cause.message : '保存失败');
}
};
const title = mode === 'create' ? '新增条目' : mode === 'edit' ? '编辑条目' : '查看条目';
const saving = createItem.isPending || updateItem.isPending;
return (
<SideSheet
visible
width={560}
title={title}
closeOnEsc
maskClosable={false}
onCancel={onClose}
footer={
<div className="lx-dict-sheet__footer">
<Button onClick={onClose}>{readOnly ? '关闭' : '取消'}</Button>
{readOnly ? null : (
<Button
type="primary"
theme="solid"
loading={saving}
onClick={() => {
void submit();
}}
>
保存
</Button>
)}
</div>
}
>
<div className="lx-dict-sheet">
<div className="lx-dict-sheet__sub">
所属字典 <code>{type.code}</code> · {type.name}
</div>
<div className="lx-dict-form-grid">
<Field label="条目名称" required full error={errors.name}>
<Input
value={form.name}
disabled={readOnly}
maxLength={64}
placeholder="如:口播"
onChange={(value) => {
patch({ name: value });
}}
/>
</Field>
<Field
label="编码"
required={mode === 'create'}
full
hint={mode === 'create' ? '创建后不可修改' : '编码创建后不可修改'}
error={errors.code}
>
<Input
value={form.code}
disabled={readOnly || mode !== 'create'}
maxLength={49}
placeholder="字母、数字、下划线或短横线"
onChange={(value) => {
patch({ code: value });
}}
/>
</Field>
<Field
label="父级"
hint={isTree ? '可选,树类型专有' : '词表类型无层级'}
error={errors.parentCode}
>
{isTree ? (
<Select
value={form.parentCode}
disabled={readOnly || mode === 'edit'}
optionList={parentList}
onChange={(value) => {
patch({ parentCode: typeof value === 'string' ? value : '' });
}}
/>
) : (
<Input value="— 词表类型无层级 —" disabled />
)}
</Field>
<Field
label="所属行业"
hint={mode === 'edit' ? '创建后不可切换' : '默认当前视角,可切换'}
error={errors.industryId}
>
<Select
value={form.industryId}
disabled={readOnly || mode === 'edit'}
optionList={industryList}
onChange={(value) => {
patch({ industryId: typeof value === 'string' ? value : '' });
}}
/>
</Field>
<Field label="排序" error={errors.sortOrder}>
<InputNumber
value={form.sortOrder}
min={0}
max={9999}
disabled={readOnly}
onChange={(value) => {
patch({ sortOrder: String(value) });
}}
/>
</Field>
<Field label="状态">
<span className="lx-dict-switch-line">
<Switch
checked={form.status === 'active'}
disabled={readOnly}
onChange={(checked) => {
patch({ status: checked ? 'active' : 'disabled' });
}}
/>
<span>{form.status === 'active' ? '启用条目' : '停用条目'}</span>
</span>
</Field>
<Field label="备注" full error={errors.remark}>
<Input
value={form.remark}
disabled={readOnly}
maxLength={255}
placeholder="补充用途、口径或平台要求"
onChange={(value) => {
patch({ remark: value });
}}
/>
</Field>
</div>
{submitError === null ? null : <div className="lx-dict-form-error">{submitError}</div>}
{mode === 'create' ? (
<div className="lx-dict-note">
编码提交后不可修改;名称、排序、备注与启停可再次编辑。新增在当前行业视角下即时生效。
</div>
) : null}
{item !== null ? (
<div className="lx-dict-note is-warn">
被 <b>{item.refCount}</b> 个行业包 /
模板引用;变更只影响新数据,历史引用保留填写时快照(R-10)。
</div>
) : null}
</div>
</SideSheet>
);
}
@@ -0,0 +1,283 @@
/* eslint-disable max-lines-per-function -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
import type { DictItem } from '@lingxi/shared';
import { IconChevronDown, IconChevronRight } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { buildDictItemTree, formatDictDateTime } from './dict-utils';
import { DictSourceTags, DictStatusTag } from './DictTags';
export interface DictItemActionProps {
item: DictItem;
readOnly: boolean;
typeDisabled: boolean;
onView: (item: DictItem) => void;
onEdit: (item: DictItem) => void;
onToggle: (item: DictItem) => void;
}
function ItemActions({
item,
readOnly,
typeDisabled,
onView,
onEdit,
onToggle,
}: DictItemActionProps): ReactNode {
if (readOnly || typeDisabled) {
return (
<button
type="button"
className="lx-dict-link"
onClick={() => {
onView(item);
}}
>
查看
</button>
);
}
return (
<span className="lx-dict-row-actions">
<button
type="button"
className="lx-dict-link"
onClick={() => {
onEdit(item);
}}
>
编辑
</button>
<button
type="button"
className={item.status === 'active' ? 'lx-dict-link is-danger' : 'lx-dict-link'}
onClick={() => {
onToggle(item);
}}
>
{item.status === 'active' ? '停用' : '启用'}
</button>
</span>
);
}
function ItemCells({ item }: { item: DictItem }): ReactNode {
return (
<>
<td className="lx-dict-col-name">
<b title={item.name}>{item.name}</b>
</td>
<td>
<code className="lx-dict-code">{item.code}</code>
</td>
<td className="lx-dict-col-parent" title={item.parentName ?? undefined}>
{item.parentName ?? '—'}
</td>
<td>
<DictSourceTags
source={item.source}
overridden={item.overridden}
disabled={item.status === 'disabled'}
/>
</td>
<td className="lx-dict-num">{item.sortOrder}</td>
<td>
<DictStatusTag status={item.status} />
</td>
<td className="lx-dict-num">{item.refCount}</td>
<td className="lx-dict-col-time" title={item.updatedAt}>
{formatDictDateTime(item.updatedAt)}
</td>
</>
);
}
function DictItemsTable({
items,
readOnly,
typeDisabled,
onView,
onEdit,
onToggle,
}: {
items: readonly DictItem[];
} & Omit<DictItemActionProps, 'item'>): ReactNode {
return (
<table className="lx-dict-table">
<colgroup>
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th>名称</th>
<th>编码</th>
<th>父级</th>
<th>来源</th>
<th className="lx-dict-num">排序</th>
<th>状态</th>
<th className="lx-dict-num">引用</th>
<th>更新时间</th>
<th className="lx-dict-col-acts">操作</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className={item.status === 'disabled' ? 'is-disabled' : undefined}>
<ItemCells item={item} />
<td className="lx-dict-col-acts">
<ItemActions
item={item}
readOnly={readOnly}
typeDisabled={typeDisabled}
onView={onView}
onEdit={onEdit}
onToggle={onToggle}
/>
</td>
</tr>
))}
</tbody>
</table>
);
}
function DictTreeRow({
item,
childrenByCode,
collapsed,
onToggleNode,
actions,
}: {
item: DictItem;
childrenByCode: Map<string, DictItem[]>;
collapsed: ReadonlySet<string>;
onToggleNode: (code: string) => void;
actions: Omit<DictItemActionProps, 'item'>;
}): ReactNode {
const children = childrenByCode.get(item.code) ?? [];
const open = !collapsed.has(item.code);
const isParent = children.length > 0;
return (
<div className="lx-dict-tree__branch">
<div className={`lx-dict-tree__row${item.status === 'disabled' ? ' is-disabled' : ''}`}>
{isParent ? (
<button
type="button"
className="lx-dict-tree__caret"
aria-expanded={open}
aria-label={open ? '收起子项' : '展开子项'}
onClick={() => {
onToggleNode(item.code);
}}
>
{open ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</button>
) : (
<span className="lx-dict-tree__caret is-empty" />
)}
<div className="lx-dict-tree__main">
<b title={item.name}>{item.name}</b>
<code className="lx-dict-code">{item.code}</code>
<DictSourceTags
source={item.source}
overridden={item.overridden}
disabled={item.status === 'disabled'}
/>
</div>
<div className="lx-dict-tree__meta">
{isParent ? <span>{children.length} 个子项</span> : null}
<span>{item.refCount} 引用</span>
<span>{formatDictDateTime(item.updatedAt)}</span>
</div>
<div className="lx-dict-tree__acts">
<ItemActions item={item} {...actions} />
</div>
</div>
{isParent && open
? children.map((child) => (
<div key={child.id} className="lx-dict-tree__child">
<DictTreeRow
item={child}
childrenByCode={childrenByCode}
collapsed={collapsed}
onToggleNode={onToggleNode}
actions={actions}
/>
</div>
))
: null}
</div>
);
}
function DictItemsTree({
items,
readOnly,
typeDisabled,
onView,
onEdit,
onToggle,
}: {
items: readonly DictItem[];
} & Omit<DictItemActionProps, 'item'>): ReactNode {
const tree = useMemo(() => buildDictItemTree(items), [items]);
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
const toggleNode = (code: string): void => {
setCollapsed((current) => {
const next = new Set(current);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
});
};
return (
<div className="lx-dict-tree">
{tree.roots.map((item) => (
<DictTreeRow
key={item.id}
item={item}
childrenByCode={tree.childrenByCode}
collapsed={collapsed}
onToggleNode={toggleNode}
actions={{ readOnly, typeDisabled, onView, onEdit, onToggle }}
/>
))}
</div>
);
}
export interface DictItemsViewProps {
items: readonly DictItem[];
view: 'tree' | 'list';
readOnly: boolean;
typeDisabled: boolean;
onView: (item: DictItem) => void;
onEdit: (item: DictItem) => void;
onToggle: (item: DictItem) => void;
}
/** 条目区域:树视图 / 词表视图(两种视图都展示编码、来源与停用态) */
export function DictItemsView({
items,
view,
readOnly,
typeDisabled,
onView,
onEdit,
onToggle,
}: DictItemsViewProps): ReactNode {
const actions = { readOnly, typeDisabled, onView, onEdit, onToggle };
return view === 'tree' ? (
<DictItemsTree items={items} {...actions} />
) : (
<DictItemsTable items={items} {...actions} />
);
}
@@ -0,0 +1,42 @@
import { Tag } from '@douyinfe/semi-ui-19';
import type { DictItemSource, DictStatus } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { dictSourceLabel } from './dict-utils';
export function DictStatusTag({ status }: { status: DictStatus }): ReactNode {
return (
<Tag className={`lx-dict-status lx-dict-status--${status}`} size="small">
{status === 'active' ? '启用' : '停用'}
</Tag>
);
}
/** 三层合并来源标签:通用 / 父行业 / 本行业 +(可选)已覆盖(R-10 快照仍按停用展示) */
export function DictSourceTags({
source,
overridden,
disabled,
}: {
source: DictItemSource;
overridden: boolean;
disabled?: boolean;
}): ReactNode {
return (
<span className="lx-dict-source-group">
<Tag className={`lx-dict-source lx-dict-source--${source}`} size="small">
{dictSourceLabel(source)}
</Tag>
{overridden ? (
<Tag className="lx-dict-source lx-dict-source--override" size="small">
已覆盖
</Tag>
) : null}
{disabled === true ? (
<Tag className="lx-dict-source lx-dict-source--disabled" size="small">
已停用
</Tag>
) : null}
</span>
);
}
@@ -0,0 +1,235 @@
/* eslint-disable max-lines-per-function -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
import { Input, RadioGroup } from '@douyinfe/semi-ui-19';
import type { DictGroupKey, DictStatus, DictTypeSummary } from '@lingxi/shared';
import { ErrorState, PageEmpty } from '@lingxi/ui';
import { IconChevronDown, IconChevronRight, IconSearch } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { dictTypeKindLabel, formatDictDate, groupDictTypes } from './dict-utils';
import { DictGroupIcon } from './DictGroupIcon';
import { DictStatusTag } from './DictTags';
export interface DictTypeListPanelProps {
types: readonly DictTypeSummary[];
loading: boolean;
error: Error | null;
keyword: string;
status: 'all' | DictStatus;
selectedCode: string | null;
onKeywordChange: (value: string) => void;
onStatusChange: (value: 'all' | DictStatus) => void;
onSelect: (typeCode: string) => void;
onRetry: () => void;
}
function DictTypeRow({
type,
selected,
onSelect,
}: {
type: DictTypeSummary;
selected: boolean;
onSelect: (typeCode: string) => void;
}): ReactNode {
return (
<button
type="button"
className={`lx-dict-type${selected ? ' is-selected' : ''}`}
aria-current={selected ? 'true' : undefined}
onClick={() => {
onSelect(type.code);
}}
>
<span className="lx-dict-type__icon">
<DictGroupIcon group={type.group} />
</span>
<span className="lx-dict-type__main">
<span className="lx-dict-type__name">
<b>{type.name}</b>
<span className="lx-dict-mini-tag">{dictTypeKindLabel(type.kind)}</span>
{type.builtin ? <span className="lx-dict-mini-tag is-blue">内置</span> : null}
<span className={`lx-dict-mini-tag${type.industryScoped ? ' is-blue' : ''}`}>
{type.industryScoped ? '行业' : '全平台'}
</span>
</span>
<span className="lx-dict-type__meta">
<code>{type.code}</code>
<i>·</i>
<span>{type.itemCount} 项</span>
{type.industryScoped ? (
<>
<i>·</i>
<span>覆盖 {type.industryCount}</span>
</>
) : null}
</span>
</span>
<span className="lx-dict-type__side">
<DictStatusTag status={type.status} />
<span className="lx-dict-type__date">{formatDictDate(type.updatedAt)}</span>
</span>
</button>
);
}
function DictTypeGroup({
name,
count,
iconKey,
open,
onToggle,
children,
}: {
name: string;
count: number;
iconKey: DictGroupKey;
open: boolean;
onToggle: () => void;
children: ReactNode;
}): ReactNode {
return (
<section className="lx-dict-group">
<button type="button" className="lx-dict-group__head" aria-expanded={open} onClick={onToggle}>
<span className="lx-dict-group__caret">
{open ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</span>
<span className="lx-dict-group__icon">
<DictGroupIcon group={iconKey} />
</span>
<b>{name}</b>
<span className="lx-dict-group__count">{count} 个类型</span>
</button>
{open ? <div className="lx-dict-group__items">{children}</div> : null}
</section>
);
}
function DictListSkeleton(): ReactNode {
return (
<div className="lx-dict-skeleton" aria-hidden="true">
{Array.from({ length: 6 }, (_value, index) => (
<div key={index} className="lx-dict-skeleton__row">
<span className="lx-dict-skeleton__icon" />
<span className="lx-dict-skeleton__lines">
<span />
<span />
</span>
</div>
))}
</div>
);
}
/** 左栏 320:统计在页头,卡片内是搜索 + 状态筛选 + 4 分组可折叠类型列表 */
export function DictTypeListPanel({
types,
loading,
error,
keyword,
status,
selectedCode,
onKeywordChange,
onStatusChange,
onSelect,
onRetry,
}: DictTypeListPanelProps): ReactNode {
const groups = useMemo(() => groupDictTypes(types), [types]);
const [closedGroups, setClosedGroups] = useState<ReadonlySet<string>>(new Set());
const statusCounts = useMemo(
() => ({
all: types.length,
active: types.filter((type) => type.status === 'active').length,
disabled: types.filter((type) => type.status === 'disabled').length,
}),
[types],
);
const toggleGroup = (groupKey: string): void => {
setClosedGroups((current) => {
const next = new Set(current);
if (next.has(groupKey)) next.delete(groupKey);
else next.add(groupKey);
return next;
});
};
return (
<aside className="lx-dict-list" aria-label="字典类型">
<div className="lx-dict-list__head">
<span className="lx-dict-list__title">字典类型</span>
<span className="lx-dict-list__count">{loading ? '—' : types.length}</span>
<span className="lx-dict-list__note">三层合并</span>
</div>
<div className="lx-dict-list__tools">
<Input
value={keyword}
prefix={<IconSearch size={16} />}
placeholder="搜索类型名称或编码"
showClear
aria-label="搜索字典类型"
onChange={onKeywordChange}
/>
<RadioGroup
type="button"
buttonSize="small"
className="lx-dict-status-filter"
value={status}
options={[
{ value: 'all', label: `全部 ${statusCounts.all}` },
{ value: 'active', label: `启用 ${statusCounts.active}` },
{ value: 'disabled', label: `停用 ${statusCounts.disabled}` },
]}
onChange={(event) => {
const next = String(event.target.value);
if (next === 'all' || next === 'active' || next === 'disabled') onStatusChange(next);
}}
/>
</div>
<div className="lx-dict-list__body">
{loading ? <DictListSkeleton /> : null}
{error !== null ? (
<ErrorState
title="字典类型加载失败"
message={error.message}
onRetry={onRetry}
className="lx-dict-list__state"
/>
) : null}
{!loading && error === null && groups.length === 0 ? (
<PageEmpty
title="没有匹配的字典类型"
description={
keyword.trim() === ''
? '当前没有可用类型,可新建字典类型'
: '换个关键词或调整状态筛选'
}
/>
) : null}
{!loading && error === null
? groups.map((group) => (
<DictTypeGroup
key={group.key}
name={group.name}
count={group.types.length}
iconKey={group.key}
open={!closedGroups.has(group.key)}
onToggle={() => {
toggleGroup(group.key);
}}
>
{group.types.map((type) => (
<DictTypeRow
key={type.code}
type={type}
selected={type.code === selectedCode}
onSelect={onSelect}
/>
))}
</DictTypeGroup>
))
: null}
</div>
</aside>
);
}
@@ -0,0 +1,306 @@
/* eslint-disable max-lines-per-function, complexity -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
import { Button, Input, InputNumber, Modal, Select, Switch, TextArea } from '@douyinfe/semi-ui-19';
import { DICT_GROUPS, type DictTypeDetail } from '@lingxi/shared';
import { notifySuccess } from '@lingxi/ui';
import { IconDelete } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { ApiError } from '@/api/client';
import {
useCreateDictTypeMutation,
useDeleteDictTypeMutation,
useUpdateDictTypeMutation,
} from '@/hooks/queries/use-dicts';
import {
type DictTypeFormErrors,
type DictTypeFormState,
validateCreateDictType,
validateUpdateDictType,
} from './dict-utils';
export type DictTypeModalMode = 'create' | 'edit';
interface DictTypeModalProps {
mode: DictTypeModalMode;
type: DictTypeDetail | null;
onClose: () => void;
onSaved: (type: DictTypeDetail) => void;
onDeleted: () => void;
}
const GROUP_OPTIONS = DICT_GROUPS.map((group) => ({ value: group.key, label: group.name }));
const KIND_OPTIONS = [
{ value: 'list', label: '词表' },
{ value: 'tree', label: '树' },
];
function initialForm(mode: DictTypeModalMode, type: DictTypeDetail | null): DictTypeFormState {
if (mode === 'edit' && type !== null) {
return {
code: type.code,
name: type.name,
group: type.group,
kind: type.kind,
industryScoped: type.industryScoped,
description: type.description,
sortOrder: String(type.sortOrder),
};
}
return {
code: '',
name: '',
group: 'content',
kind: 'list',
industryScoped: false,
description: '',
sortOrder: '10',
};
}
function Field({
label,
required,
hint,
error,
full,
children,
}: {
label: string;
required?: boolean;
hint?: ReactNode;
error?: string;
full?: boolean;
children: ReactNode;
}): ReactNode {
return (
<label className={`lx-dict-field${full === true ? ' is-full' : ''}`}>
<span className="lx-dict-field__label">
{label}
{required === true ? <i>*</i> : null}
{hint === undefined ? null : <span className="lx-dict-field__hint">{hint}</span>}
</span>
{children}
{error === undefined ? null : <span className="lx-dict-field__error">{error}</span>}
</label>
);
}
/** 新建 / 编辑字典类型;内置类型不可删除,编码 / 形态 / 行业作用域创建后只读 */
export function DictTypeModal({
mode,
type,
onClose,
onSaved,
onDeleted,
}: DictTypeModalProps): ReactNode {
const [form, setForm] = useState<DictTypeFormState>(() => initialForm(mode, type));
const [errors, setErrors] = useState<DictTypeFormErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const createType = useCreateDictTypeMutation();
const updateType = useUpdateDictTypeMutation();
const deleteType = useDeleteDictTypeMutation();
const patch = (values: Partial<DictTypeFormState>): void => {
setForm((current) => ({ ...current, ...values }));
setErrors({});
setSubmitError(null);
};
const submit = async (): Promise<void> => {
if (mode === 'create') {
const result = validateCreateDictType(form);
if (result.input === null) {
setErrors(result.errors);
return;
}
try {
const created = await createType.mutateAsync(result.input);
notifySuccess('字典类型已创建');
onSaved(created);
} catch (cause) {
if (cause instanceof ApiError && cause.code === 40902) setErrors({ code: cause.message });
else setSubmitError(cause instanceof Error ? cause.message : '创建失败');
}
return;
}
if (type === null) return;
const result = validateUpdateDictType(form);
if (result.input === null) {
setErrors(result.errors);
return;
}
try {
const updated = await updateType.mutateAsync({ typeCode: type.code, input: result.input });
notifySuccess('字典类型已更新');
onSaved(updated);
} catch (cause) {
setSubmitError(cause instanceof Error ? cause.message : '保存失败');
}
};
const confirmDelete = (): void => {
if (type === null || type.builtin) return;
Modal.confirm({
title: `删除字典类型「${type.name}」?`,
content:
'删除后该类型的全部条目将一并移除,历史数据中的填写快照不受影响(R-10)。此操作不可恢复。',
okText: '删除类型',
okButtonProps: { type: 'danger' },
onOk: async () => {
try {
await deleteType.mutateAsync(type.code);
notifySuccess('字典类型已删除');
onDeleted();
} catch (cause) {
setSubmitError(cause instanceof Error ? cause.message : '删除失败');
}
},
});
};
const saving = createType.isPending || updateType.isPending;
const immutableHint = mode === 'edit' ? '创建后不可修改' : undefined;
return (
<Modal
visible
title={mode === 'create' ? '新建字典类型' : '编辑字典类型'}
width={560}
closeOnEsc
maskClosable={false}
onCancel={onClose}
footer={
<div className="lx-dict-modal__footer">
{mode === 'edit' && type !== null && !type.builtin ? (
<Button
type="danger"
theme="borderless"
icon={<IconDelete size={14} />}
onClick={confirmDelete}
>
删除该类型
</Button>
) : null}
<span className="lx-dict-modal__spacer" />
<Button onClick={onClose}>取消</Button>
<Button
type="primary"
theme="solid"
loading={saving}
onClick={() => {
void submit();
}}
>
保存
</Button>
</div>
}
>
<div className="lx-dict-form-grid">
{mode === 'create' ? (
<>
<Field label="类型名称" required full error={errors.name}>
<Input
value={form.name}
maxLength={64}
placeholder="如:投放位置"
onChange={(value) => {
patch({ name: value });
}}
/>
</Field>
<Field label="类型编码" required full hint="创建后不可修改" error={errors.code}>
<Input
value={form.code}
maxLength={49}
placeholder="小写字母、数字与下划线"
onChange={(value) => {
patch({ code: value });
}}
/>
</Field>
</>
) : (
<Field label="类型编码" full hint={immutableHint}>
<Input value={form.code} disabled />
</Field>
)}
{mode === 'edit' ? (
<Field label="类型名称" required full error={errors.name}>
<Input
value={form.name}
maxLength={64}
onChange={(value) => {
patch({ name: value });
}}
/>
</Field>
) : null}
<Field label="所属分组" error={errors.group}>
<Select
value={form.group}
optionList={GROUP_OPTIONS}
onChange={(value) => {
const matched = DICT_GROUPS.find((group) => group.key === value);
if (matched !== undefined) patch({ group: matched.key });
}}
/>
</Field>
<Field label="形态" hint={mode === 'edit' ? immutableHint : '树 / 词表'}>
<Select
value={form.kind}
disabled={mode === 'edit'}
optionList={KIND_OPTIONS}
onChange={(value) => {
if (value === 'tree' || value === 'list') patch({ kind: value });
}}
/>
</Field>
<Field label="行业作用域" hint={mode === 'edit' ? immutableHint : '关闭则全平台通用'}>
<span className="lx-dict-switch-line">
<Switch
checked={form.industryScoped}
disabled={mode === 'edit'}
onChange={(checked) => {
patch({ industryScoped: checked });
}}
/>
<span>{form.industryScoped ? '允许行业覆盖 / 追加' : '仅全平台通用'}</span>
</span>
</Field>
<Field label="排序" error={errors.sortOrder}>
<InputNumber
value={form.sortOrder}
min={0}
max={9999}
onChange={(value) => {
patch({ sortOrder: String(value) });
}}
/>
</Field>
<Field label="说明" full error={errors.description}>
<TextArea
value={form.description}
maxLength={255}
rows={3}
placeholder="说明该字典的用途与引用位置"
onChange={(value) => {
patch({ description: value });
}}
/>
</Field>
</div>
{submitError === null ? null : <div className="lx-dict-form-error">{submitError}</div>}
{mode === 'edit' && type?.builtin === true ? (
<div className="lx-dict-note">内置类型的编码不可修改,仅支持停用,不提供「删除类型」。</div>
) : null}
{mode === 'create' ? (
<div className="lx-dict-note">
自定义类型可删除;内置类型仅支持停用。类型编码创建后不可修改。
</div>
) : null}
</Modal>
);
}
@@ -0,0 +1,350 @@
/* eslint-disable max-lines-per-function, complexity -- 字典主从页编排列表筛选、详情查询、URL 状态与两个弹层,集中便于追踪数据流 */
import { Button, Modal } from '@douyinfe/semi-ui-19';
import { type DictItem, type DictTypeDetail, Permission } from '@lingxi/shared';
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
import { IconAdd, IconView } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router';
import { useIndustriesQuery } from '@/hooks/queries/use-client';
import {
useDictItemsQuery,
useDictTypeDetailQuery,
useDictTypesQuery,
useSetDictTypeStatusMutation,
useUpdateDictItemMutation,
} from '@/hooks/queries/use-dicts';
import { usePermission } from '@/hooks/use-permission';
import { dictDetailPath, ROUTES } from '@/routes';
import { dictTypeStats, filterDictTypes } from './dict-utils';
import { DictDetailPanel, type DictScopeOption } from './DictDetailPanel';
import { DictItemSheet, type DictItemSheetMode } from './DictItemSheet';
import { DictTypeListPanel } from './DictTypeListPanel';
import { DictTypeModal, type DictTypeModalMode } from './DictTypeModal';
interface ItemSheetState {
mode: DictItemSheetMode;
item: DictItem | null;
}
/** 数据字典(方案 A:左 320 类型列表 + 右详情主从分栏;列表 / 详情两个入口共用) */
export function DictsPage(): ReactNode {
const { type: routeTypeCode } = useParams<{ type: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const canWrite = usePermission(Permission.DICT_MANAGE);
const isDetailRoute = routeTypeCode !== undefined;
const [itemSheet, setItemSheet] = useState<ItemSheetState | null>(null);
const [typeModal, setTypeModal] = useState<DictTypeModalMode | null>(null);
const keywordParam = searchParams.get('keyword') ?? '';
const statusParam = searchParams.get('status');
const status = statusParam === 'active' || statusParam === 'disabled' ? statusParam : 'all';
const industryParam = searchParams.get('industry');
const viewParam = searchParams.get('view');
const typesQuery = useDictTypesQuery({ limit: 100 });
const industries = useIndustriesQuery({ limit: 100 });
const allTypes = useMemo(() => typesQuery.data?.items ?? [], [typesQuery.data]);
const visibleTypes = useMemo(
() => filterDictTypes(allTypes, { keyword: keywordParam, status }),
[allTypes, keywordParam, status],
);
const stats = useMemo(() => dictTypeStats(allTypes), [allTypes]);
const defaultTypeCode =
allTypes.find((candidate) => candidate.code === 'script_direction')?.code ??
visibleTypes[0]?.code ??
allTypes[0]?.code ??
null;
const selectedCode = routeTypeCode ?? defaultTypeCode;
const detailQuery = useDictTypeDetailQuery(selectedCode);
const type = detailQuery.data;
/** 行业视角:无 query 时默认「电商 / 服饰鞋包」;`industry=all` 表示显式选择全平台 */
const defaultIndustryId = useMemo(() => {
let firstLeaf: string | null = null;
for (const root of industries.data?.items ?? []) {
if (root.name === '服饰鞋包') return root.id;
for (const child of root.children) {
if (child.name === '服饰鞋包') return child.id;
firstLeaf ??= child.id;
}
}
return firstLeaf;
}, [industries.data]);
const industryId =
type?.industryScoped === true
? industryParam === null
? defaultIndustryId
: industryParam === 'all'
? null
: industryParam
: null;
const itemsQuery = useDictItemsQuery(selectedCode, industryId, {
enabled: type !== undefined,
});
const view =
viewParam === 'tree' || viewParam === 'list'
? viewParam
: type?.kind === 'tree'
? 'tree'
: 'list';
const industryOptions = useMemo<DictScopeOption[]>(() => {
const options: DictScopeOption[] = [];
for (const root of industries.data?.items ?? []) {
options.push({ value: root.id, label: root.name });
for (const child of root.children) {
options.push({ value: child.id, label: `${root.name} / ${child.name}` });
}
}
return options;
}, [industries.data]);
const updateItem = useUpdateDictItemMutation();
const setTypeStatus = useSetDictTypeStatusMutation();
const updateParams = (updates: Record<string, string | null>, replace = false): void => {
const next = new URLSearchParams(searchParams);
for (const [key, value] of Object.entries(updates)) {
if (value === null) next.delete(key);
else next.set(key, value);
}
setSearchParams(next, { replace });
};
const selectType = (typeCode: string): void => {
const search = searchParams.toString();
void navigate({
pathname: dictDetailPath(typeCode),
search: search === '' ? '' : `?${search}`,
});
};
const retry = (): void => {
void detailQuery.refetch();
void itemsQuery.refetch();
void typesQuery.refetch();
};
const openCreateItem = (): void => {
if (!canWrite || type === undefined || type.status === 'disabled') return;
setItemSheet({ mode: 'create', item: null });
};
const toggleItem = (item: DictItem): void => {
if (type === undefined) return;
const disabling = item.status === 'active';
Modal.confirm({
title: `${disabling ? '停用条目「' : '启用条目「'}${item.name}」?`,
content: disabling
? '停用后新数据不可选该条目,历史引用仍显示「已停用」,历史统计按填写时快照解释(R-10)。'
: '启用后新数据可重新选择该条目,历史数据与快照不变。',
okText: disabling ? '停用' : '启用',
okButtonProps: disabling ? { type: 'danger' } : undefined,
onOk: async () => {
try {
await updateItem.mutateAsync({
typeCode: type.code,
itemId: item.id,
input: { status: disabling ? 'disabled' : 'active' },
});
notifySuccess(disabling ? `已停用「${item.name}」` : `已启用「${item.name}」`);
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '操作失败');
}
},
});
};
const toggleType = (): void => {
if (type === undefined) return;
const disabling = type.status === 'active';
Modal.confirm({
title: `${disabling ? '停用字典类型「' : '启用字典类型「'}${type.name}」?`,
content: disabling
? `停用后新数据不可选该类型条目(共 ${type.itemCount} 个,含行业覆盖项);历史引用保留填写时快照,历史统计按快照解释(R-10)。`
: '启用后新数据恢复可选该类型条目,历史快照不变。',
okText: disabling ? '停用类型' : '启用类型',
okButtonProps: disabling ? { type: 'danger' } : undefined,
onOk: async () => {
try {
await setTypeStatus.mutateAsync({
typeCode: type.code,
input: { status: disabling ? 'disabled' : 'active' },
});
notifySuccess(disabling ? '已停用字典类型' : '已启用字典类型');
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '操作失败');
}
},
});
};
const handleTypeSaved = (saved: DictTypeDetail): void => {
setTypeModal(null);
if (!isDetailRoute) {
const search = searchParams.toString();
void navigate({
pathname: dictDetailPath(saved.code),
search: search === '' ? '' : `?${search}`,
});
}
};
const handleTypeDeleted = (): void => {
setTypeModal(null);
const next = new URLSearchParams(searchParams);
next.delete('industry');
next.delete('view');
setSearchParams(next, { replace: true });
void navigate({
pathname: ROUTES.system.dicts,
search: next.toString() === '' ? '' : `?${next.toString()}`,
});
};
const pageHead = (
<header className="lx-dict-page__header">
<div>
<h1>数据字典</h1>
<p>平台枚举、行业词表与编码的唯一来源</p>
</div>
<div className="lx-dict-page__actions">
{canWrite ? (
<Button
type="primary"
theme="solid"
icon={<IconAdd size={16} />}
onClick={() => {
setTypeModal('create');
}}
>
新建字典类型
</Button>
) : (
<span className="lx-dict-readonly-chip">
<IconView size={14} />
只读权限
</span>
)}
</div>
</header>
);
const statStrip = (
<div className="lx-dict-statstrip">
<span className="lx-dict-statstrip__item">
字典类型 <b>{typesQuery.isPending ? '—' : stats.typeCount}</b>
</span>
<span className="lx-dict-statstrip__item">
字典条目 <b>{typesQuery.isPending ? '—' : stats.itemCount}</b>
</span>
<span className="lx-dict-statstrip__item">
分组 <b>{typesQuery.isPending ? '—' : stats.groupCount}</b>
</span>
<span className="lx-dict-statstrip__item">
树类型 <b>{typesQuery.isPending ? '—' : stats.treeCount}</b>
</span>
<span className="lx-dict-statstrip__hint">
类型编码创建后不可修改;条目变更只影响新数据(R-10)
</span>
</div>
);
return (
<div className="lx-dict-page">
{isDetailRoute ? null : pageHead}
{isDetailRoute ? null : statStrip}
{typesQuery.isPending && !isDetailRoute ? (
<PageSkeleton rows={8} />
) : (
<div className="lx-dict-split">
<DictTypeListPanel
types={visibleTypes}
loading={typesQuery.isPending}
error={typesQuery.error}
keyword={keywordParam}
status={status}
selectedCode={selectedCode}
onKeywordChange={(value) => {
updateParams({ keyword: value === '' ? null : value }, true);
}}
onStatusChange={(value) => {
updateParams({ status: value === 'all' ? null : value }, true);
}}
onSelect={selectType}
onRetry={() => {
void typesQuery.refetch();
}}
/>
<DictDetailPanel
type={type}
typeLoading={selectedCode !== null && detailQuery.isPending}
typeError={detailQuery.error}
items={itemsQuery.data}
itemsLoading={selectedCode !== null && itemsQuery.isPending}
itemsError={itemsQuery.error}
view={view}
industryId={industryId}
industryOptions={industryOptions}
readOnly={!canWrite}
onRetry={retry}
onViewChange={(next) => {
updateParams({ view: next });
}}
onIndustryChange={(next) => {
// 显式选择全平台写入 industry=all,避免被默认行业覆盖
updateParams({ industry: next ?? 'all' });
}}
onAddItem={openCreateItem}
onEditType={() => {
setTypeModal('edit');
}}
onToggleType={toggleType}
onViewItem={(item) => {
setItemSheet({ mode: 'view', item });
}}
onEditItem={(item) => {
setItemSheet({ mode: 'edit', item });
}}
onToggleItem={toggleItem}
/>
</div>
)}
{itemSheet !== null && type !== undefined ? (
<DictItemSheet
mode={itemSheet.mode}
type={type}
item={itemSheet.item}
items={itemsQuery.data?.items ?? []}
industryOptions={industryOptions}
defaultIndustryId={industryId}
onClose={() => {
setItemSheet(null);
}}
onSaved={() => {
setItemSheet(null);
void itemsQuery.refetch();
}}
/>
) : null}
{typeModal !== null ? (
<DictTypeModal
mode={typeModal}
type={typeModal === 'edit' ? (type ?? null) : null}
onClose={() => {
setTypeModal(null);
}}
onSaved={handleTypeSaved}
onDeleted={handleTypeDeleted}
/>
) : null}
</div>
);
}
@@ -0,0 +1,265 @@
import type { DictItem, DictItemListResponse, DictTypeSummary } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import {
buildDictItemTree,
dictItemViewStats,
dictScopeChips,
dictSourceLabel,
dictTypeKindLabel,
dictTypeStats,
filterDictTypes,
formatDictDate,
groupDictTypes,
isDictItemCodeValid,
isDictTypeCodeValid,
validateCreateDictItem,
validateCreateDictType,
validateUpdateDictItem,
} from './dict-utils';
const NOW = '2026-11-04T10:00:00.000Z';
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
function typeSummary(overrides: Partial<DictTypeSummary> & { code: string }): DictTypeSummary {
return {
name: overrides.code,
group: 'content',
kind: 'list',
description: '',
industryScoped: false,
builtin: true,
status: 'active',
sortOrder: 10,
itemCount: 0,
activeItemCount: 0,
industryCount: 0,
updatedAt: NOW,
updatedBy: '江岚',
...overrides,
};
}
function dictItem(overrides: Partial<DictItem> & { id: string; code: string }): DictItem {
return {
typeCode: 'script_direction',
name: overrides.code,
parentCode: null,
parentName: null,
industryId: null,
industryPath: null,
sortOrder: 10,
status: 'active',
remark: '',
source: 'generic',
overridden: false,
refCount: 0,
updatedAt: NOW,
updatedBy: '江岚',
...overrides,
};
}
function itemResponse(items: DictItem[]): DictItemListResponse {
return {
items,
next_cursor: null,
layers: [],
effectiveCount: items.length,
overriddenCount: items.filter((item) => item.overridden).length,
disabledCount: items.filter((item) => item.status === 'disabled').length,
};
}
describe('数据字典派生视图', () => {
it('构建二级树:父级缺失的条目提升为一级,不丢行', () => {
const items = [
dictItem({ id: `${ID}a`, code: 'oral', name: '口播', sortOrder: 20 }),
dictItem({ id: `${ID}b`, code: 'live', name: '直播切片', parentCode: 'oral', sortOrder: 10 }),
dictItem({ id: `${ID}c`, code: 'orphan', name: '孤儿', parentCode: 'missing' }),
];
const tree = buildDictItemTree(items);
expect(tree.roots.map((item) => item.code)).toEqual(['orphan', 'oral']);
expect(tree.childrenByCode.get('oral')?.map((item) => item.code)).toEqual(['live']);
});
it('4 个固定分组:空分组不渲染,组内类型按原顺序保留', () => {
const types = [
typeSummary({ code: 'hook_type', name: '钩子类型', group: 'content' }),
typeSummary({ code: 'platform_spec', name: '平台与规格', group: 'platform', kind: 'tree' }),
typeSummary({ code: 'banned_words', name: '禁用词库', group: 'quality' }),
];
const groups = groupDictTypes(types);
expect(groups.map((group) => group.key)).toEqual(['content', 'platform', 'quality']);
expect(groups[0]?.types.map((type) => type.code)).toEqual(['hook_type']);
});
it('搜索按名称 / 编码,状态筛选 all / active / disabled', () => {
const types = [
typeSummary({ code: 'script_direction', name: '脚本方向' }),
typeSummary({ code: 'hook_type', name: '钩子类型', status: 'disabled' }),
typeSummary({ code: 'audience_tag', name: '人群标签' }),
];
expect(filterDictTypes(types, { keyword: '方向', status: 'all' }).map((t) => t.code)).toEqual([
'script_direction',
]);
expect(filterDictTypes(types, { keyword: 'HOOK', status: 'all' }).map((t) => t.code)).toEqual([
'hook_type',
]);
expect(filterDictTypes(types, { keyword: '', status: 'active' }).map((t) => t.code)).toEqual([
'script_direction',
'audience_tag',
]);
});
it('页头统计全部由接口数据计算', () => {
const stats = dictTypeStats([
typeSummary({ code: 'a', group: 'content', itemCount: 3 }),
typeSummary({ code: 'b', group: 'platform', itemCount: 7, kind: 'tree' }),
typeSummary({ code: 'c', group: 'platform', itemCount: 2, kind: 'tree' }),
]);
expect(stats).toEqual({ typeCount: 3, itemCount: 12, groupCount: 2, treeCount: 2 });
});
it('来源标签与形态文案', () => {
expect(dictSourceLabel('generic')).toBe('通用');
expect(dictSourceLabel('parent')).toBe('父行业');
expect(dictSourceLabel('current')).toBe('本行业');
expect(dictTypeKindLabel('tree')).toBe('树');
expect(dictTypeKindLabel('list')).toBe('词表');
expect(formatDictDate(NOW)).toMatch(/^11-04$/);
});
});
describe('三层合并 chips 与视图统计', () => {
it('行业视角展示通用 / 父行业 / 本行业,优先使用后端 layers 计数', () => {
const response: DictItemListResponse = {
...itemResponse([
dictItem({ id: `${ID}a`, code: 'oral', source: 'generic' }),
dictItem({ id: `${ID}b`, code: 'seeding', source: 'parent', overridden: true }),
dictItem({ id: `${ID}c`, code: 'outfit', source: 'current' }),
]),
layers: [
{ source: 'generic', industryId: null, label: '通用', count: 5 },
{ source: 'parent', industryId: ID, label: '父行业', count: 2 },
{ source: 'current', industryId: ID, label: '本行业', count: 1 },
],
};
expect(dictScopeChips(response, { scoped: true })).toEqual([
{ source: 'generic', label: '通用', count: 5 },
{ source: 'parent', label: '父行业', count: 2 },
{ source: 'current', label: '本行业', count: 1 },
]);
expect(dictScopeChips(response, { scoped: false })).toEqual([
{ source: 'generic', label: '通用', count: 5 },
]);
});
it('layers 缺失时按条目 source 兜底计数', () => {
const response = itemResponse([
dictItem({ id: `${ID}a`, code: 'oral' }),
dictItem({ id: `${ID}b`, code: 'seeding', source: 'parent' }),
]);
expect(dictScopeChips(response, { scoped: true }).map((chip) => chip.count)).toEqual([1, 1, 0]);
});
it('视图提示:生效 / 继承 / 覆盖 / 停用', () => {
const response: DictItemListResponse = {
...itemResponse([
dictItem({ id: `${ID}a`, code: 'oral' }),
dictItem({ id: `${ID}b`, code: 'seeding', source: 'parent', overridden: true }),
dictItem({ id: `${ID}c`, code: 'old', status: 'disabled' }),
]),
effectiveCount: 2,
overriddenCount: 1,
disabledCount: 1,
};
expect(dictItemViewStats(response)).toEqual({
total: 3,
active: 2,
inherited: 1,
overridden: 1,
disabled: 1,
});
});
});
describe('字典表单校验(与 shared schema 同源)', () => {
it('新建条目:编码格式、名称必填与默认排序', () => {
const ok = validateCreateDictItem({
name: '口播',
code: 'oral',
parentCode: '',
industryId: '',
sortOrder: '10',
status: 'active',
remark: '',
});
expect(ok.errors).toEqual({});
expect(ok.input).toMatchObject({ code: 'oral', parentCode: null, industryId: null });
const bad = validateCreateDictItem({
name: '',
code: 'Oral!',
parentCode: '',
industryId: '',
sortOrder: '',
status: 'active',
remark: '',
});
expect(bad.input).toBeNull();
expect(bad.errors.name).toBeDefined();
expect(bad.errors.code).toBeDefined();
expect(bad.errors.sortOrder).toBeDefined();
});
it('编辑条目只提交可改字段(编码 / 行业不进 body)', () => {
const result = validateUpdateDictItem({
name: '口播(新)',
code: 'oral',
parentCode: '',
industryId: '',
sortOrder: '20',
status: 'disabled',
remark: '说明',
});
expect(result.input).toEqual({
name: '口播(新)',
sortOrder: 20,
status: 'disabled',
remark: '说明',
});
});
it('新建类型:编码 snake_case 与默认形态', () => {
const ok = validateCreateDictType({
code: 'delivery_slot',
name: '投放位置',
group: 'platform',
kind: 'list',
industryScoped: false,
description: '',
sortOrder: '10',
});
expect(ok.input).toMatchObject({ code: 'delivery_slot', kind: 'list' });
const bad = validateCreateDictType({
code: 'DeliverySlot',
name: '投放位置',
group: 'platform',
kind: 'tree',
industryScoped: true,
description: '',
sortOrder: '10',
});
expect(bad.input).toBeNull();
expect(bad.errors.code).toBeDefined();
});
it('编码即时校验与 shared 正则一致', () => {
expect(isDictTypeCodeValid('script_direction')).toBe(true);
expect(isDictTypeCodeValid('ScriptDirection')).toBe(false);
expect(isDictItemCodeValid('15s')).toBe(true);
expect(isDictItemCodeValid('oral')).toBe(true);
expect(isDictItemCodeValid('a')).toBe(false);
});
});
@@ -0,0 +1,346 @@
/**
* 数据字典页纯函数:树构建、分组、筛选、三层来源标签与表单校验。
*
* 三层合并规则由后端实现(通用 → 父行业 → 本行业,同编码更具体层覆盖),
* 前端只消费 `DictItemListResponse`,不重写覆盖规则;这里仅做展示派生。
*/
import {
type CreateDictItemInput,
createDictItemInputSchema,
type CreateDictTypeInput,
createDictTypeInputSchema,
DICT_GROUPS,
type DictItem,
dictItemCodeSchema,
type DictItemListResponse,
type DictItemSource,
type DictStatus,
dictTypeCodeSchema,
type DictTypeKind,
type DictTypeSummary,
type UpdateDictItemInput,
updateDictItemInputSchema,
type UpdateDictTypeInput,
updateDictTypeInputSchema,
} from '@lingxi/shared';
// ---------------------------------------------------------------------------
// 派生视图
// ---------------------------------------------------------------------------
export interface DictItemTreeNode {
roots: DictItem[];
childrenByCode: Map<string, DictItem[]>;
}
function bySortOrder(left: DictItem, right: DictItem): number {
return left.sortOrder - right.sortOrder || left.name.localeCompare(right.name, 'zh-Hans-CN');
}
/** 构建二级树:父级不存在 / 未生效时提升为一级条目,避免整行消失 */
export function buildDictItemTree(items: readonly DictItem[]): DictItemTreeNode {
const byCode = new Map(items.map((item) => [item.code, item] as const));
const roots: DictItem[] = [];
const childrenByCode = new Map<string, DictItem[]>();
for (const item of [...items].sort(bySortOrder)) {
const parent = item.parentCode === null ? undefined : byCode.get(item.parentCode);
if (parent === undefined) {
roots.push(item);
continue;
}
const siblings = childrenByCode.get(parent.code);
if (siblings === undefined) childrenByCode.set(parent.code, [item]);
else siblings.push(item);
}
return { roots, childrenByCode };
}
export function dictSourceLabel(source: DictItemSource): string {
if (source === 'generic') return '通用';
if (source === 'parent') return '父行业';
return '本行业';
}
export interface DictScopeChip {
source: DictItemSource;
label: string;
count: number;
}
/**
* 三层 chips:行业视角下展示 通用 + 父行业 + 本行业;全平台只展示通用。
* 后端 `layers` 优先;缺失时按条目 source 兜底计数。
*/
export function dictScopeChips(
response: Pick<DictItemListResponse, 'items' | 'layers'>,
options: { scoped: boolean },
): DictScopeChip[] {
const layerCounts = new Map(response.layers.map((layer) => [layer.source, layer.count] as const));
const fallbackCounts: Record<DictItemSource, number> = { generic: 0, parent: 0, current: 0 };
for (const item of response.items) {
fallbackCounts[item.source] += 1;
}
const sources: DictItemSource[] = options.scoped ? ['generic', 'parent', 'current'] : ['generic'];
return sources.map((source) => ({
source,
label: dictSourceLabel(source),
count: layerCounts.get(source) ?? fallbackCounts[source],
}));
}
export interface DictItemViewStats {
total: number;
active: number;
inherited: number;
overridden: number;
disabled: number;
}
/** 视图提示:生效 / 继承 / 覆盖 / 停用(后端计数优先,条目级派生兜底) */
export function dictItemViewStats(
response: Pick<
DictItemListResponse,
'items' | 'effectiveCount' | 'overriddenCount' | 'disabledCount'
>,
): DictItemViewStats {
const disabled =
response.disabledCount || response.items.filter((item) => item.status === 'disabled').length;
return {
total: response.items.length,
active: response.effectiveCount || response.items.length - disabled,
inherited: response.items.filter((item) => item.source !== 'generic').length,
overridden: response.overriddenCount || response.items.filter((item) => item.overridden).length,
disabled,
};
}
/** 类型列表按 4 个固定分组归档;空分组不渲染 */
export function groupDictTypes(types: readonly DictTypeSummary[]): {
key: (typeof DICT_GROUPS)[number]['key'];
name: string;
description: string;
types: DictTypeSummary[];
}[] {
return DICT_GROUPS.map((group) => ({
key: group.key,
name: group.name,
description: group.description,
types: types.filter((type) => type.group === group.key),
})).filter((group) => group.types.length > 0);
}
export function filterDictTypes(
types: readonly DictTypeSummary[],
query: { keyword: string; status: 'all' | DictStatus },
): DictTypeSummary[] {
const keyword = query.keyword.trim().toLowerCase();
return types.filter((type) => {
if (query.status !== 'all' && type.status !== query.status) return false;
if (keyword === '') return true;
return type.name.toLowerCase().includes(keyword) || type.code.toLowerCase().includes(keyword);
});
}
export interface DictTypeStats {
typeCount: number;
itemCount: number;
groupCount: number;
treeCount: number;
}
/** 页头统计:类型 / 条目 / 分组 / 树类型,全部由接口数据计算 */
export function dictTypeStats(types: readonly DictTypeSummary[]): DictTypeStats {
return {
typeCount: types.length,
itemCount: types.reduce((sum, type) => sum + type.itemCount, 0),
groupCount: new Set(types.map((type) => type.group)).size,
treeCount: types.filter((type) => type.kind === 'tree').length,
};
}
export function dictTypeKindLabel(kind: DictTypeKind): string {
return kind === 'tree' ? '树' : '词表';
}
const pad = (value: number): string => String(value).padStart(2, '0');
/** 列表侧栏更新时间:MM-DD */
export function formatDictDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '—';
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/** 详情 / 条目更新时间:MM-DD HH:mm */
export function formatDictDateTime(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '—';
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
// ---------------------------------------------------------------------------
// 表单校验(保存前与 shared schema 同源,避免两套规则)
// ---------------------------------------------------------------------------
export interface DictItemFormState {
name: string;
code: string;
parentCode: string;
industryId: string;
sortOrder: string;
status: DictStatus;
remark: string;
}
export type DictItemFormErrors = Partial<
Record<'name' | 'code' | 'parentCode' | 'industryId' | 'sortOrder' | 'remark', string>
>;
function collectIssues(
issues: readonly { path: readonly (string | number)[]; message: string }[],
): DictItemFormErrors {
const errors: DictItemFormErrors = {};
for (const issue of issues) {
const key = String(issue.path[0] ?? 'form');
if (
key === 'name' ||
key === 'code' ||
key === 'parentCode' ||
key === 'industryId' ||
key === 'sortOrder' ||
key === 'remark'
) {
errors[key] ??= issue.message;
}
}
return errors;
}
function itemFormBase(values: DictItemFormState): {
name: string;
sortOrder: number;
status: DictStatus;
remark: string;
} {
return {
name: values.name.trim(),
sortOrder: values.sortOrder.trim() === '' ? Number.NaN : Number(values.sortOrder),
status: values.status,
remark: values.remark,
};
}
export function validateCreateDictItem(values: DictItemFormState): {
errors: DictItemFormErrors;
input: CreateDictItemInput | null;
} {
const parsed = createDictItemInputSchema.safeParse({
...itemFormBase(values),
code: values.code.trim(),
parentCode: values.parentCode === '' ? null : values.parentCode,
industryId: values.industryId === '' ? null : values.industryId,
});
if (!parsed.success) return { errors: collectIssues(parsed.error.issues), input: null };
return { errors: {}, input: parsed.data };
}
export function validateUpdateDictItem(values: DictItemFormState): {
errors: DictItemFormErrors;
input: UpdateDictItemInput | null;
} {
const parsed = updateDictItemInputSchema.safeParse(itemFormBase(values));
if (!parsed.success) return { errors: collectIssues(parsed.error.issues), input: null };
return { errors: {}, input: parsed.data };
}
export interface DictTypeFormState {
code: string;
name: string;
group: (typeof DICT_GROUPS)[number]['key'];
kind: DictTypeKind;
industryScoped: boolean;
description: string;
sortOrder: string;
}
export type DictTypeFormErrors = Partial<
Record<'code' | 'name' | 'group' | 'kind' | 'description' | 'sortOrder', string>
>;
function typeFormBase(values: DictTypeFormState): {
name: string;
group: DictTypeFormState['group'];
description: string;
sortOrder: number;
} {
return {
name: values.name.trim(),
group: values.group,
description: values.description,
sortOrder: values.sortOrder.trim() === '' ? Number.NaN : Number(values.sortOrder),
};
}
export function validateCreateDictType(values: DictTypeFormState): {
errors: DictTypeFormErrors;
input: CreateDictTypeInput | null;
} {
const parsed = createDictTypeInputSchema.safeParse({
...typeFormBase(values),
code: values.code.trim(),
kind: values.kind,
industryScoped: values.industryScoped,
});
if (!parsed.success) {
const errors: DictTypeFormErrors = {};
for (const issue of parsed.error.issues) {
const key = String(issue.path[0] ?? 'form');
if (
key === 'code' ||
key === 'name' ||
key === 'group' ||
key === 'kind' ||
key === 'description' ||
key === 'sortOrder'
) {
errors[key] ??= issue.message;
}
}
return { errors, input: null };
}
return { errors: {}, input: parsed.data };
}
export function validateUpdateDictType(values: DictTypeFormState): {
errors: DictTypeFormErrors;
input: UpdateDictTypeInput | null;
} {
const parsed = updateDictTypeInputSchema.safeParse(typeFormBase(values));
if (!parsed.success) {
const errors: DictTypeFormErrors = {};
for (const issue of parsed.error.issues) {
const key = String(issue.path[0] ?? 'form');
if (
key === 'code' ||
key === 'name' ||
key === 'group' ||
key === 'kind' ||
key === 'description' ||
key === 'sortOrder'
) {
errors[key] ??= issue.message;
}
}
return { errors, input: null };
}
return { errors: {}, input: parsed.data };
}
/** 新建 / 编辑类型时即时校验编码格式(不访问后端) */
export function isDictTypeCodeValid(code: string): boolean {
return dictTypeCodeSchema.safeParse(code).success;
}
export function isDictItemCodeValid(code: string): boolean {
return dictItemCodeSchema.safeParse(code).success;
}
@@ -0,0 +1,154 @@
/* eslint-disable max-lines-per-function -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
import { Select, SideSheet, Tag } from '@douyinfe/semi-ui-19';
import type { SystemConfig, SystemConfigChange } from '@lingxi/shared';
import { ErrorState, PageEmpty } from '@lingxi/ui';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { useSystemConfigHistoryQuery } from '@/hooks/queries/use-configs';
import {
configKeyLabel,
configValueText,
formatConfigTime,
formatConfigValue,
} from './config-utils';
function HistoryItem({
change,
config,
}: {
change: SystemConfigChange;
config: SystemConfig | undefined;
}): ReactNode {
const oldText =
config === undefined
? formatConfigValue(change.oldValue)
: configValueText(config, change.oldValue);
const newText =
config === undefined
? formatConfigValue(change.newValue)
: configValueText(config, change.newValue);
return (
<div className="lx-settings-history-item">
<div className="lx-settings-history-item__head">
<b>{change.label === '' ? configKeyLabel(change.key) : change.label}</b>
<Tag className="lx-settings-tag is-blue" size="small">
v{change.version}
</Tag>
<span className="lx-settings-history-item__time">
{formatConfigTime(change.changedAt)} · {change.operator ?? '—'}
</span>
<Tag className="lx-settings-tag is-green" size="small">
{change.result === '' ? '已生效' : change.result}
</Tag>
</div>
<div className="lx-settings-history-item__change">
<span className="lx-settings-history-item__old">{oldText}</span>
<span className="lx-settings-history-item__arrow">→</span>
<span className="lx-settings-history-item__new">{newText}</span>
</div>
<div className="lx-settings-history-item__reason">
原因:{change.reason === null || change.reason === '' ? '—' : change.reason}
</div>
</div>
);
}
export interface ConfigHistorySheetProps {
open: boolean;
filterKey: string | null;
configs: readonly SystemConfig[];
onFilterChange: (key: string | null) => void;
onClose: () => void;
}
/** 变更记录抽屉:版本、前后值、操作人、时间、原因与结果;按配置项筛选 */
export function ConfigHistorySheet({
open,
filterKey,
configs,
onFilterChange,
onClose,
}: ConfigHistorySheetProps): ReactNode {
const history = useSystemConfigHistoryQuery(
filterKey === null ? { limit: 50 } : { config_key: filterKey, limit: 50 },
{ enabled: open },
);
const items = useMemo(() => history.data?.items ?? [], [history.data]);
const configByKey = useMemo(
() => new Map(configs.map((config) => [config.key, config] as const)),
[configs],
);
const filterOptions = useMemo(() => {
const keys = new Set(configs.map((config) => config.key));
for (const item of items) keys.add(item.key);
return [
{ value: 'all', label: '全部配置' },
...[...keys]
.sort((left, right) =>
configKeyLabel(left).localeCompare(configKeyLabel(right), 'zh-Hans-CN'),
)
.map((key) => ({ value: key, label: configKeyLabel(key) })),
];
}, [configs, items]);
return (
<SideSheet
visible={open}
width={560}
title="配置变更记录"
closeOnEsc
maskClosable={false}
onCancel={onClose}
footer={
<div className="lx-settings-sheet__footer">
<span className="lx-settings-note-inline">含版本号、前后值、操作人、时间与原因</span>
</div>
}
>
<div className="lx-settings-history">
<div className="lx-settings-history__toolbar">
<span>配置项</span>
<Select
className="lx-settings-history__filter"
size="small"
value={filterKey ?? 'all'}
optionList={filterOptions}
onChange={(value) => {
onFilterChange(typeof value === 'string' && value !== 'all' ? value : null);
}}
/>
<span className="lx-settings-history__count">共 {items.length} 条 · 按时间倒序</span>
</div>
{history.isPending ? <div className="lx-settings-history__loading">加载中…</div> : null}
{!history.isPending && history.error !== null ? (
<ErrorState
title="变更记录加载失败"
message={history.error.message}
onRetry={() => {
void history.refetch();
}}
/>
) : null}
{!history.isPending && history.error === null && items.length === 0 ? (
<PageEmpty
title="暂无变更记录"
description="保存系统配置后会在此留存版本、前后值与操作人。"
/>
) : null}
{!history.isPending && history.error === null && items.length > 0 ? (
<div className="lx-settings-history__list">
{items.map((change) => (
<HistoryItem
key={`${change.key}-${String(change.version)}-${change.changedAt}`}
change={change}
config={configByKey.get(change.key)}
/>
))}
</div>
) : null}
</div>
</SideSheet>
);
}
@@ -0,0 +1,154 @@
/* eslint-disable max-lines-per-function, complexity -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
import { Button, Input, Switch } from '@douyinfe/semi-ui-19';
import type { SystemConfig } from '@lingxi/shared';
import { IconAlert, IconShare } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { configValueText, formatConfigTime, namingPreview, rangeTextOf } from './config-utils';
const SHARED_FIELD_KEYS: ReadonlySet<string> = new Set([
'limit.customer.base_field',
'limit.customer.gen_field',
]);
function LabelBlock({ config, dirty }: { config: SystemConfig; dirty: boolean }): ReactNode {
return (
<div className="lx-settings-config__label">
<div className="lx-settings-config__title">
<b>{config.label}</b>
{dirty ? <span className="lx-settings-dirty-tag">未保存</span> : null}
<code>{config.key}</code>
</div>
<p className="lx-settings-config__desc">{config.description}</p>
<p className="lx-settings-config__impact">
<IconAlert size={13} />
<span>{config.impact}</span>
</p>
{SHARED_FIELD_KEYS.has(config.key) ? (
<p className="lx-settings-config__shared">
<IconShare size={12} />
<span>A3 客户字段、表单校验与任务提交共用(R-26)</span>
</p>
) : null}
</div>
);
}
function ValueMeta({ config, error }: { config: SystemConfig; error: string | null }): ReactNode {
if (error !== null) return <span className="lx-settings-config__err">{error}</span>;
const range = rangeTextOf(config);
return (
<span className="lx-settings-config__meta">
{range === '' ? null : `${range} · `}v{config.version} · {config.updatedBy ?? '—'}{' '}
{formatConfigTime(config.updatedAt)}
</span>
);
}
export interface ConfigRowProps {
config: SystemConfig;
raw: string;
dirty: boolean;
readOnly: boolean;
error: string | null;
editingPattern: boolean;
namingMaxLength: number;
onRawChange: (raw: string) => void;
onEditPattern: () => void;
onCollapsePattern: () => void;
}
/** 单个配置项:文本 / 数字 / 开关;命名模板(pattern)展开行内编辑 + 校验提示 */
export function ConfigRow({
config,
raw,
dirty,
readOnly,
error,
editingPattern,
namingMaxLength,
onRawChange,
onEditPattern,
onCollapsePattern,
}: ConfigRowProps): ReactNode {
if (config.type === 'pattern') {
const preview = namingPreview(raw, namingMaxLength);
const patternError = error ?? preview.error;
return (
<div className={`lx-settings-config is-pattern${dirty ? ' is-dirty' : ''}`}>
<LabelBlock config={config} dirty={dirty} />
{editingPattern && !readOnly ? (
<div className="lx-settings-pattern">
<Input value={raw} maxLength={200} aria-label="命名规则" onChange={onRawChange} />
<div className="lx-settings-pattern__hint">
<span className={patternError === null ? 'is-ok' : 'is-bad'}>
{patternError === null ? '校验通过' : `校验未通过:${patternError}`}
</span>
<span>
{' '}
· {raw.length} / {namingMaxLength} 字符 · 点击右侧占位符插入
</span>
</div>
<Button size="small" theme="borderless" onClick={onCollapsePattern}>
收起编辑
</Button>
</div>
) : (
<div className="lx-settings-pattern__value">
<code>{configValueText(config, raw)}</code>
{readOnly ? null : (
<Button
size="small"
theme="borderless"
onClick={() => {
onEditPattern();
}}
>
编辑
</Button>
)}
</div>
)}
</div>
);
}
const control =
config.type === 'boolean' ? (
<span className="lx-settings-config__switch">
<span>{raw === 'true' ? '开启' : '关闭'}</span>
<Switch
checked={raw === 'true'}
disabled={readOnly || !config.editable}
aria-label={config.label}
onChange={(checked) => {
onRawChange(checked ? 'true' : 'false');
}}
/>
</span>
) : (
<span className={`lx-settings-config__control${error === null ? '' : ' is-error'}`}>
<Input
type={config.type === 'number' ? 'number' : 'text'}
value={raw}
maxLength={config.type === 'text' ? (config.maxLength ?? 120) : undefined}
disabled={readOnly || !config.editable}
aria-label={config.label}
onChange={onRawChange}
/>
{config.unit === null || config.unit === '' ? null : (
<span className="lx-settings-config__unit">{config.unit}</span>
)}
</span>
);
return (
<div className={`lx-settings-config${dirty ? ' is-dirty' : ''}`}>
<LabelBlock config={config} dirty={dirty} />
<div className="lx-settings-config__value">
{control}
<ValueMeta config={config} error={error} />
</div>
</div>
);
}
@@ -0,0 +1,70 @@
/* eslint-disable max-lines-per-function -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
import { NAMING_TOKEN_DEFINITIONS } from '@lingxi/shared';
import { IconAlert } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { namingPreview } from './config-utils';
export interface NamingAsideProps {
configLabel: string;
raw: string;
maxLength: number;
readOnly: boolean;
onInsertToken: (token: string) => void;
}
/** 命名规则右侧:占位符 chips + shared resolveNamingTemplate 的实时预览与校验 */
export function NamingAside({
configLabel,
raw,
maxLength,
readOnly,
onInsertToken,
}: NamingAsideProps): ReactNode {
const preview = namingPreview(raw, maxLength);
const hasError = preview.error !== null;
return (
<aside className="lx-settings-naming-aside">
<div className="lx-settings-aside-head">
<b>命名占位符</b>
<span>{NAMING_TOKEN_DEFINITIONS.length} 个</span>
</div>
<div className="lx-settings-token-stack">
{NAMING_TOKEN_DEFINITIONS.map((token) => (
<button
key={token.key}
type="button"
className="lx-settings-token"
disabled={readOnly}
title={token.description}
onClick={() => {
onInsertToken(token.token);
}}
>
<code>{token.token}</code>
<span>{token.label}</span>
<span className="lx-settings-token__example">如 {token.example}</span>
</button>
))}
</div>
<div className="lx-settings-preview">
<div className="lx-settings-preview__label">实时预览 · {configLabel}</div>
<div className="lx-settings-preview__value">{preview.resolved ?? '—'}</div>
<div className="lx-settings-preview__meta">
<span>预览客户 花间集</span>
<span className={hasError ? 'is-bad' : 'is-ok'}>
{hasError ? '校验未通过' : '校验通过'}
</span>
<span>
{raw.length} / {maxLength} 字符
</span>
</div>
{hasError ? <div className="lx-settings-preview__error">{preview.error}</div> : null}
</div>
<p className="lx-settings-note">
<IconAlert size={13} />
<span>命名规则只影响新生成任务;已在队列中的任务使用提交时规则(R-01)。</span>
</p>
</aside>
);
}
@@ -0,0 +1,190 @@
/* eslint-disable max-lines-per-function -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
import { Button } from '@douyinfe/semi-ui-19';
import type { SystemConfig, SystemConfigGroupKey } from '@lingxi/shared';
import { PageEmpty } from '@lingxi/ui';
import { IconAlert, IconClock, IconError, IconRefresh, IconView } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import type { ConfigGroupView } from './config-utils';
import { formatConfigTime, latestUpdatedAt, rawValueOf } from './config-utils';
import { ConfigRow } from './ConfigRow';
import { NamingAside } from './NamingAside';
function groupNote(key: SystemConfigGroupKey): ReactNode {
if (key === 'limits') {
return (
<p className="lx-settings-groupnote is-info">
<IconAlert size={14} />
<span>
<b>前端上限、DTO 校验与任务判定读取同一配置。</b>
修改后仅对新数据生效,历史数据保持填写时口径(R-26)。
</span>
</p>
);
}
if (key === 'threshold') {
return (
<p className="lx-settings-groupnote is-warn">
<IconError size={14} />
<span>
<b>M4 爆量判定、质量看板与通知共用同一阈值。</b>
修改后下次判定生效,不回溯历史数据。
</span>
</p>
);
}
return null;
}
export interface SettingsGroupFormProps {
group: ConfigGroupView;
readOnly: boolean;
overrides: Readonly<Record<string, string>>;
errors: Readonly<Record<string, string>>;
dirtyCount: number;
editingPattern: string | null;
activePatternConfig: SystemConfig | undefined;
namingMaxLength: number;
saveError: string | null;
saving: boolean;
onRawChange: (config: SystemConfig, raw: string) => void;
onEditPattern: (config: SystemConfig) => void;
onCollapsePattern: () => void;
onInsertToken: (token: string) => void;
onDiscard: () => void;
onSave: () => void;
onRetrySave: () => void;
}
/** 右侧配置卡片:组头 + 只读条 + 保存失败条 + 配置项 / 命名规则双列 + 底部保存条 */
export function SettingsGroupForm({
group,
readOnly,
overrides,
errors,
dirtyCount,
editingPattern,
activePatternConfig,
namingMaxLength,
saveError,
saving,
onRawChange,
onEditPattern,
onCollapsePattern,
onInsertToken,
onDiscard,
onSave,
onRetrySave,
}: SettingsGroupFormProps): ReactNode {
const latest = latestUpdatedAt(group.items);
const configRows = group.items.map((config) => (
<ConfigRow
key={config.key}
config={config}
raw={overrides[config.key] ?? rawValueOf(config)}
dirty={overrides[config.key] !== undefined}
readOnly={readOnly}
error={errors[config.key] ?? null}
editingPattern={editingPattern === config.key}
namingMaxLength={namingMaxLength}
onRawChange={(raw) => {
onRawChange(config, raw);
}}
onEditPattern={() => {
onEditPattern(config);
}}
onCollapsePattern={onCollapsePattern}
/>
));
return (
<section className="lx-settings-form">
<div className="lx-settings-form__head">
<div>
<h2>{group.name}</h2>
<p>{group.description}</p>
</div>
<div className="lx-settings-form__meta">
<span>
{readOnly ? '只读 · ' : '可编辑 · '}
{group.items.length} 项配置
</span>
<span>最近更新 {formatConfigTime(latest)}</span>
</div>
</div>
{readOnly ? (
<p className="lx-settings-readonlybar">
<IconView size={14} />
<span>当前账号无系统配置权限(system:configure),仅可查看配置与变更记录</span>
</p>
) : null}
{saveError === null ? null : (
<div className="lx-settings-saveerror">
<IconError size={14} />
<span>
<b>保存失败:{saveError}</b>
修改已保留在页面,可点击「重试保存」再次提交。
</span>
<Button size="small" icon={<IconRefresh size={13} />} onClick={onRetrySave}>
重试保存
</Button>
</div>
)}
<div className="lx-settings-form__body">
{group.items.length === 0 ? (
<PageEmpty
title="当前分组暂无配置项"
description="配置项由平台初始化写入;可切换到其他分组查看,或联系超级管理员补充。"
/>
) : null}
{group.items.length === 0 ? null : group.key === 'naming' &&
activePatternConfig !== undefined ? (
<div className="lx-settings-naming-cols">
<div className="lx-settings-configlist">
{groupNote(group.key)}
{configRows}
</div>
<NamingAside
configLabel={activePatternConfig.label}
raw={overrides[activePatternConfig.key] ?? rawValueOf(activePatternConfig)}
maxLength={namingMaxLength}
readOnly={readOnly}
onInsertToken={onInsertToken}
/>
</div>
) : (
<div className="lx-settings-configlist">
{groupNote(group.key)}
{configRows}
</div>
)}
</div>
<div className="lx-settings-form__foot">
<span className={`lx-settings-dirty${dirtyCount > 0 ? ' is-on' : ''}`}>
{dirtyCount > 0 ? <i /> : null}
{dirtyCount > 0 ? `未保存 ${dirtyCount} 项修改` : '所有修改已保存'}
</span>
<span className="lx-settings-form__footnote">
<IconClock size={13} />
保存后版本号 +1,变更记录留前后值
</span>
{readOnly ? null : (
<>
<Button size="small" disabled={dirtyCount === 0} onClick={onDiscard}>
放弃更改
</Button>
<Button
size="small"
type="primary"
theme="solid"
disabled={dirtyCount === 0 || saving}
loading={saving}
onClick={onSave}
>
保存全部更改
</Button>
</>
)}
</div>
</section>
);
}
@@ -0,0 +1,132 @@
/* eslint-disable max-lines-per-function -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
import type { SystemConfigChange, SystemConfigGroupKey } from '@lingxi/shared';
import {
IconChevronRight,
IconExport,
IconNotification,
IconScript,
IconSettings,
IconShield,
IconStack,
} from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { type ConfigGroupView, formatConfigTime, latestUpdatedAt } from './config-utils';
function groupIcon(key: SystemConfigGroupKey): ReactNode {
if (key === 'basic') return <IconSettings size={15} />;
if (key === 'naming') return <IconScript size={15} />;
if (key === 'export') return <IconExport size={15} />;
if (key === 'notify') return <IconNotification size={15} />;
if (key === 'limits') return <IconStack size={15} />;
return <IconShield size={15} />;
}
export interface SettingsNavProps {
groups: readonly ConfigGroupView[];
activeGroup: SystemConfigGroupKey;
totalConfigs: number;
dirtyCountByGroup: Readonly<Record<string, number>>;
recent: readonly SystemConfigChange[];
recentLoading: boolean;
onSelectGroup: (key: SystemConfigGroupKey) => void;
onOpenHistory: (key: string | null) => void;
}
/** 左侧 200 配置分组导航:项数 / 最近更新 / 未保存角标 + 底部最近变更入口 */
export function SettingsNav({
groups,
activeGroup,
totalConfigs,
dirtyCountByGroup,
recent,
recentLoading,
onSelectGroup,
onOpenHistory,
}: SettingsNavProps): ReactNode {
return (
<aside className="lx-settings-nav" aria-label="配置分组">
<div className="lx-settings-nav__head">
<b>配置分组</b>
<span>
{groups.length} 组 · {totalConfigs} 项配置
</span>
</div>
<div className="lx-settings-nav__list">
{groups.map((group) => {
const latest = latestUpdatedAt(group.items);
const dirty = dirtyCountByGroup[group.key] ?? 0;
return (
<button
key={group.key}
type="button"
className={`lx-settings-nav__item${group.key === activeGroup ? ' is-active' : ''}`}
aria-current={group.key === activeGroup ? 'true' : undefined}
onClick={() => {
onSelectGroup(group.key);
}}
>
<span className="lx-settings-nav__icon">{groupIcon(group.key)}</span>
<span className="lx-settings-nav__main">
<b>{group.name}</b>
<span>
{group.items.length} 项 · {latest === null ? '—' : formatConfigTime(latest)}
</span>
</span>
{dirty > 0 ? <i className="lx-settings-nav__dirty">{dirty}</i> : null}
</button>
);
})}
</div>
<div className="lx-settings-nav__recent">
<div className="lx-settings-nav__recent-head">
<b>最近变更</b>
<button
type="button"
className="lx-settings-link"
onClick={() => {
onOpenHistory(null);
}}
>
查看全部
</button>
</div>
{recentLoading ? (
<div className="lx-settings-nav__recent-loading" aria-hidden="true">
<span />
<span />
<span />
</div>
) : null}
{!recentLoading && recent.length === 0 ? (
<p className="lx-settings-nav__recent-empty">暂无变更记录</p>
) : null}
{!recentLoading
? recent.slice(0, 4).map((change) => (
<button
key={`${change.key}-${String(change.version)}`}
type="button"
className="lx-settings-nav__recent-row"
onClick={() => {
onOpenHistory(change.key);
}}
>
<span className="lx-settings-nav__dot" />
<span className="lx-settings-nav__recent-main">
<b>{change.label}</b>
<span>
{change.operator ?? '—'} · {formatConfigTime(change.changedAt)}
</span>
</span>
<IconChevronRight size={13} />
</button>
))
: null}
</div>
<div className="lx-settings-nav__foot">
<IconShield size={12} />
system:configure 权限控制
</div>
</aside>
);
}
@@ -0,0 +1,308 @@
/* eslint-disable max-lines-per-function, complexity -- 系统配置页编排草稿、校验、二次确认、保存与历史抽屉,集中便于追踪数据流 */
import { Button, Modal } from '@douyinfe/semi-ui-19';
import { Permission, type SystemConfig } from '@lingxi/shared';
import { ErrorState, notifySuccess, notifyWarning, PageSkeleton } from '@lingxi/ui';
import { IconCheck, IconDocs } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import {
useSystemConfigHistoryQuery,
useSystemConfigsQuery,
useUpdateSystemConfigsMutation,
} from '@/hooks/queries/use-configs';
import { usePermission } from '@/hooks/use-permission';
import {
collectConfigChanges,
type ConfigChangeDraft,
configGroupViews,
configValueText,
dirtyConfigKeys,
hasGuardedChange,
isConfigDirty,
isGuardedConfig,
rawValueOf,
validateConfigRaw,
} from './config-utils';
import { ConfigHistorySheet } from './ConfigHistorySheet';
import { SettingsGroupForm } from './SettingsGroupForm';
import { SettingsNav } from './SettingsNav';
/** 系统配置(方案 A:左 200 分组导航 + 右表单 + 底部保存条 + 变更记录抽屉) */
export function SettingsPage(): ReactNode {
const [searchParams, setSearchParams] = useSearchParams();
const canWrite = usePermission(Permission.SYSTEM_CONFIGURE);
const configsQuery = useSystemConfigsQuery();
const recentQuery = useSystemConfigHistoryQuery({ limit: 5 });
const updateConfigs = useUpdateSystemConfigsMutation();
const [overrides, setOverrides] = useState<Record<string, string>>({});
const [editingPattern, setEditingPattern] = useState<string | null>(null);
const [previewKey, setPreviewKey] = useState<string>('naming.script.pattern');
const [historyOpen, setHistoryOpen] = useState(false);
const [historyKey, setHistoryKey] = useState<string | null>(null);
const [confirmChanges, setConfirmChanges] = useState<ConfigChangeDraft[] | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const configs = useMemo(() => configsQuery.data?.items ?? [], [configsQuery.data]);
const groups = useMemo(() => configGroupViews(configs), [configs]);
const readOnly = !canWrite;
const groupParam = searchParams.get('group');
const activeGroup =
groups.find((group) => group.key === groupParam) ??
groups.find((group) => group.key === 'naming') ??
groups[0];
const errors = useMemo(() => {
const result: Record<string, string> = {};
for (const config of configs) {
const raw = overrides[config.key];
if (raw === undefined) continue;
const message = validateConfigRaw(config, raw);
if (message !== null) result[config.key] = message;
}
return result;
}, [configs, overrides]);
const dirtyKeys = useMemo(() => dirtyConfigKeys(configs, overrides), [configs, overrides]);
const dirtyCountByGroup = useMemo(() => {
const result: Record<string, number> = {};
for (const key of dirtyKeys) {
const config = configs.find((candidate) => candidate.key === key);
if (config === undefined) continue;
result[config.group] = (result[config.group] ?? 0) + 1;
}
return result;
}, [configs, dirtyKeys]);
const namingGroup = groups.find((group) => group.key === 'naming');
const patternConfigs = (namingGroup?.items ?? []).filter((config) => config.type === 'pattern');
const activePatternConfig =
patternConfigs.find((config) => config.key === (editingPattern ?? previewKey)) ??
patternConfigs[0];
const maxLengthConfig = configs.find((config) => config.key === 'naming.max_length');
const maxLengthRaw =
maxLengthConfig === undefined
? '100'
: (overrides['naming.max_length'] ?? rawValueOf(maxLengthConfig));
const parsedMaxLength = Number(maxLengthRaw);
const namingMaxLength =
Number.isFinite(parsedMaxLength) && parsedMaxLength > 0 ? parsedMaxLength : 100;
const selectGroup = (key: string): void => {
const next = new URLSearchParams(searchParams);
next.set('group', key);
setSearchParams(next, { replace: true });
setEditingPattern(null);
};
const openHistory = (key: string | null): void => {
setHistoryKey(key);
setHistoryOpen(true);
};
const changeRaw = (config: SystemConfig, raw: string): void => {
setOverrides((current) => {
if (!isConfigDirty(config, raw)) {
const { [config.key]: _removed, ...rest } = current;
return rest;
}
return { ...current, [config.key]: raw };
});
if (config.type === 'pattern') setPreviewKey(config.key);
setSaveError(null);
};
const insertToken = (token: string): void => {
if (activePatternConfig === undefined) return;
const current = overrides[activePatternConfig.key] ?? rawValueOf(activePatternConfig);
changeRaw(activePatternConfig, current + token);
setEditingPattern(activePatternConfig.key);
setPreviewKey(activePatternConfig.key);
};
const runSave = async (changes: ConfigChangeDraft[]): Promise<void> => {
setSaveError(null);
try {
await updateConfigs.mutateAsync({
changes: changes.map((change) => ({
key: change.config.key,
value: change.value,
expectedVersion: change.config.version,
reason: '在系统配置页调整',
})),
});
setOverrides({});
setConfirmChanges(null);
setEditingPattern(null);
notifySuccess('配置已保存,版本已更新');
} catch (cause) {
setConfirmChanges(null);
setSaveError(cause instanceof Error ? cause.message : '保存失败,请稍后重试');
}
};
const attemptSave = (): void => {
if (readOnly) return;
const result = collectConfigChanges(configs, overrides);
if (Object.keys(result.errors).length > 0) {
notifyWarning('有配置项校验未通过,请检查后重试');
return;
}
if (result.changes.length === 0) {
notifyWarning('没有需要保存的修改');
return;
}
if (hasGuardedChange(result.changes)) {
setConfirmChanges(result.changes);
return;
}
void runSave(result.changes);
};
const guardedChanges = (confirmChanges ?? []).filter((change) => isGuardedConfig(change.config));
return (
<div className="lx-settings-page">
<header className="lx-settings-page__header">
<div>
<h1>系统配置</h1>
<p>阈值、上限与命名规则的唯一来源,修改即时影响新数据</p>
</div>
<div className="lx-settings-page__actions">
<span className={`lx-settings-headindicator${dirtyKeys.length > 0 ? ' is-on' : ''}`}>
{readOnly
? '只读权限'
: dirtyKeys.length > 0
? `未保存 ${dirtyKeys.length} 项`
: '所有修改已保存'}
</span>
<Button
icon={<IconDocs size={15} />}
onClick={() => {
openHistory(null);
}}
>
变更记录
</Button>
{readOnly ? null : (
<Button
type="primary"
theme="solid"
icon={<IconCheck size={15} />}
disabled={dirtyKeys.length === 0}
onClick={attemptSave}
>
保存全部更改
</Button>
)}
</div>
</header>
{configsQuery.isPending ? <PageSkeleton rows={8} /> : null}
{!configsQuery.isPending && configsQuery.error !== null ? (
<ErrorState
title="系统配置加载失败"
message={configsQuery.error.message}
onRetry={() => {
void configsQuery.refetch();
}}
/>
) : null}
{!configsQuery.isPending && configsQuery.error === null ? (
<div className="lx-settings-split">
<SettingsNav
groups={groups}
activeGroup={activeGroup?.key ?? 'basic'}
totalConfigs={configs.length}
dirtyCountByGroup={dirtyCountByGroup}
recent={recentQuery.data?.items ?? []}
recentLoading={recentQuery.isPending}
onSelectGroup={selectGroup}
onOpenHistory={openHistory}
/>
{activeGroup === undefined ? null : (
<SettingsGroupForm
group={activeGroup}
readOnly={readOnly}
overrides={overrides}
errors={errors}
dirtyCount={dirtyKeys.length}
editingPattern={editingPattern}
activePatternConfig={activePatternConfig}
namingMaxLength={namingMaxLength}
saveError={saveError}
saving={updateConfigs.isPending}
onRawChange={changeRaw}
onEditPattern={(config) => {
setEditingPattern(config.key);
setPreviewKey(config.key);
}}
onCollapsePattern={() => {
setEditingPattern(null);
}}
onInsertToken={insertToken}
onDiscard={() => {
setOverrides({});
setEditingPattern(null);
setSaveError(null);
}}
onSave={attemptSave}
onRetrySave={attemptSave}
/>
)}
</div>
) : null}
<Modal
visible={confirmChanges !== null}
title="确认修改阈值与上限?"
width={520}
okText="确认并保存"
cancelText="取消"
confirmLoading={updateConfigs.isPending}
onCancel={() => {
setConfirmChanges(null);
}}
onOk={() => {
if (confirmChanges !== null) void runSave(confirmChanges);
}}
>
<div className="lx-settings-confirm">
<p>
本次有 <b>{guardedChanges.length}</b> 项阈值 / 上限配置变更,保存后版本号递增。
</p>
<ul className="lx-settings-confirm__impact">
<li>前端表单上限、DTO 校验、任务判定 / 队列读取同一配置来源(R-26)</li>
<li>修改后仅对新数据生效,历史数据保持填写时口径</li>
<li>变更记录会留存前后值、操作人与时间</li>
</ul>
<div className="lx-settings-confirm__list">
{guardedChanges.map((change) => (
<div key={change.config.key} className="lx-settings-confirm__row">
<span>{change.config.label}</span>
<b>
{configValueText(change.config, change.oldValue)}
<i>→</i>
{configValueText(change.config, change.value)}
</b>
</div>
))}
</div>
</div>
</Modal>
<ConfigHistorySheet
open={historyOpen}
filterKey={historyKey}
configs={configs}
onFilterChange={setHistoryKey}
onClose={() => {
setHistoryOpen(false);
}}
/>
</div>
);
}
@@ -0,0 +1,178 @@
import { SYSTEM_CONFIG_GROUPS, type SystemConfig, systemConfigDefinition } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import {
collectConfigChanges,
configGroupViews,
configKeyLabel,
configValueText,
dirtyConfigKeys,
formatConfigValue,
hasGuardedChange,
isConfigDirty,
isGuardedConfig,
latestUpdatedAt,
NAMING_SAMPLE_CONTEXT,
namingPreview,
parseConfigRaw,
rangeTextOf,
rawValueOf,
validateConfigRaw,
} from './config-utils';
const NOW = '2026-11-04T10:00:00.000Z';
function config(overrides: Partial<SystemConfig> & { key: string }): SystemConfig {
const definition = systemConfigDefinition(overrides.key);
const type = overrides.type ?? definition?.type ?? 'text';
return {
group: definition?.group ?? 'basic',
label: definition?.label ?? overrides.key,
description: definition?.description ?? '',
impact: definition?.impact ?? '',
type,
value: definition?.defaultValue ?? '',
valueType: type === 'number' ? 'number' : type === 'boolean' ? 'boolean' : 'string',
unit: definition?.unit ?? null,
version: 1,
updatedAt: NOW,
updatedBy: '江岚',
editable: true,
isPublic: definition?.public ?? false,
min: definition?.min ?? null,
max: definition?.max ?? null,
step: definition?.step ?? null,
maxLength: definition?.maxLength ?? null,
...overrides,
};
}
describe('系统配置分组与草稿', () => {
it('按 6 个固定分组渲染,组内按定义 sortOrder 排序', () => {
const configs = [
config({ key: 'naming.max_length', value: 100 }),
config({ key: 'naming.script.pattern', value: '{行业}-{序号}' }),
config({ key: 'platform.name', value: '灵犀' }),
];
const groups = configGroupViews(configs);
expect(groups.map((group) => group.key)).toEqual(
SYSTEM_CONFIG_GROUPS.map((group) => group.key),
);
expect(groups.find((group) => group.key === 'naming')?.items.map((item) => item.key)).toEqual([
'naming.script.pattern',
'naming.max_length',
]);
});
it('原始值 / 解析 / 脏检查:数字、布尔与文本', () => {
const numberConfig = config({ key: 'limit.customer.base_field', value: 10 });
expect(rawValueOf(numberConfig)).toBe('10');
expect(parseConfigRaw(numberConfig, '12')).toBe(12);
expect(parseConfigRaw(numberConfig, '')).toBeNull();
expect(isConfigDirty(numberConfig, '10')).toBe(false);
expect(isConfigDirty(numberConfig, '12')).toBe(true);
expect(isConfigDirty(numberConfig, '')).toBe(true);
const booleanConfig = config({ key: 'notify.task.completed', value: true });
expect(rawValueOf(booleanConfig)).toBe('true');
expect(parseConfigRaw(booleanConfig, 'false')).toBe(false);
const textConfig = config({ key: 'platform.name', value: '灵犀' });
expect(isConfigDirty(textConfig, '灵犀 Pro')).toBe(true);
expect(isConfigDirty(textConfig, '灵犀')).toBe(false);
});
it('行内校验复用 shared validateSystemConfigValue(范围 / 模板 / 长度)', () => {
const limit = config({ key: 'limit.customer.base_field', value: 10 });
expect(validateConfigRaw(limit, '12')).toBeNull();
expect(validateConfigRaw(limit, '0')).toContain('不能小于');
expect(validateConfigRaw(limit, '51')).toContain('不能大于');
expect(validateConfigRaw(limit, '')).toBe('请输入有效数字');
const pattern = config({ key: 'naming.script.pattern', value: '{行业}-{序号}' });
expect(validateConfigRaw(pattern, '{行业}-{客户}-{序号}')).toBeNull();
expect(validateConfigRaw(pattern, '{行业}*{序号}')).toContain('只能包含');
expect(validateConfigRaw(pattern, '{未知}')).toContain('未识别');
expect(validateConfigRaw(pattern, `{行业}${'x'.repeat(120)}`)).toContain('不能超过');
});
it('收集变更:跳过未改动项,返回脏项错误', () => {
const configs = [
config({ key: 'platform.name', value: '灵犀' }),
config({ key: 'limit.customer.base_field', value: 10 }),
config({ key: 'limit.customer.gen_field', value: 5 }),
];
const result = collectConfigChanges(configs, {
'platform.name': '灵犀 Pro',
'limit.customer.base_field': '12',
'limit.customer.gen_field': '0',
});
expect(result.changes.map((change) => change.config.key)).toEqual([
'platform.name',
'limit.customer.base_field',
]);
expect(result.errors['limit.customer.gen_field']).toContain('不能小于');
const noChange = collectConfigChanges(configs, { 'platform.name': '灵犀' });
expect(noChange.changes).toEqual([]);
expect(noChange.errors).toEqual({});
});
it('阈值 / 上限类配置需要二次确认', () => {
const guarded = config({ key: 'limit.customer.base_field', value: 10 });
const plain = config({ key: 'platform.name', value: '灵犀' });
expect(isGuardedConfig(guarded)).toBe(true);
expect(isGuardedConfig(plain)).toBe(false);
const changes = collectConfigChanges([guarded, plain], {
'limit.customer.base_field': '12',
'platform.name': '灵犀 Pro',
});
expect(hasGuardedChange(changes.changes)).toBe(true);
expect(dirtyConfigKeys([guarded, plain], { 'platform.name': '灵犀 Pro' })).toEqual([
'platform.name',
]);
});
it('展示格式化:单位 / 开关 / 未知值', () => {
const limit = config({ key: 'limit.customer.base_field', value: 10 });
expect(configValueText(limit, 12)).toBe('12 个');
expect(formatConfigValue(null)).toBe('未配置');
expect(formatConfigValue(true, { type: 'boolean' })).toBe('开启');
expect(formatConfigValue(false, { type: 'boolean' })).toBe('关闭');
expect(configKeyLabel('limit.customer.base_field')).toBe('客户基础字段上限');
expect(configKeyLabel('unknown.key')).toBe('unknown.key');
expect(rangeTextOf(limit)).toBe('范围 1–50 个');
expect(latestUpdatedAt([{ updatedAt: '2026-11-01T00:00:00.000Z' }, { updatedAt: NOW }])).toBe(
NOW,
);
});
});
describe('命名规则预览(shared resolveNamingTemplate)', () => {
it('默认模板解析为示例文件名', () => {
const result = namingPreview('{行业}-{客户}-{脚本方向}-{日期}-{序号}');
expect(result.error).toBeNull();
expect(result.resolved).toBe('电商-花间集-口播-20261104-01');
expect(result.check.tokens).toEqual(['industry', 'customer', 'direction', 'date', 'sequence']);
});
it('未知占位符 / 非法字符 / 超长给出错误', () => {
expect(namingPreview('{行业}-{未知}').error).toContain('未识别');
expect(namingPreview('{行业}*{序号}').error).toContain('只能包含');
expect(namingPreview(`{行业}${'x'.repeat(120)}`).error).toContain('不能超过');
});
it('示例 context 覆盖 6 个占位符', () => {
expect(NAMING_SAMPLE_CONTEXT).toMatchObject({
industry: '电商',
customer: '花间集',
direction: '口播',
date: '20261104',
sequence: '01',
spec: '1080x1920',
});
expect(namingPreview('{行业}_{客户}_{日期}_{序号}_{规格}').resolved).toBe(
'电商_花间集_20261104_01_1080x1920',
);
});
});
@@ -0,0 +1,240 @@
/**
* 系统配置页纯函数:分组、草稿校验、命名模板预览与展示格式化。
*
* R-26:校验与保存共用 shared 的 `validateSystemConfigValue` / `validateNamingTemplate`,
* 页面不再维护第二套阈值 / 模板规则。
*/
import {
type NamingContext,
type NamingTemplateCheck,
resolveNamingTemplate,
SYSTEM_CONFIG_GROUPS,
type SystemConfig,
systemConfigDefinition,
type SystemConfigGroupKey,
type SystemConfigValue,
validateNamingTemplate,
validateSystemConfigValue,
} from '@lingxi/shared';
export interface ConfigGroupView {
key: SystemConfigGroupKey;
name: string;
description: string;
items: SystemConfig[];
}
function itemSortOrder(config: SystemConfig): number {
return systemConfigDefinition(config.key)?.sortOrder ?? 9_999;
}
/** 按 6 个固定分组渲染,组内按配置定义排序;空分组保留(页面展示空态) */
export function configGroupViews(configs: readonly SystemConfig[]): ConfigGroupView[] {
return [...SYSTEM_CONFIG_GROUPS]
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((group) => ({
key: group.key,
name: group.name,
description: group.description,
items: configs
.filter((config) => config.group === group.key)
.sort((left, right) => itemSortOrder(left) - itemSortOrder(right)),
}));
}
export function configKeyLabel(key: string): string {
return systemConfigDefinition(key)?.label ?? key;
}
/** 原始值转输入框字符串(布尔用 'true' / 'false') */
export function rawValueOf(config: SystemConfig): string {
if (typeof config.value === 'boolean') return config.value ? 'true' : 'false';
return String(config.value);
}
/** 输入框字符串按类型解析;非法输入返回 null(不抛错,用于行内校验) */
export function parseConfigRaw(config: SystemConfig, raw: string): SystemConfigValue | null {
if (config.type === 'boolean') {
if (raw === 'true') return true;
if (raw === 'false') return false;
return null;
}
if (config.type === 'number') {
const trimmed = raw.trim();
if (trimmed === '') return null;
const value = Number(trimmed);
return Number.isFinite(value) ? value : null;
}
return raw;
}
export function isConfigDirty(config: SystemConfig, raw: string): boolean {
const parsed = parseConfigRaw(config, raw);
if (parsed === null) return true;
return parsed !== config.value;
}
export function dirtyConfigKeys(
configs: readonly SystemConfig[],
overrides: Readonly<Record<string, string>>,
): string[] {
return configs
.filter((config) => {
const raw = overrides[config.key];
return raw !== undefined && isConfigDirty(config, raw);
})
.map((config) => config.key);
}
/** 行内校验:优先用 shared 定义校验,未知 key 按响应类型兜底 */
export function validateConfigRaw(config: SystemConfig, raw: string): string | null {
const parsed = parseConfigRaw(config, raw);
if (parsed === null) {
return config.type === 'number' ? '请输入有效数字' : '请输入合法值';
}
const definition = systemConfigDefinition(config.key);
if (definition !== undefined) {
const result = validateSystemConfigValue(definition, parsed);
return result.ok ? null : result.message;
}
if (config.type === 'pattern') {
const check = validateNamingTemplate(String(parsed), { maxLength: config.maxLength ?? 100 });
return check.valid ? null : check.errors.join(';');
}
if (typeof parsed === 'string' && config.maxLength !== null && parsed.length > config.maxLength) {
return `配置值不能超过 ${config.maxLength} 个字符`;
}
return null;
}
export interface ConfigChangeDraft {
config: SystemConfig;
value: SystemConfigValue;
oldValue: SystemConfigValue;
}
export interface CollectConfigChangesResult {
changes: ConfigChangeDraft[];
errors: Record<string, string>;
}
/** 保存前收集脏项 + 校验;错误按 key 返回给行内提示,失败时保留草稿可重试 */
export function collectConfigChanges(
configs: readonly SystemConfig[],
overrides: Readonly<Record<string, string>>,
): CollectConfigChangesResult {
const changes: ConfigChangeDraft[] = [];
const errors: Record<string, string> = {};
for (const config of configs) {
const raw = overrides[config.key];
if (raw === undefined) continue;
if (!isConfigDirty(config, raw)) continue;
const error = validateConfigRaw(config, raw);
if (error !== null) {
errors[config.key] = error;
continue;
}
const parsed = parseConfigRaw(config, raw);
if (parsed === null) continue;
changes.push({ config, value: parsed, oldValue: config.value });
}
return { changes, errors };
}
/** 阈值 / 上限类配置修改必须先二次确认(R-26) */
export function isGuardedConfig(config: SystemConfig): boolean {
return config.group === 'limits' || config.group === 'threshold';
}
export function hasGuardedChange(changes: readonly ConfigChangeDraft[]): boolean {
return changes.some((change) => isGuardedConfig(change.config));
}
export function latestUpdatedAt(items: readonly { updatedAt: string }[]): string | null {
let latest: string | null = null;
for (const item of items) {
if (latest === null || item.updatedAt > latest) latest = item.updatedAt;
}
return latest;
}
export function formatConfigValue(
value: unknown,
options: { type?: SystemConfig['type']; unit?: string | null } = {},
): string {
if (value === null || value === undefined) return '未配置';
if (options.type === 'boolean') return value === true || value === 'true' ? '开启' : '关闭';
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
return '—';
}
if (options.unit !== null && options.unit !== undefined && options.unit !== '') {
return `${String(value)} ${options.unit}`;
}
return String(value);
}
export function configValueText(config: SystemConfig, value: unknown): string {
return formatConfigValue(value, { type: config.type, unit: config.unit });
}
/** MM-DD HH:mm(系统配置页统一时间展示) */
export function formatConfigTime(iso: string | null): string {
if (iso === null || iso === '') return '—';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '—';
const pad = (value: number): string => String(value).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
/** 数值范围 / 模板长度提示 */
export function rangeTextOf(config: SystemConfig): string {
if (config.type === 'number' && (config.min !== null || config.max !== null)) {
const min = config.min ?? '';
const max = config.max ?? '';
return `范围 ${String(min)}–${String(max)}${config.unit === null ? '' : ` ${config.unit}`}`;
}
if (config.type === 'pattern') {
return `≤ ${config.maxLength ?? 100} 字符`;
}
return '';
}
// ---------------------------------------------------------------------------
// 命名规则预览(与保存前校验共用 shared 函数)
// ---------------------------------------------------------------------------
/** 预览用示例 context:电商 / 花间集 / 口播 / 20261104 / 01 / 1080x1920 */
export const NAMING_SAMPLE_CONTEXT: NamingContext = {
industry: '电商',
customer: '花间集',
direction: '口播',
date: '20261104',
sequence: '01',
spec: '1080x1920',
};
export interface NamingPreviewResult {
check: NamingTemplateCheck;
resolved: string | null;
error: string | null;
}
export function namingPreview(pattern: string, maxLength = 100): NamingPreviewResult {
const check = validateNamingTemplate(pattern, { maxLength });
if (!check.valid) {
return { check, resolved: null, error: check.errors.join(';') };
}
try {
return {
check,
resolved: resolveNamingTemplate(pattern, NAMING_SAMPLE_CONTEXT, { maxLength }),
error: null,
};
} catch (cause) {
return {
check,
resolved: null,
error: cause instanceof Error ? cause.message : '命名预览失败',
};
}
}
+34
View File
@@ -74,6 +74,16 @@ const CustomerFieldsPage = lazy(async () => {
return { default: module.CustomerFieldsPage };
});
const DictsPage = lazy(async () => {
const module = await import('@/pages/system/dicts/DictsPage');
return { default: module.DictsPage };
});
const SettingsPage = lazy(async () => {
const module = await import('@/pages/system/settings/SettingsPage');
return { default: module.SettingsPage };
});
function withSkeleton(node: ReactNode): ReactNode {
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
}
@@ -212,6 +222,30 @@ export const router = createBrowserRouter([
</RequirePermission>
),
},
{
path: 'system/dicts',
element: (
<RequirePermission permission={Permission.DICT_MANAGE}>
{withSkeleton(<DictsPage />)}
</RequirePermission>
),
},
{
path: 'system/dicts/:type',
element: (
<RequirePermission permission={Permission.DICT_MANAGE}>
{withSkeleton(<DictsPage />)}
</RequirePermission>
),
},
{
path: 'system/settings',
element: (
<RequirePermission permission={Permission.SYSTEM_CONFIGURE}>
{withSkeleton(<SettingsPage />)}
</RequirePermission>
),
},
{
path: ':moduleKey/:itemKey?',
lazy: async () => ({
+11
View File
@@ -22,6 +22,9 @@ export const ROUTES = {
customerDetail: '/system/customers/:id',
customerGroups: '/system/customer-groups',
customerFields: '/system/customer-fields/approvals',
dicts: '/system/dicts',
dictDetail: '/system/dicts/:type',
settings: '/system/settings',
},
unauthorized: '/401',
forbidden: '/403',
@@ -46,6 +49,9 @@ export type RoutePath =
| typeof ROUTES.system.customerDetail
| typeof ROUTES.system.customerGroups
| typeof ROUTES.system.customerFields
| typeof ROUTES.system.dicts
| typeof ROUTES.system.dictDetail
| typeof ROUTES.system.settings
| typeof ROUTES.unauthorized
| typeof ROUTES.forbidden
| typeof ROUTES.notFound
@@ -66,6 +72,11 @@ export function customerDetailPath(customerId: string): string {
return `${ROUTES.system.customers}/${encodeURIComponent(customerId)}`;
}
/** 字典类型详情深链:`/system/dicts/{typeCode}` */
export function dictDetailPath(typeCode: string): string {
return `${ROUTES.system.dicts}/${encodeURIComponent(typeCode)}`;
}
/** 新建客户(四步向导) */
export function customerNewPath(): string {
return ROUTES.system.customerNew;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff