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,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: '说明',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user