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

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

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
import type {
DictItem,
DictItemListResponse,
DictTypeDetail,
DictTypeSummary,
} from '@lingxi/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { setUnauthorizedHandler } from './client';
import {
createDictItem,
createDictType,
deleteDictType,
fetchDictItemList,
fetchDictTypeDetail,
fetchDictTypeList,
setDictTypeStatus,
updateDictItem,
updateDictType,
} from './dicts';
const NOW = '2026-11-04T10:00:00.000Z';
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(status === 204 ? null : JSON.stringify(body), {
status,
headers: status === 204 ? undefined : { 'Content-Type': 'application/json' },
});
const typeSummary: DictTypeSummary = {
code: 'script_direction',
name: '脚本方向',
group: 'content',
kind: 'list',
description: '脚本“说什么”的方向',
industryScoped: true,
builtin: true,
status: 'active',
sortOrder: 60,
itemCount: 11,
activeItemCount: 11,
industryCount: 4,
updatedAt: NOW,
updatedBy: '江岚',
};
const item: DictItem = {
id: ID,
typeCode: 'script_direction',
code: 'oral',
name: '口播',
parentCode: null,
parentName: null,
industryId: null,
industryPath: null,
sortOrder: 10,
status: 'active',
remark: '',
source: 'generic',
overridden: false,
refCount: 15,
updatedAt: NOW,
updatedBy: '江岚',
};
const typeDetail: DictTypeDetail = { ...typeSummary, items: [item] };
const itemList: DictItemListResponse = {
items: [item],
next_cursor: null,
layers: [
{ source: 'generic', industryId: null, label: '通用', count: 5 },
{ source: 'parent', industryId: ID, label: '父行业', count: 2 },
{ source: 'current', industryId: ID, label: '本行业', count: 1 },
],
effectiveCount: 7,
overriddenCount: 1,
disabledCount: 0,
};
function lastCall(): { url: string; init: RequestInit | undefined } {
const fetchMock = vi.mocked(globalThis.fetch);
const call = fetchMock.mock.calls.at(-1);
const input = call?.[0];
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : (input?.url ?? '');
return { url, init: call?.[1] };
}
function lastBody(): unknown {
const body = lastCall().init?.body;
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
}
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
useAuthStore.getState().clearSession();
setUnauthorizedHandler(null);
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: ID, name: '江岚', account: 'jiang.lan' },
permissions: ['dict:manage'],
});
});
describe('数据字典接口封装', () => {
it('类型列表:query 用 snake_case(keyword / group / status / limit / cursor)', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
jsonResponse({ items: [typeSummary], next_cursor: 'cursor-2' }),
);
const result = await fetchDictTypeList({
keyword: '脚本',
group: 'content',
status: 'active',
limit: 100,
cursor: 'cursor-1',
});
expect(result.next_cursor).toBe('cursor-2');
expect(lastCall().url).toContain('/dicts?keyword=');
expect(lastCall().url).toContain('group=content');
expect(lastCall().url).toContain('status=active');
expect(lastCall().url).toContain('limit=100');
expect(lastCall().url).toContain('cursor=cursor-1');
});
it('类型详情 / 新建 / 更新 / 状态 / 删除', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse(typeDetail))
.mockResolvedValueOnce(jsonResponse(typeDetail, 201))
.mockResolvedValueOnce(jsonResponse(typeDetail))
.mockResolvedValueOnce(jsonResponse(typeDetail))
.mockResolvedValueOnce(jsonResponse(undefined, 204));
await fetchDictTypeDetail('script_direction');
expect(lastCall().url).toContain('/dicts/script_direction');
await createDictType({
code: 'delivery_slot',
name: '投放位置',
group: 'platform',
kind: 'list',
industryScoped: false,
description: '',
sortOrder: 10,
});
expect(lastCall().init?.method).toBe('POST');
expect(lastBody()).toMatchObject({ code: 'delivery_slot', industryScoped: false });
await updateDictType('script_direction', { name: '脚本方向2' });
expect(lastCall().init?.method).toBe('PATCH');
expect(lastBody()).toEqual({ name: '脚本方向2' });
await setDictTypeStatus('script_direction', { status: 'disabled' });
expect(lastCall().url).toContain('/dicts/script_direction/status');
expect(lastBody()).toEqual({ status: 'disabled' });
await deleteDictType('script_direction');
expect(lastCall().init?.method).toBe('DELETE');
});
it('条目列表:industry_id 只在传入行业视角时出现', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse(itemList))
.mockResolvedValueOnce(jsonResponse(itemList));
await fetchDictItemList('script_direction', { industry_id: ID });
expect(lastCall().url).toContain('/dicts/script_direction/items?industry_id=');
await fetchDictItemList('script_direction');
expect(lastCall().url).not.toContain('industry_id');
});
it('新建 / 编辑条目:body 用 camelCase,编辑不提交编码与行业', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse(item, 201))
.mockResolvedValueOnce(jsonResponse(item));
await createDictItem('script_direction', {
code: 'oral',
name: '口播',
parentCode: null,
industryId: null,
sortOrder: 10,
status: 'active',
remark: '',
});
expect(lastCall().init?.method).toBe('POST');
expect(lastBody()).toMatchObject({ code: 'oral', parentCode: null, industryId: null });
await updateDictItem('script_direction', ID, {
name: '口播(新)',
sortOrder: 20,
status: 'disabled',
remark: '说明',
});
expect(lastCall().url).toContain(`/dicts/script_direction/items/${ID}`);
expect(lastCall().init?.method).toBe('PATCH');
expect(lastBody()).toEqual({
name: '口播(新)',
sortOrder: 20,
status: 'disabled',
remark: '说明',
});
});
});