背景:用户选定方案 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 无溢出。
210 lines
6.3 KiB
TypeScript
210 lines
6.3 KiB
TypeScript
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: '说明',
|
||
});
|
||
});
|
||
});
|