From 3b0a99d91f36222d978d6bce88bee04e39acbc6f Mon Sep 17 00:00:00 2001 From: Stev_Wang <304865932@qq.com> Date: Wed, 4 Nov 2026 11:49:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20A4=20=E6=95=B0=E6=8D=AE=E5=AD=97?= =?UTF-8?q?=E5=85=B8=E4=B8=8E=E7=B3=BB=E7=BB=9F=E9=85=8D=E7=BD=AE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:用户选定方案 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 无溢出。 --- apps/web/src/api/configs.spec.ts | 121 ++ apps/web/src/api/configs.ts | 68 + apps/web/src/api/dicts.spec.ts | 209 +++ apps/web/src/api/dicts.ts | 133 ++ apps/web/src/api/endpoints.ts | 12 + apps/web/src/api/generated/openapi.d.ts | 362 +++++ apps/web/src/api/query-keys.ts | 9 + apps/web/src/hooks/queries/use-configs.ts | 108 ++ apps/web/src/hooks/queries/use-dicts.ts | 160 ++ apps/web/src/layout/AppLayout.tsx | 7 +- apps/web/src/main.tsx | 2 + .../customer-fields/CustomerFieldsPage.tsx | 4 +- .../system/customers/CustomerDetailPage.tsx | 31 +- .../pages/system/customers/CustomersPage.tsx | 10 +- .../pages/system/dicts/DictDetailPanel.tsx | 461 ++++++ .../src/pages/system/dicts/DictGroupIcon.tsx | 16 + .../src/pages/system/dicts/DictItemSheet.tsx | 310 ++++ .../src/pages/system/dicts/DictItemsView.tsx | 283 ++++ apps/web/src/pages/system/dicts/DictTags.tsx | 42 + .../pages/system/dicts/DictTypeListPanel.tsx | 235 +++ .../src/pages/system/dicts/DictTypeModal.tsx | 306 ++++ apps/web/src/pages/system/dicts/DictsPage.tsx | 350 +++++ .../src/pages/system/dicts/dict-utils.spec.ts | 265 ++++ apps/web/src/pages/system/dicts/dict-utils.ts | 346 +++++ .../system/settings/ConfigHistorySheet.tsx | 154 ++ .../src/pages/system/settings/ConfigRow.tsx | 154 ++ .../src/pages/system/settings/NamingAside.tsx | 70 + .../system/settings/SettingsGroupForm.tsx | 190 +++ .../src/pages/system/settings/SettingsNav.tsx | 132 ++ .../pages/system/settings/SettingsPage.tsx | 308 ++++ .../system/settings/config-utils.spec.ts | 178 +++ .../src/pages/system/settings/config-utils.ts | 240 +++ apps/web/src/router.tsx | 34 + apps/web/src/routes.ts | 11 + apps/web/src/styles/dicts.css | 1306 +++++++++++++++++ apps/web/src/styles/settings.css | 1079 ++++++++++++++ 36 files changed, 7691 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/api/configs.spec.ts create mode 100644 apps/web/src/api/configs.ts create mode 100644 apps/web/src/api/dicts.spec.ts create mode 100644 apps/web/src/api/dicts.ts create mode 100644 apps/web/src/hooks/queries/use-configs.ts create mode 100644 apps/web/src/hooks/queries/use-dicts.ts create mode 100644 apps/web/src/pages/system/dicts/DictDetailPanel.tsx create mode 100644 apps/web/src/pages/system/dicts/DictGroupIcon.tsx create mode 100644 apps/web/src/pages/system/dicts/DictItemSheet.tsx create mode 100644 apps/web/src/pages/system/dicts/DictItemsView.tsx create mode 100644 apps/web/src/pages/system/dicts/DictTags.tsx create mode 100644 apps/web/src/pages/system/dicts/DictTypeListPanel.tsx create mode 100644 apps/web/src/pages/system/dicts/DictTypeModal.tsx create mode 100644 apps/web/src/pages/system/dicts/DictsPage.tsx create mode 100644 apps/web/src/pages/system/dicts/dict-utils.spec.ts create mode 100644 apps/web/src/pages/system/dicts/dict-utils.ts create mode 100644 apps/web/src/pages/system/settings/ConfigHistorySheet.tsx create mode 100644 apps/web/src/pages/system/settings/ConfigRow.tsx create mode 100644 apps/web/src/pages/system/settings/NamingAside.tsx create mode 100644 apps/web/src/pages/system/settings/SettingsGroupForm.tsx create mode 100644 apps/web/src/pages/system/settings/SettingsNav.tsx create mode 100644 apps/web/src/pages/system/settings/SettingsPage.tsx create mode 100644 apps/web/src/pages/system/settings/config-utils.spec.ts create mode 100644 apps/web/src/pages/system/settings/config-utils.ts create mode 100644 apps/web/src/styles/dicts.css create mode 100644 apps/web/src/styles/settings.css diff --git a/apps/web/src/api/configs.spec.ts b/apps/web/src/api/configs.spec.ts new file mode 100644 index 0000000..f7586c2 --- /dev/null +++ b/apps/web/src/api/configs.spec.ts @@ -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'); + }); +}); diff --git a/apps/web/src/api/configs.ts b/apps/web/src/api/configs.ts new file mode 100644 index 0000000..69add3a --- /dev/null +++ b/apps/web/src/api/configs.ts @@ -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 { + 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 = {}, +): 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); +} diff --git a/apps/web/src/api/dicts.spec.ts b/apps/web/src/api/dicts.spec.ts new file mode 100644 index 0000000..7056176 --- /dev/null +++ b/apps/web/src/api/dicts.spec.ts @@ -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: '说明', + }); + }); +}); diff --git a/apps/web/src/api/dicts.ts b/apps/web/src/api/dicts.ts new file mode 100644 index 0000000..04d2d66 --- /dev/null +++ b/apps/web/src/api/dicts.ts @@ -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 { + 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 = {}, +): 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 { + const payload: unknown = await request(ENDPOINTS.dict(typeCode)); + return dictTypeDetailSchema.parse(payload); +} + +export async function createDictType(input: CreateDictTypeInput): Promise { + 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 { + 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 { + 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 { + await request(ENDPOINTS.dict(typeCode), { method: 'DELETE' }); +} + +/** 生效条目列表(三层合并结果):不传 industry_id 表示仅通用层 */ +export async function fetchDictItemList( + typeCode: string, + query: Partial = {}, +): Promise { + 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 { + 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 { + const body = updateDictItemInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.dictItem(typeCode, itemId), { + method: 'PATCH', + body, + }); + return dictItemSchema.parse(payload); +} diff --git a/apps/web/src/api/endpoints.ts b/apps/web/src/api/endpoints.ts index 4bc4010..43bc4f6 100644 --- a/apps/web/src/api/endpoints.ts +++ b/apps/web/src/api/endpoints.ts @@ -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; diff --git a/apps/web/src/api/generated/openapi.d.ts b/apps/web/src/api/generated/openapi.d.ts index 8dad4d9..7971adb 100644 --- a/apps/web/src/api/generated/openapi.d.ts +++ b/apps/web/src/api/generated/openapi.d.ts @@ -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; diff --git a/apps/web/src/api/query-keys.ts b/apps/web/src/api/query-keys.ts index 6ae6b51..7b1b217 100644 --- a/apps/web/src/api/query-keys.ts +++ b/apps/web/src/api/query-keys.ts @@ -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, }; diff --git a/apps/web/src/hooks/queries/use-configs.ts b/apps/web/src/hooks/queries/use-configs.ts new file mode 100644 index 0000000..48505db --- /dev/null +++ b/apps/web/src/hooks/queries/use-configs.ts @@ -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> { + return useQuery({ + queryKey: queryKeys.systemConfigsEffective, + queryFn: fetchEffectiveSystemConfigs, + select: (data) => { + const values: Record = {}; + 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 = {}, + 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'] }), + ]); + }, + }); +} diff --git a/apps/web/src/hooks/queries/use-dicts.ts b/apps/web/src/hooks/queries/use-dicts.ts new file mode 100644 index 0000000..629e658 --- /dev/null +++ b/apps/web/src/hooks/queries/use-dicts.ts @@ -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 = {}, +): 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 { + 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 { + 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 { + 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 { + 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(); + }, + }); +} diff --git a/apps/web/src/layout/AppLayout.tsx b/apps/web/src/layout/AppLayout.tsx index e52116f..ed9ad57 100644 --- a/apps/web/src/layout/AppLayout.tsx +++ b/apps/web/src/layout/AppLayout.tsx @@ -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 ); } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4c9d4b4..8e36438 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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'; diff --git a/apps/web/src/pages/system/customer-fields/CustomerFieldsPage.tsx b/apps/web/src/pages/system/customer-fields/CustomerFieldsPage.tsx index 6b4313a..f6faa71 100644 --- a/apps/web/src/pages/system/customer-fields/CustomerFieldsPage.tsx +++ b/apps/web/src/pages/system/customer-fields/CustomerFieldsPage.tsx @@ -12,6 +12,7 @@ import { useCustomerFieldsQuery, useCustomersQuery, } from '@/hooks/queries/use-client'; +import { useCustomerFieldLimits } from '@/hooks/queries/use-configs'; const FIELD_TYPE_LABELS: Record = { 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 { )}
- 审批与变更写入审计 · 未审批字段不进入生成 · 每客户进入生成字段 ≤ 5 个 + 审批与变更写入审计 · 未审批字段不进入生成 · 每客户进入生成字段 ≤ {genFieldLimit} 个
{applyOpen ? ( diff --git a/apps/web/src/pages/system/customers/CustomerDetailPage.tsx b/apps/web/src/pages/system/customers/CustomerDetailPage.tsx index 322af88..bf76897 100644 --- a/apps/web/src/pages/system/customers/CustomerDetailPage.tsx +++ b/apps/web/src/pages/system/customers/CustomerDetailPage.tsx @@ -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({
- 基础字段仅用于检索与报表,永不进入生成;每客户最多 10 个。 + 基础字段仅用于检索与报表,永不进入生成;每客户最多 {limit} 个。
); @@ -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} ) : null} -
进入生成字段需审批后生效;每客户最多 5 个。
+
进入生成字段需审批后生效;每客户最多 {limit} 个。
); } @@ -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 { + + {typeDisabled ? ( + + ) : ( + + )} + + )} + + + ); +} + +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 ( +
+ 行业视角 + + + 全平台通用 · 该类型不支持行业扩展 + + + 通用 {items.effectiveCount} + + + + 行业包只覆盖差异项 + +
+ ); + } + const chips = dictScopeChips(items, { scoped: industryId !== null }); + return ( +
+ 行业视角 + { + patch({ name: value }); + }} + /> + + + { + patch({ code: value }); + }} + /> + + + {isTree ? ( + + )} + + + { + patch({ remark: value }); + }} + /> + +
+ {submitError === null ? null :
{submitError}
} + {mode === 'create' ? ( +
+ 编码提交后不可修改;名称、排序、备注与启停可再次编辑。新增在当前行业视角下即时生效。 +
+ ) : null} + {item !== null ? ( +
+ 被 {item.refCount} 个行业包 / + 模板引用;变更只影响新数据,历史引用保留填写时快照(R-10)。 +
+ ) : null} + + + ); +} diff --git a/apps/web/src/pages/system/dicts/DictItemsView.tsx b/apps/web/src/pages/system/dicts/DictItemsView.tsx new file mode 100644 index 0000000..3255843 --- /dev/null +++ b/apps/web/src/pages/system/dicts/DictItemsView.tsx @@ -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 ( + + ); + } + return ( + + + + + ); +} + +function ItemCells({ item }: { item: DictItem }): ReactNode { + return ( + <> + + {item.name} + + + {item.code} + + + {item.parentName ?? '—'} + + + + + {item.sortOrder} + + + + {item.refCount} + + {formatDictDateTime(item.updatedAt)} + + + ); +} + +function DictItemsTable({ + items, + readOnly, + typeDisabled, + onView, + onEdit, + onToggle, +}: { + items: readonly DictItem[]; +} & Omit): ReactNode { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + {items.map((item) => ( + + + + + ))} + +
名称编码父级来源排序状态引用更新时间操作
+ +
+ ); +} + +function DictTreeRow({ + item, + childrenByCode, + collapsed, + onToggleNode, + actions, +}: { + item: DictItem; + childrenByCode: Map; + collapsed: ReadonlySet; + onToggleNode: (code: string) => void; + actions: Omit; +}): ReactNode { + const children = childrenByCode.get(item.code) ?? []; + const open = !collapsed.has(item.code); + const isParent = children.length > 0; + return ( +
+
+ {isParent ? ( + + ) : ( + + )} +
+ {item.name} + {item.code} + +
+
+ {isParent ? {children.length} 个子项 : null} + {item.refCount} 引用 + {formatDictDateTime(item.updatedAt)} +
+
+ +
+
+ {isParent && open + ? children.map((child) => ( +
+ +
+ )) + : null} +
+ ); +} + +function DictItemsTree({ + items, + readOnly, + typeDisabled, + onView, + onEdit, + onToggle, +}: { + items: readonly DictItem[]; +} & Omit): ReactNode { + const tree = useMemo(() => buildDictItemTree(items), [items]); + const [collapsed, setCollapsed] = useState>(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 ( +
+ {tree.roots.map((item) => ( + + ))} +
+ ); +} + +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' ? ( + + ) : ( + + ); +} diff --git a/apps/web/src/pages/system/dicts/DictTags.tsx b/apps/web/src/pages/system/dicts/DictTags.tsx new file mode 100644 index 0000000..41373d8 --- /dev/null +++ b/apps/web/src/pages/system/dicts/DictTags.tsx @@ -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 ( + + {status === 'active' ? '启用' : '停用'} + + ); +} + +/** 三层合并来源标签:通用 / 父行业 / 本行业 +(可选)已覆盖(R-10 快照仍按停用展示) */ +export function DictSourceTags({ + source, + overridden, + disabled, +}: { + source: DictItemSource; + overridden: boolean; + disabled?: boolean; +}): ReactNode { + return ( + + + {dictSourceLabel(source)} + + {overridden ? ( + + 已覆盖 + + ) : null} + {disabled === true ? ( + + 已停用 + + ) : null} + + ); +} diff --git a/apps/web/src/pages/system/dicts/DictTypeListPanel.tsx b/apps/web/src/pages/system/dicts/DictTypeListPanel.tsx new file mode 100644 index 0000000..a4a68ac --- /dev/null +++ b/apps/web/src/pages/system/dicts/DictTypeListPanel.tsx @@ -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 ( + + ); +} + +function DictTypeGroup({ + name, + count, + iconKey, + open, + onToggle, + children, +}: { + name: string; + count: number; + iconKey: DictGroupKey; + open: boolean; + onToggle: () => void; + children: ReactNode; +}): ReactNode { + return ( +
+ + {open ?
{children}
: null} +
+ ); +} + +function DictListSkeleton(): ReactNode { + return ( + + ); +} + +/** 左栏 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>(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 ( + + ); +} diff --git a/apps/web/src/pages/system/dicts/DictTypeModal.tsx b/apps/web/src/pages/system/dicts/DictTypeModal.tsx new file mode 100644 index 0000000..3e44190 --- /dev/null +++ b/apps/web/src/pages/system/dicts/DictTypeModal.tsx @@ -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 ( + + ); +} + +/** 新建 / 编辑字典类型;内置类型不可删除,编码 / 形态 / 行业作用域创建后只读 */ +export function DictTypeModal({ + mode, + type, + onClose, + onSaved, + onDeleted, +}: DictTypeModalProps): ReactNode { + const [form, setForm] = useState(() => initialForm(mode, type)); + const [errors, setErrors] = useState({}); + const [submitError, setSubmitError] = useState(null); + const createType = useCreateDictTypeMutation(); + const updateType = useUpdateDictTypeMutation(); + const deleteType = useDeleteDictTypeMutation(); + + const patch = (values: Partial): void => { + setForm((current) => ({ ...current, ...values })); + setErrors({}); + setSubmitError(null); + }; + + const submit = async (): Promise => { + 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 ( + + {mode === 'edit' && type !== null && !type.builtin ? ( + + ) : null} + + + + + } + > +
+ {mode === 'create' ? ( + <> + + { + patch({ name: value }); + }} + /> + + + { + patch({ code: value }); + }} + /> + + + ) : ( + + + + )} + {mode === 'edit' ? ( + + { + patch({ name: value }); + }} + /> + + ) : null} + + { + if (value === 'tree' || value === 'list') patch({ kind: value }); + }} + /> + + + + { + patch({ industryScoped: checked }); + }} + /> + {form.industryScoped ? '允许行业覆盖 / 追加' : '仅全平台通用'} + + + + { + patch({ sortOrder: String(value) }); + }} + /> + + +