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:
1 parent
e4330a43e4
commit
3b0a99d91f
36 files changed
+7691
-15
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: '说明',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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'] }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 : '命名预览失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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
Reference in new issue
Block a user