Compare commits

..
12 Commits
Author SHA1 Message Date
Stev_Wang a5a2914214 docs(plan): 标注 A4 完成状态与 WBS 状态列
背景:A4 已完成,开发计划需显式标注阶段与 7 项 WBS 的完成状态。\n改动:§6.5 增加「✅ 已完成」执行状态;WBS 表新增状态列并逐项标 ✅;与 §5.2 批次总览、§12.1 DoD、版本记录保持一致。\n影响面:仅文档;不改变范围与验收口径。\n回滚:回滚本提交即可。\n验证:prettier 通过;A4 报告/门禁数据已在上一提交回写。
2026-11-04 13:33:34 +08:00
Stev_Wang 02c716d6f2 docs(plan): A4 实施报告与计划回写
背景:A4 开发完成,需要回写实施报告、技术架构与开发计划状态。\n改动:新增 A4 实施报告;技术架构补 system_config_versions 与 dict:manage 口径;开发计划 A4 标记完成并回写门禁/遗留;README 索引补 A4 报告。\n影响面:仅文档;不改变需求与技术边界。\n回滚:回滚本提交即可。\n验证:prettier 通过;文档链接已检查。
2026-11-04 11:50:30 +08:00
Stev_Wang f16d04fe02 test(e2e): A4 字典与配置端到端用例
背景:A4-QA-01 需要真实 API + Web + PG 的浏览器回归。\n改动:新增 e2e/a4-dicts-settings.spec.ts,覆盖三层合并 / 编码只读 / 树词表切换 / 类型与条目启停 / 命名预览 / 阈值二次确认 / 变更记录 / 权限三态;e2e/README 登记 A4 用例与 Semi 选择器口径。\n影响面:仅测试文件;A4_E2E=1 才执行,默认 skip。\n回滚:删除本文件即可。\n验证:A4_E2E=1 运行 7 passed(约 1.1m);eslint/prettier 通过。
2026-11-04 11:50:11 +08:00
Stev_Wang 3b0a99d91f 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 无溢出。
2026-11-04 11:49:32 +08:00
Stev_Wang e4330a43e4 feat(api): A4 数据字典与系统配置后端
背景:A4 需要字典三层合并、配置单一来源与阈值同源。\n改动:新增 catalog 模块(类型/条目/三层合并/编码不可改/快照工具);platform 新增 SystemConfigService/Controller、NamingService 与配置版本历史;roles/users/customers/customer-fields 改为读 system_configs 上限。\n影响面:新增 13 个 REST 端点与 4 个模块注入;system_configs 写入走事务/乐观锁/版本历史;OpenAPI 快照更新。\n回滚:回滚本提交并恢复 shared 契约;新增表可保留。\n验证:api typecheck/build;单测 137;集成 58;OpenAPI diff 无破坏性变更。
2026-11-04 11:49:05 +08:00
Stev_Wang cc6e84cdbc feat(shared): A4 数据字典与系统配置契约
背景:A4 需要字典三层合并、配置版本与命名规则的共享契约。\n改动:新增 dict/config 契约、system-configs 定义与校验、命名模板解析;新增 dict_types/dict_items/system_config_versions 迁移与 13 类/96 条字典、26 项配置种子。\n影响面:shared 新增导出;prisma 新增表与迁移;客户基础字段 DTO 硬上界调整为 50(实际生效上限仍由配置动态校验)。\n回滚:回滚本提交并保留新增表(不删数据)。\n验证:shared typecheck/test 147;seed 幂等;prisma migrate dev 已应用。
2026-11-04 11:48:38 +08:00
Stev_Wang 32b44bdeec refactor(web): 侧栏「字典」更名为「数据字典」
背景:进入 A4 数据字典与系统配置阶段,产品口径统一为「数据字典」。\n改动:nav-config 菜单 label 与 description;补充 nav-config 单测。\n影响面:仅管理中心侧栏文案;路由 /system/dicts 与权限 dict:manage 不变。\n回滚:回滚本提交即可。\n验证:nav-config 单测 10/10;web lint / typecheck / 全量单测 126 通过;prettier 通过。
2026-11-04 10:26:23 +08:00
Stev_Wang 50afaee49c docs(plan): 回写 A3 标准完成状态(WBS 状态列 + 最终验收)
按 A1 的完成状态格式补齐 A3 §6.4:新增执行状态说明、WBS 表状态列并逐项标 ✅;完成日期更新为 2026-11-04 最终验收。仅文档变更。
2026-11-04 09:37:24 +08:00
Stev_Wang c90a036f82 docs(ci): 明确 CI 仅本地执行,远端 Gitea 不启用 CI
背景:远端自建 Gitea 未部署 Actions runner,推送后线上 CI 无法执行;原规范仍要求「推送后确认 CI」,容易误以为需等待远端流水线。

改动:
- AGENTS §6/§7:CI 只本地执行,推送前必须 pnpm ci:local 或本地门禁全绿,禁止等待/依赖远端 CI。
- 06-测试与CI规范 V1.1:新增「本地 CI 流水线(远端不跑 CI)」强制规则;集成/契约运行时机改为推送前本地。
- 01-Git规范 / 07-CodeReview:删除「推送后确认 CI」,改为本地门禁后推送。
- README:明确远端无 runner,ci.yml 仅本地参考/手动触发。
- .gitea/workflows/ci.yml:去掉 push 触发,仅保留 workflow_dispatch,避免推送停留在 queued。

影响面:仅规范与工作流配置,不影响代码/数据。
验证:pnpm format:check / lint 通过;pnpm ci:local 全部门禁通过(237.2s,4 e2e 冒烟 passed,8 个门控用例按环境变量跳过)。
2026-11-04 08:15:06 +08:00
Stev_Wang 3e4bf48841 fix(web): A3 页面对齐选定原型(行业 Tab / 新建向导 / 筛选条)
ci / 质量门禁 (push) Waiting to run
背景:A3 首版实现把行业管理简化为普通树 + 简化详情,新建客户用 Semi 默认 Steps + 下拉框,与用户选定的原型 B 结构差异较大;客户管理筛选区也缺少白底与统一图标间距。

改动:
- 行业管理(B):补头像 + 状态/层级标签、基本信息/子行业/引用 Tab、最近变更时间线、子行业与引用客户列表,右栏白底卡片,删除入口。
- 新建客户(B):补左侧「创建客户/共 4 步」步骤导航(标题 + 说明 + 当前/完成态)、两级行业卡片选择、表单卡头/底部操作条。
- 客户组(B):成员改为「客户/所属行业/状态/负责人/操作」表格并支持移除。
- 客户列表(A):补表头统计、筛选区白底卡片与搜索图标间距。
- 客户详情(A):补右栏快捷操作;字段审批(A):补状态分段、中文类型。
- 主操作 theme=solid、次操作 theme=outline,与原型按钮层级一致。
- 更新 A3 实施报告与设计说明。

影响面:仅 apps/web A3 页面、client.css 与文档;接口、数据、权限不变。
验证:lint / format:check / typecheck / test(shared 131 / api 90 / web 125)/ build 全绿;真实浏览器六页重拍通过。截图:design/a3-ui-v1/impl/。
2026-11-04 08:04:14 +08:00
Stev_Wang 140dc386c1 feat(client): A3 客户与行业目录(两级行业、客户组、字段审批)
背景:A3 需把行业 / 客户 / 客户组 / 客户级字段立为资产根节点;用户确认两级行业树与混搭 UI(行业 B / 客户列表 A / 详情 A / 新建 B / 客户组 B / 字段 A)。

改动:
- 新增 ADR-002,行业改为两级树,客户挂末级行业;同步 PRD / 技术架构 / 开发计划。
- 新增 client 模块:industries / customers / customer-groups / customer-fields 接口、真实 PrismaCustomerDirectory、客户状态机与客户转交、客户组数据范围解析、跨客户 404。
- 新增迁移与幂等种子(电商/金融两级行业、2 客户、1 客户组、4 条字段)。
- 前端新增行业/客户/客户组/字段审批页面与路由、导航更名(客户管理 / 行业管理 / 客户组),复用现有 token。
- 补充 shared 契约单测、client 集成用例、A3 浏览器 e2e。

影响面:client 模块与 client 表、shared 契约、web 路由/导航、ScopeContext 数据范围、OpenAPI 与种子;A1 客户转交占位由 client 实现替换。
回滚:回滚本提交;数据库保留新增表或按报告执行 down SQL;CUSTOMER_DIRECTORY 可切回空集实现。
验证:format:check / lint / typecheck / test 全绿(shared 131 / api 90 / web 125,共 346);test:int 5 文件 39 条;A3 e2e 1/1;build 8/8;真实浏览器六页验证通过。
2026-11-03 21:45:38 +08:00
Stev_Wang 8626996bb1 docs(plan): A2 最终验收通过并确认完成状态
ci / 质量门禁 (push) Canceled after 0s
背景:A2 全部 WBS 已完成,且后续合入了 A1 部门管理补强与角色页 UI 收口;需按当前 main 重新验收并在开发计划确认完成状态。

改动:
- 开发计划 A2 状态行/批次表保持 ✅ 已完成,进度与 §6.3 完成情况更新为最终验收口径;
- 更新最终测试数字:test 331(shared 121 / api 90 / web 120)、test:int 33、A2 e2e 1/1(22.1s);
- 补充收口项(角色管理命名、页头操作右对齐、编辑角色移入页头、移除重复目录抽屉),A2 实施报告同步。

验证:format:check / lint / typecheck / test / test:int 全绿;A2 e2e 最终版 UI 下通过。
2026-11-03 17:10:18 +08:00
117 changed files with 25641 additions and 250 deletions

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
# 远端 Gitea 未部署 Actions runner:本工作流不随 push 触发,仅供本地参考 / 手动触发。
# 本地等价命令:pnpm ci:local(推送前必须全绿)。
name: ci name: ci
on: on:
push:
branches: [main]
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
+3 -3
View File
@@ -114,7 +114,7 @@ docs/ PRD / 技术架构 / 立项建议 / 工程规范
| 开发 | `pnpm dev` | | 开发 | `pnpm dev` |
| 构建 | `pnpm build` | | 构建 | `pnpm build` |
| 质量 | `pnpm lint` · `pnpm lint:fix` · `pnpm format` · `pnpm format:check` · `pnpm typecheck` | | 质量 | `pnpm lint` · `pnpm lint:fix` · `pnpm format` · `pnpm format:check` · `pnpm typecheck` |
| 测试 | `pnpm test` · `pnpm test:int` · `pnpm test:e2e` · `pnpm ci:local` | | 测试 | `pnpm test` · `pnpm test:int` · `pnpm test:e2e` · `pnpm ci:local`(CI 只本地执行,远端无 runner) |
| 数据库 | `pnpm db:init`(迁移 + 幂等种子,不重置数据)· `pnpm db:migrate` · `pnpm db:reset`(清库重建;默认被拦截,必须 `ALLOW_DB_RESET=1` 显式确认;禁止手工删库 / 删数据卷) | | 数据库 | `pnpm db:init`(迁移 + 幂等种子,不重置数据)· `pnpm db:migrate` · `pnpm db:reset`(清库重建;默认被拦截,必须 `ALLOW_DB_RESET=1` 显式确认;禁止手工删库 / 删数据卷) |
| 契约 | `pnpm openapi:diff` | | 契约 | `pnpm openapi:diff` |
@@ -127,7 +127,7 @@ docs/ PRD / 技术架构 / 立项建议 / 工程规范
1. 基于最新 `main` 直接提交,小步 commit(**Conventional Commits,提交信息一律用中文**,`type` / `scope` 保留英文)。 1. 基于最新 `main` 直接提交,小步 commit(**Conventional Commits,提交信息一律用中文**,`type` / `scope` 保留英文)。
2. 本地门禁:`pnpm format:check && pnpm lint && pnpm typecheck && pnpm test`。 2. 本地门禁:`pnpm format:check && pnpm lint && pnpm typecheck && pnpm test`。
3. 提交说明必含:**背景 / 改动 / 影响面 / 回滚方式 / 验证证据**;迁移、权限、AI、导出类变更须在推送前完成双人走查。 3. 提交说明必含:**背景 / 改动 / 影响面 / 回滚方式 / 验证证据**;迁移、权限、AI、导出类变更须在推送前完成双人走查。
4. 直接 `git push origin main`;禁止 force push `main`,禁止 Merge commit;推送后立即确认 CI,失败按前向修复处理。 4. 直接 `git push origin main`;禁止 force push `main`,禁止 Merge commit。**远端 Gitea 未部署 Actions runner,线上 CI 不可用,禁止等待/依赖远端 CI**;CI 一律在推送前本地执行(`pnpm ci:local`),本地门禁全绿才能推送。
5. 发布:staging 验证 → 生产灰度;**迁移先行、代码后行**。 5. 发布:staging 验证 → 生产灰度;**迁移先行、代码后行**。
细则:[Git 规范](docs/工程规范/01-Git规范.md) · [Code Review](docs/工程规范/07-CodeReview与PR规范.md) · [测试与 CI](docs/工程规范/06-测试与CI规范.md) 细则:[Git 规范](docs/工程规范/01-Git规范.md) · [Code Review](docs/工程规范/07-CodeReview与PR规范.md) · [测试与 CI](docs/工程规范/06-测试与CI规范.md)
@@ -154,7 +154,7 @@ docs/ PRD / 技术架构 / 立项建议 / 工程规范
| API 与路由 | [04-API与路由规范](docs/工程规范/04-API与路由规范.md) | REST /api/v1、错误码、鉴权链、任务协议 | | API 与路由 | [04-API与路由规范](docs/工程规范/04-API与路由规范.md) | REST /api/v1、错误码、鉴权链、任务协议 |
| 数据库 | [05-数据库规范](docs/工程规范/05-数据库规范.md) | ULID、customer_id、游标分页、expand/contract | | 数据库 | [05-数据库规范](docs/工程规范/05-数据库规范.md) | ULID、customer_id、游标分页、expand/contract |
| 测试与 CI | [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md) | 分层测试、权限三态、覆盖率只升不降 | | 测试与 CI | [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md) | 分层测试、权限三态、覆盖率只升不降 |
| Code Review | [07-CodeReview与PR规范](docs/工程规范/07-CodeReview与PR规范.md) | 提交说明、双人场景、DoD、推送后观察 | | Code Review | [07-CodeReview与PR规范](docs/工程规范/07-CodeReview与PR规范.md) | 提交说明、双人场景、DoD、本地 CI 后推送 |
| 日志与可观测 | [08-日志与可观测性规范](docs/工程规范/08-日志与可观测性规范.md) | Pino 字段、trace_id、审计与日志分离、告警 | | 日志与可观测 | [08-日志与可观测性规范](docs/工程规范/08-日志与可观测性规范.md) | Pino 字段、trace_id、审计与日志分离、告警 |
| 环境变量与密钥 | [09-环境变量与密钥规范](docs/工程规范/09-环境变量与密钥规范.md) | `.env.example` 入库、启动校验、轮换与泄露处置 | | 环境变量与密钥 | [09-环境变量与密钥规范](docs/工程规范/09-环境变量与密钥规范.md) | `.env.example` 入库、启动校验、轮换与泄露处置 |
| 资产命名与图层 | [10-资产命名与PSD图层规范](docs/工程规范/10-资产命名与PSD图层规范.md) | 命名占位符、PSD 图层前缀、规格矩阵、低质量标记 | | 资产命名与图层 | [10-资产命名与PSD图层规范](docs/工程规范/10-资产命名与PSD图层规范.md) | 命名占位符、PSD 图层前缀、规格矩阵、低质量标记 |
+4 -2
View File
@@ -21,7 +21,9 @@
| **A0 报告** | [灵犀_A0工程骨架与契约基线_实施报告_V0.1.md](docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md) | V0.1 | A0 完成范围、门禁实测、环境适配与待确认事项 | | **A0 报告** | [灵犀_A0工程骨架与契约基线_实施报告_V0.1.md](docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md) | V0.1 | A0 完成范围、门禁实测、环境适配与待确认事项 |
| **A1 报告** | [灵犀_A1身份认证与用户管理_实施报告_V0.1.md](docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md) | V0.1 | A1 完成范围、契约/实现/测试、遗留与下一步 | | **A1 报告** | [灵犀_A1身份认证与用户管理_实施报告_V0.1.md](docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md) | V0.1 | A1 完成范围、契约/实现/测试、遗留与下一步 |
| **A2 报告** | [灵犀_A2角色权限与数据范围_实施报告_V0.1.md](docs/开发计划/灵犀_A2角色权限与数据范围_实施报告_V0.1.md) | V0.1 | A2 完成范围、H1 决策、前后端/测试、遗留与下一步 | | **A2 报告** | [灵犀_A2角色权限与数据范围_实施报告_V0.1.md](docs/开发计划/灵犀_A2角色权限与数据范围_实施报告_V0.1.md) | V0.1 | A2 完成范围、H1 决策、前后端/测试、遗留与下一步 |
| **ADR** | [docs/adr/](docs/adr/README.md) | — | 架构决策记录(ADR-001 权限点目录与六类数据范围) | | **A3 报告** | [灵犀_A3客户与行业目录_实施报告_V0.1.md](docs/开发计划/灵犀_A3客户与行业目录_实施报告_V0.1.md) | V0.1 | A3 完成范围、两级行业决策、混搭 UI、测试与遗留 |
| **A4 报告** | [灵犀_A4数据字典与系统配置_实施报告_V0.1.md](docs/开发计划/灵犀_A4数据字典与系统配置_实施报告_V0.1.md) | V0.1 | A4 完成范围、三层合并/配置版本、方案 A UI、测试与遗留 |
| **ADR** | [docs/adr/](docs/adr/README.md) | — | 架构决策记录(ADR-001 权限;ADR-002 两级行业目录) |
| 历史 PRD | `docs/PRD/灵犀_信息流素材生产平台_PRD_V0.1~V0.3.md` | 归档 | 仅作沿革参考,**不具约束力** | | 历史 PRD | `docs/PRD/灵犀_信息流素材生产平台_PRD_V0.1~V0.3.md` | 归档 | 仅作沿革参考,**不具约束力** |
> 阅读顺序:`AGENTS.md` → PRD §1/§6 → 技术架构 §0.2/§3 → 按任务查 `docs/工程规范/`。 > 阅读顺序:`AGENTS.md` → PRD §1/§6 → 技术架构 §0.2/§3 → 按任务查 `docs/工程规范/`。
@@ -118,7 +120,7 @@ pnpm openapi:export && pnpm openapi:diff # 契约快照与破坏性变更检
pnpm ci:local # 本地跑完整 CI 门禁(已内置 e2e 所需 env) pnpm ci:local # 本地跑完整 CI 门禁(已内置 e2e 所需 env)
``` ```
> Gitea 暂未部署 Actions runner,推送后远程 CI 会停留在 queued;当前以 `pnpm ci:local` 为准,runner 就绪后 `.gitea/workflows/ci.yml` 会自动接管。 > **远端 Gitea 未部署 Actions runner,线上 CI 不可用**。所有 CI 一律在本地执行:推送前跑 `pnpm ci:local`(或至少 `format:check + lint + typecheck + test`)。`.gitea/workflows/ci.yml` 仅作本地流水线参考 / 手动触发,**不随 push 自动运行**。
命令与门禁见 [AGENTS.md §6](AGENTS.md) 与 [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md)。当前 UI 采用「顶部模块菜单 + 左侧功能菜单」布局,视觉方向为 **A · 云白蓝(传统企业后台 + 现代浅色质感)**。 命令与门禁见 [AGENTS.md §6](AGENTS.md) 与 [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md)。当前 UI 采用「顶部模块菜单 + 左侧功能菜单」布局,视觉方向为 **A · 云白蓝(传统企业后台 + 现代浅色质感)**。
+4
View File
@@ -7,6 +7,8 @@ import { APP_CONFIG, AppConfigModule } from './config/config.module';
import type { AppConfig } from './config/env'; import type { AppConfig } from './config/env';
import { PrismaModule } from './infra/prisma/prisma.module'; import { PrismaModule } from './infra/prisma/prisma.module';
import { RedisModule } from './infra/redis/redis.module'; import { RedisModule } from './infra/redis/redis.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { ClientModule } from './modules/client/client.module';
import { IdentityModule } from './modules/identity/identity.module'; import { IdentityModule } from './modules/identity/identity.module';
import { PlatformModule } from './modules/platform/platform.module'; import { PlatformModule } from './modules/platform/platform.module';
@@ -49,6 +51,8 @@ import { PlatformModule } from './modules/platform/platform.module';
RedisModule, RedisModule,
SecurityModule, SecurityModule,
IdentityModule, IdentityModule,
ClientModule,
CatalogModule,
PlatformModule, PlatformModule,
], ],
}) })
@@ -7,7 +7,6 @@ import { PermissionCatalogService } from './catalog/permission-catalog.service';
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards'; import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
import { PermissionService } from './permission.service'; import { PermissionService } from './permission.service';
import { RequestContextService } from './request-context'; import { RequestContextService } from './request-context';
import { CUSTOMER_DIRECTORY, EmptyCustomerDirectory } from './scope/customer-directory';
import { ScopeResolver } from './scope/scope-resolver.service'; import { ScopeResolver } from './scope/scope-resolver.service';
import { TokenService } from './token.service'; import { TokenService } from './token.service';
@@ -15,8 +14,8 @@ import { TokenService } from './token.service';
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、数据范围解析、JWT 与守卫链。 * 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、数据范围解析、JWT 与守卫链。
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。 * 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
* *
* `CUSTOMER_DIRECTORY` 是客户目录端口:A2 注册空集实现,A3 落地 client 模块后替换 provider * `CUSTOMER_DIRECTORY` 由全局 ClientModule 提供真实实现(A3);
* 即可让六类数据范围解析出真实客户集合,ScopeResolver / 守卫链无需改动。 * ScopeResolver / 守卫链只依赖端口,不感知客户模块。
*/ */
@Global() @Global()
@Module({ @Module({
@@ -32,7 +31,6 @@ import { TokenService } from './token.service';
JwtAuthGuard, JwtAuthGuard,
SpecialPermissionGuard, SpecialPermissionGuard,
ScopeGuard, ScopeGuard,
{ provide: CUSTOMER_DIRECTORY, useClass: EmptyCustomerDirectory },
{ provide: APP_GUARD, useExisting: JwtAuthGuard }, { provide: APP_GUARD, useExisting: JwtAuthGuard },
{ provide: APP_GUARD, useExisting: PermissionGuard }, { provide: APP_GUARD, useExisting: PermissionGuard },
{ provide: APP_GUARD, useExisting: SpecialPermissionGuard }, { provide: APP_GUARD, useExisting: SpecialPermissionGuard },
@@ -0,0 +1,515 @@
import { API_PREFIX, ErrorCode, Permission, ulid } from '@lingxi/shared';
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp, type CreatedApiApp } from '../../bootstrap';
import { loadDotenv } from '../../config/load-dotenv';
const PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
const PASSWORD = 'lingxi2026';
const PREFIX = 'itqa4.';
const TYPE_PREFIX = 'itqa4_';
const INDUSTRY_PREFIX = 'ITQA4-';
const API = API_PREFIX;
loadDotenv();
const prisma = new PrismaClient();
interface TokenBody {
accessToken: string;
}
interface JsonResult<T> {
status: number;
body: T;
}
interface DictTypeSummaryBody {
code: string;
name: string;
group: string;
kind: string;
status: string;
builtin: boolean;
industryScoped: boolean;
itemCount: number;
activeItemCount: number;
industryCount: number;
updatedBy: string | null;
}
interface DictItemBody {
id: string;
code: string;
name: string;
source: string;
overridden: boolean;
status: string;
parentName: string | null;
industryPath: string | null;
refCount: number;
}
interface DictItemListBody {
items: DictItemBody[];
layers: { source: string; industryId: string | null; label: string; count: number }[];
effectiveCount: number;
overriddenCount: number;
disabledCount: number;
}
let phoneCounter = 0;
let app: CreatedApiApp['app'];
let adminToken = '';
let noPermissionToken = '';
let rootIndustryId = '';
let leafIndustryId = '';
const industryTypeCode = `${TYPE_PREFIX}industry_scoped`;
const emptyTypeCode = `${TYPE_PREFIX}empty_tmp`;
const createdRoleIds: string[] = [];
const createdUserIds: string[] = [];
function nextPhone(): string {
phoneCounter += 1;
return `135${String(phoneCounter).padStart(8, '0')}`;
}
async function createRole(suffix: string, permissions: string[]): Promise<string> {
const id = ulid();
await prisma.role.create({
data: {
id,
code: `${PREFIX}role_${suffix}`,
name: `${PREFIX}角色-${suffix}`,
description: 'A4 集成测试角色',
status: 'ACTIVE',
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
dataScopes: { create: [{ id: ulid(), scopeType: 'all', targetId: null }] },
},
});
createdRoleIds.push(id);
return id;
}
async function createUser(suffix: string, roleId: string): Promise<string> {
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
const account = `${PREFIX}${suffix}`;
const id = ulid();
await prisma.user.create({
data: {
id,
name: `A4 集成测试${suffix}`,
account,
email: `${account}@lingxi.cn`,
phone: nextPhone(),
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId }] },
},
});
createdUserIds.push(id);
return id;
}
async function login(account: string): Promise<string> {
const response = await app.inject({
method: 'POST',
url: `${API}/auth/login`,
payload: { account, password: PASSWORD },
});
if (response.statusCode !== 200 && response.statusCode !== 201) {
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
}
return response.json<TokenBody>().accessToken;
}
async function call<T>(
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
url: string,
token: string,
payload?: unknown,
): Promise<JsonResult<T>> {
const response = await app.inject({
method,
url: `${API}${url}`,
headers: { authorization: `Bearer ${token}` },
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
});
const body = response.body.length === 0 ? (null as T) : response.json<T>();
return { status: response.statusCode, body };
}
async function createType(
code: string,
body: Record<string, unknown>,
): Promise<DictTypeSummaryBody> {
const created = await call<DictTypeSummaryBody>('POST', '/dicts', adminToken, {
code,
...body,
});
expect(created.status).toBe(201);
return created.body;
}
async function createItem(typeCode: string, body: Record<string, unknown>): Promise<DictItemBody> {
const created = await call<DictItemBody>('POST', `/dicts/${typeCode}/items`, adminToken, body);
expect(created.status).toBe(201);
return created.body;
}
async function cleanupFixtures(): Promise<void> {
await prisma.dictType.deleteMany({ where: { code: { startsWith: TYPE_PREFIX } } });
await prisma.industry.deleteMany({ where: { name: { startsWith: INDUSTRY_PREFIX } } });
await prisma.user.deleteMany({ where: { account: { startsWith: PREFIX } } });
if (createdRoleIds.length > 0) {
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
}
}
beforeAll(async () => {
await cleanupFixtures();
const adminRole = await createRole('admin', [Permission.DICT_MANAGE]);
await createUser('admin', adminRole);
const readRole = await createRole('reader', [Permission.CUSTOMER_READ]);
await createUser('reader', readRole);
const root = await prisma.industry.create({
data: { id: ulid(), parentId: null, level: 1, name: `${INDUSTRY_PREFIX}电商`, sortOrder: 10 },
});
rootIndustryId = root.id;
const leaf = await prisma.industry.create({
data: {
id: ulid(),
parentId: rootIndustryId,
level: 2,
name: `${INDUSTRY_PREFIX}服饰鞋包`,
sortOrder: 10,
},
});
leafIndustryId = leaf.id;
const created = await createApp();
app = created.app;
await app.init();
adminToken = await login(`${PREFIX}admin`);
noPermissionToken = await login(`${PREFIX}reader`);
});
afterAll(async () => {
await cleanupFixtures();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
if (app) await app.close();
await prisma.$disconnect();
});
describe('A4 字典 · 类型列表 / 详情 / 写操作', () => {
it('类型列表:分组顺序 + 统计;keyword / group 过滤;无权限 403', async () => {
const list = await call<{ items: DictTypeSummaryBody[]; next_cursor: string | null }>(
'GET',
'/dicts?limit=100',
adminToken,
);
expect(list.status).toBe(200);
expect(list.body.items.length).toBeGreaterThanOrEqual(13);
const groupOrder = ['content', 'platform', 'quality', 'audience'];
const ranks = list.body.items.map((item) => groupOrder.indexOf(item.group));
expect(ranks).toEqual([...ranks].sort((left, right) => left - right));
const platformSpec = list.body.items.find((item) => item.code === 'platform_spec');
expect(platformSpec).toMatchObject({ builtin: true, industryScoped: true });
expect(platformSpec?.itemCount).toBeGreaterThan(0);
const filtered = await call<{ items: DictTypeSummaryBody[] }>(
'GET',
'/dicts?group=quality&status=active&limit=100',
adminToken,
);
expect(filtered.status).toBe(200);
expect(filtered.body.items.every((item) => item.group === 'quality')).toBe(true);
const forbidden = await call<{ code: number }>('GET', '/dicts', noPermissionToken);
expect(forbidden.status).toBe(403);
expect(forbidden.body.code).toBe(ErrorCode.PERMISSION_DENIED);
});
it('新建类型 / 详情 / 编码重复 40902 / 不存在 40401', async () => {
const created = await createType(industryTypeCode, {
name: 'ITQA4 行业脚本方向',
group: 'content',
kind: 'tree',
description: 'A4 集成测试三层合并',
industryScoped: true,
sortOrder: 95,
});
expect(created).toMatchObject({
code: industryTypeCode,
builtin: false,
industryScoped: true,
kind: 'tree',
});
const detail = await call<DictTypeSummaryBody>('GET', `/dicts/${industryTypeCode}`, adminToken);
expect(detail.status).toBe(200);
expect(detail.body.code).toBe(industryTypeCode);
const duplicate = await call<{ code: number }>('POST', '/dicts', adminToken, {
code: industryTypeCode,
name: '重复编码',
group: 'content',
});
expect(duplicate.status).toBe(409);
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
const missing = await call<{ code: number }>('GET', '/dicts/itqa4_missing_type', adminToken);
expect(missing.status).toBe(404);
expect(missing.body.code).toBe(ErrorCode.NOT_FOUND);
});
it('更新类型(编码 / 形态 / 行业作用域不可改)与启停', async () => {
const updated = await call<DictTypeSummaryBody>(
'PATCH',
`/dicts/${industryTypeCode}`,
adminToken,
{ name: 'ITQA4 行业脚本方向(改)', sortOrder: 96 },
);
expect(updated.status).toBe(200);
expect(updated.body).toMatchObject({ name: 'ITQA4 行业脚本方向(改)', sortOrder: 96 });
const immutable = await call<{ code: number }>(
'PATCH',
`/dicts/${industryTypeCode}`,
adminToken,
{ code: 'itqa4_changed_code' },
);
expect(immutable.status).toBe(400);
expect(immutable.body.code).toBe(ErrorCode.PARAM_INVALID);
const disabled = await call<DictTypeSummaryBody>(
'PATCH',
`/dicts/${industryTypeCode}/status`,
adminToken,
{ status: 'disabled' },
);
expect(disabled.status).toBe(200);
expect(disabled.body.status).toBe('disabled');
const enabled = await call<DictTypeSummaryBody>(
'PATCH',
`/dicts/${industryTypeCode}/status`,
adminToken,
{ status: 'active' },
);
expect(enabled.body.status).toBe('active');
});
it('删除:内置类型 40001、有子条目 40001、空自定义类型 204 后 404', async () => {
const builtin = await call<{ code: number }>('DELETE', '/dicts/platform_spec', adminToken);
expect(builtin.status).toBe(400);
expect(builtin.body.code).toBe(ErrorCode.PARAM_INVALID);
const blockedCode = `${TYPE_PREFIX}delete_blocked`;
await createType(blockedCode, { name: 'ITQA4 待删类型', group: 'content' });
await createItem(blockedCode, { code: 'keep_me', name: '保留条目' });
const withItems = await call<{ code: number }>('DELETE', `/dicts/${blockedCode}`, adminToken);
expect(withItems.status).toBe(400);
expect(withItems.body.code).toBe(ErrorCode.PARAM_INVALID);
await createType(emptyTypeCode, { name: 'ITQA4 空类型', group: 'content' });
const removed = await call<null>('DELETE', `/dicts/${emptyTypeCode}`, adminToken);
expect(removed.status).toBe(204);
const gone = await call<{ code: number }>('GET', `/dicts/${emptyTypeCode}`, adminToken);
expect(gone.status).toBe(404);
expect(gone.body.code).toBe(ErrorCode.NOT_FOUND);
});
});
describe('A4 字典 · 条目三层合并(R-10 / A4-QA-01)', () => {
it('准备通用 5 / 父行业 2 / 本行业 1 数据', async () => {
const generic = [
{ code: 'oral', name: '口播', sortOrder: 10 },
{ code: 'drama', name: '剧情', sortOrder: 20 },
{ code: 'review', name: '测评', sortOrder: 30 },
{ code: 'seeding', name: '种草', sortOrder: 40 },
{ code: 'scene', name: '情景', sortOrder: 50 },
];
for (const item of generic) {
await createItem(industryTypeCode, { ...item, parentCode: null, industryId: null });
}
await createItem(industryTypeCode, {
code: 'live_clip',
name: '直播切片',
industryId: rootIndustryId,
sortOrder: 60,
});
await createItem(industryTypeCode, {
code: 'seeding',
name: '种草短视频',
industryId: rootIndustryId,
sortOrder: 40,
});
await createItem(industryTypeCode, {
code: 'outfit',
name: '穿搭教程',
industryId: leafIndustryId,
sortOrder: 70,
});
});
it('二级行业三层合并:7 条生效、source / overridden / layers 正确', async () => {
const result = await call<DictItemListBody>(
'GET',
`/dicts/${industryTypeCode}/items?industry_id=${leafIndustryId}`,
adminToken,
);
expect(result.status).toBe(200);
expect(result.body.items).toHaveLength(7);
expect(result.body.effectiveCount).toBe(7);
expect(result.body.overriddenCount).toBe(1);
expect(result.body.disabledCount).toBe(0);
expect(result.body.layers).toEqual([
{ source: 'generic', industryId: null, label: '通用', count: 5 },
{ source: 'parent', industryId: rootIndustryId, label: `${INDUSTRY_PREFIX}电商`, count: 2 },
{
source: 'current',
industryId: leafIndustryId,
label: `${INDUSTRY_PREFIX}服饰鞋包`,
count: 1,
},
]);
const seeding = result.body.items.find((item) => item.code === 'seeding');
expect(seeding).toMatchObject({
name: '种草短视频',
source: 'parent',
overridden: true,
industryPath: `${INDUSTRY_PREFIX}电商`,
refCount: 0,
});
expect(result.body.items.find((item) => item.code === 'live_clip')).toMatchObject({
source: 'parent',
overridden: false,
});
expect(result.body.items.find((item) => item.code === 'outfit')).toMatchObject({
source: 'current',
industryPath: `${INDUSTRY_PREFIX}电商 / ${INDUSTRY_PREFIX}服饰鞋包`,
});
});
it('不传行业只返回通用层;一级行业 = 通用 + 本行业;行业不存在 40401', async () => {
const genericOnly = await call<DictItemListBody>(
'GET',
`/dicts/${industryTypeCode}/items`,
adminToken,
);
expect(genericOnly.body.items).toHaveLength(5);
expect(genericOnly.body.items.every((item) => item.source === 'generic')).toBe(true);
const rootView = await call<DictItemListBody>(
'GET',
`/dicts/${industryTypeCode}/items?industry_id=${rootIndustryId}`,
adminToken,
);
expect(rootView.body.layers.map((layer) => layer.source)).toEqual(['generic', 'current']);
expect(rootView.body.items).toHaveLength(6);
const missing = await call<{ code: number }>(
'GET',
`/dicts/${industryTypeCode}/items?industry_id=01HZZZZZZZZZZZZZZZZZZZZZZZ`,
adminToken,
);
expect(missing.status).toBe(404);
expect(missing.body.code).toBe(ErrorCode.NOT_FOUND);
});
it('非行业作用域类型强制忽略 industry_id,只返回通用层', async () => {
const code = `${TYPE_PREFIX}list_tmp`;
await createType(code, { name: 'ITQA4 时长档', group: 'content', kind: 'list' });
await createItem(code, { code: '15s', name: '15 秒', sortOrder: 10 });
const result = await call<DictItemListBody>(
'GET',
`/dicts/${code}/items?industry_id=${leafIndustryId}`,
adminToken,
);
expect(result.status).toBe(200);
expect(result.body.items).toHaveLength(1);
expect(result.body.layers).toEqual([
{ source: 'generic', industryId: null, label: '通用', count: 1 },
]);
expect(result.body.items[0]).toMatchObject({ code: '15s', source: 'generic' });
});
it('条目写校验:非行业类型传行业 / 列表型传父级 / 父级不存在 / 重复编码', async () => {
const code = `${TYPE_PREFIX}list_tmp`;
const industryOnList = await call<{ code: number }>(
'POST',
`/dicts/${code}/items`,
adminToken,
{ code: '30s', name: '30 秒', industryId: leafIndustryId },
);
expect(industryOnList.status).toBe(400);
expect(industryOnList.body.code).toBe(ErrorCode.PARAM_INVALID);
const parentOnList = await call<{ code: number }>('POST', `/dicts/${code}/items`, adminToken, {
code: '30s',
name: '30 秒',
parentCode: '15s',
});
expect(parentOnList.status).toBe(400);
expect(parentOnList.body.code).toBe(ErrorCode.PARAM_INVALID);
const missingParent = await call<{ code: number }>(
'POST',
`/dicts/${industryTypeCode}/items`,
adminToken,
{ code: 'new_child', name: '新子项', parentCode: 'missing_parent' },
);
expect(missingParent.status).toBe(400);
expect(missingParent.body.code).toBe(ErrorCode.PARAM_INVALID);
const duplicate = await call<{ code: number }>(
'POST',
`/dicts/${industryTypeCode}/items`,
adminToken,
{ code: 'oral', name: '口播重复' },
);
expect(duplicate.status).toBe(409);
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
});
it('条目编辑:改名 / 停用;编码不可改(unknown 字段 40001)', async () => {
const created = await createItem(industryTypeCode, {
code: 'voiceover',
name: '口播旁白',
sortOrder: 80,
});
const updated = await call<DictItemBody>(
'PATCH',
`/dicts/${industryTypeCode}/items/${created.id}`,
adminToken,
{ name: '口播旁白(改)', status: 'disabled' },
);
expect(updated.status).toBe(200);
expect(updated.body).toMatchObject({
code: 'voiceover',
name: '口播旁白(改)',
status: 'disabled',
});
const immutable = await call<{ code: number }>(
'PATCH',
`/dicts/${industryTypeCode}/items/${created.id}`,
adminToken,
{ code: 'changed_code' },
);
expect(immutable.status).toBe(400);
expect(immutable.body.code).toBe(ErrorCode.PARAM_INVALID);
const missing = await call<{ code: number }>(
'PATCH',
`/dicts/${industryTypeCode}/items/01HZZZZZZZZZZZZZZZZZZZZZZZ`,
adminToken,
{ name: '不存在' },
);
expect(missing.status).toBe(404);
expect(missing.body.code).toBe(ErrorCode.NOT_FOUND);
});
});
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { DictsController } from './dicts.controller';
import { DictsService } from './dicts.service';
/**
* catalog 模块(A4-BE-01 / A4-BE-04):数据字典类型与条目、行业作用域三层合并、快照工具。
* 只依赖 @Global 的 PrismaModule / SecurityModule(RequestContextService),不反向依赖其他业务模块。
*/
@Module({
controllers: [DictsController],
providers: [DictsService],
exports: [DictsService],
})
export class CatalogModule {}
@@ -0,0 +1,139 @@
import type { DictItemSource } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import { type DictMergeLayer, type DictMergeRow, mergeDictLayers } from './dict-merge';
function row(overrides: Partial<DictMergeRow> & Pick<DictMergeRow, 'code' | 'name'>): DictMergeRow {
return {
id: `id-${overrides.code}`,
parentCode: null,
industryId: null,
sortOrder: 10,
status: 'active',
remark: '',
updatedAt: '2026-11-04T00:00:00.000Z',
updatedBy: '江岚',
...overrides,
};
}
function layer(
source: DictItemSource,
industryId: string | null,
items: DictMergeRow[],
): DictMergeLayer {
const label = source === 'generic' ? '通用' : source === 'parent' ? '父行业' : '本行业';
return { source, industryId, label, items };
}
/** A4-QA-01 口径:通用 5 + 父行业 2 + 本行业 1,其中 seeding 被父行业覆盖 → 生效 7 条 */
function scriptDirectionLayers(): DictMergeLayer[] {
return [
layer('generic', null, [
row({ code: 'oral', name: '口播', sortOrder: 10 }),
row({ code: 'drama', name: '剧情', sortOrder: 20 }),
row({ code: 'review', name: '测评', sortOrder: 30 }),
row({ code: 'seeding', name: '种草', sortOrder: 40 }),
row({ code: 'scene', name: '情景', sortOrder: 50 }),
]),
layer('parent', 'ec', [
row({ code: 'live_clip', name: '直播切片', industryId: 'ec', sortOrder: 60 }),
row({ code: 'seeding', name: '种草短视频', industryId: 'ec', sortOrder: 40 }),
]),
layer('current', 'ec_apparel', [
row({ code: 'outfit', name: '穿搭教程', industryId: 'ec_apparel', sortOrder: 70 }),
]),
];
}
describe('mergeDictLayers · 三层合并', () => {
it('通用 5 + 父行业 2 + 本行业 1:同 code 覆盖后生效 7 条', () => {
const result = mergeDictLayers(scriptDirectionLayers());
expect(result.items).toHaveLength(7);
expect(result.effectiveCount).toBe(7);
expect(result.disabledCount).toBe(0);
expect(result.overriddenCount).toBe(1);
expect(result.layers).toEqual([
{ source: 'generic', industryId: null, label: '通用', count: 5 },
{ source: 'parent', industryId: 'ec', label: '父行业', count: 2 },
{ source: 'current', industryId: 'ec_apparel', label: '本行业', count: 1 },
]);
});
it('来源与覆盖标记正确:种草短视频 parent / 直播切片 parent / 穿搭教程 current', () => {
const result = mergeDictLayers(scriptDirectionLayers());
const seeding = result.items.find((item) => item.code === 'seeding');
const liveClip = result.items.find((item) => item.code === 'live_clip');
const outfit = result.items.find((item) => item.code === 'outfit');
expect(seeding).toMatchObject({
name: '种草短视频',
source: 'parent',
overridden: true,
});
expect(liveClip).toMatchObject({ source: 'parent', overridden: false });
expect(outfit).toMatchObject({ source: 'current', overridden: false });
// 通用层的「种草」被父行业覆盖,不再出现在生效结果中
expect(result.items.filter((item) => item.code === 'seeding')).toHaveLength(1);
});
it('条目按 sortOrder asc, name asc 排序', () => {
const result = mergeDictLayers(scriptDirectionLayers());
expect(result.items.map((item) => item.code)).toEqual([
'oral',
'drama',
'review',
'seeding',
'scene',
'live_clip',
'outfit',
]);
expect(result.items.map((item) => item.sortOrder)).toEqual([10, 20, 30, 40, 50, 60, 70]);
});
it('非行业作用域只传入通用层:全部 source=generic、无覆盖', () => {
const genericLayer = layer('generic', null, [
row({ code: '15s', name: '15 秒' }),
row({ code: '30s', name: '30 秒' }),
]);
const result = mergeDictLayers([genericLayer]);
expect(result.items).toHaveLength(2);
expect(result.items.every((item) => item.source === 'generic')).toBe(true);
expect(result.items.every((item) => !item.overridden)).toBe(true);
expect(result.overriddenCount).toBe(0);
expect(result.layers).toEqual([
{ source: 'generic', industryId: null, label: '通用', count: 2 },
]);
});
it('effectiveCount 只计 active,disabledCount 计 disabled,被覆盖的禁用条目不重复计数', () => {
const result = mergeDictLayers([
layer('generic', null, [
row({ code: 'L1', name: '自然光', sortOrder: 10 }),
row({ code: 'L5', name: '高反差硬光', sortOrder: 50, status: 'disabled' }),
]),
layer('current', 'fin', [
row({ code: 'L1', name: '自然光(金融)', industryId: 'fin', sortOrder: 10 }),
row({ code: 'fin_L6', name: '商务蓝调', industryId: 'fin', status: 'disabled' }),
]),
]);
expect(result.effectiveCount).toBe(1);
expect(result.disabledCount).toBe(2);
expect(result.overriddenCount).toBe(1);
});
it('三层同 code 时以本行业为准,只计一条生效;层级顺序无关', () => {
const layers: DictMergeLayer[] = [
layer('current', 'leaf', [row({ code: 'oral', name: '本行业口播', industryId: 'leaf' })]),
layer('generic', null, [row({ code: 'oral', name: '口播' })]),
layer('parent', 'root', [row({ code: 'oral', name: '父行业口播', industryId: 'root' })]),
];
const result = mergeDictLayers(layers);
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
name: '本行业口播',
source: 'current',
overridden: true,
});
expect(result.overriddenCount).toBe(1);
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* 数据字典三层合并(A4-BE-01 / PRD R-10)。
*
* 存储只有一张通用枚举表 `dict_items`:`industry_id IS NULL` 为通用层,
* 否则为行业层;二级行业的视角为「通用 → 父行业 → 本行业」。
* 同 code 以更具体层覆盖,最终只返回生效条目(每条带 source / overridden)。
*
* 本文件是纯函数,不依赖 Prisma / Nest,便于单测覆盖合并口径。
*/
import type { DictItemSource, DictLayer, DictStatus } from '@lingxi/shared';
/** 参与合并的条目行(已完成 Prisma -> 领域字段映射) */
export interface DictMergeRow {
id: string;
code: string;
name: string;
parentCode: string | null;
industryId: string | null;
sortOrder: number;
status: DictStatus;
remark: string;
updatedAt: string;
updatedBy: string | null;
}
/** 一层来源:通用 / 父行业 / 本行业 */
export interface DictMergeLayer {
source: DictItemSource;
industryId: string | null;
label: string;
items: readonly DictMergeRow[];
}
/** 合并后的生效条目:source 表示来自哪一层,overridden 表示覆盖了更笼统层的同 code 条目 */
export interface DictEffectiveItem extends DictMergeRow {
source: DictItemSource;
overridden: boolean;
}
export interface DictMergeResult {
items: DictEffectiveItem[];
layers: DictLayer[];
/** 只统计 active 条目 */
effectiveCount: number;
/** 统计被更具体层覆盖的生效条目数(同 code 覆盖) */
overriddenCount: number;
/** 统计合并结果中 disabled 的条目数 */
disabledCount: number;
}
const LAYER_PRIORITY: Readonly<Record<DictItemSource, number>> = {
generic: 0,
parent: 1,
current: 2,
};
function layerRank(source: DictItemSource): number {
return LAYER_PRIORITY[source];
}
/** 排序:sortOrder asc, name asc, code asc(最后一个键保证结果稳定) */
function compareEffective(left: DictEffectiveItem, right: DictEffectiveItem): number {
if (left.sortOrder !== right.sortOrder) return left.sortOrder - right.sortOrder;
const byName = left.name.localeCompare(right.name, 'zh-CN');
if (byName !== 0) return byName;
return left.code.localeCompare(right.code);
}
/**
* 三层合并:低优先级层先落表,高优先级层同 code 覆盖并标记 overridden。
* 入参 layers 的顺序不影响结果(内部按 generic < parent < current 排序)。
*/
export function mergeDictLayers(layers: readonly DictMergeLayer[]): DictMergeResult {
const orderedLayers = [...layers].sort(
(left, right) => layerRank(left.source) - layerRank(right.source),
);
const merged = new Map<string, DictEffectiveItem>();
for (const layer of orderedLayers) {
for (const item of layer.items) {
const overridden = merged.has(item.code);
merged.set(item.code, { ...item, source: layer.source, overridden });
}
}
const items = [...merged.values()].sort(compareEffective);
const resultLayers: DictLayer[] = orderedLayers.map((layer) => ({
source: layer.source,
industryId: layer.industryId,
label: layer.label,
count: layer.items.length,
}));
return {
items,
layers: resultLayers,
effectiveCount: items.filter((item) => item.status === 'active').length,
overriddenCount: items.filter((item) => item.overridden).length,
disabledCount: items.filter((item) => item.status === 'disabled').length,
};
}
@@ -0,0 +1,133 @@
import { dictItemSnapshotSchema } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import {
mapDictGroup,
mapDictKind,
mapDictStatus,
toDictItem,
toDictSnapshot,
toDictTypeSummary,
} from './dict.mapper';
describe('dict.mapper · Prisma 行 -> 共享 DTO', () => {
it('类型统计与枚举转换:group / kind / status 映射为契约小写值', () => {
const summary = toDictTypeSummary(
{
code: 'script_direction',
name: '脚本方向',
groupKey: 'content',
kind: 'LIST',
description: '脚本与素材的结构化方向',
industryScoped: true,
builtin: true,
status: 'ACTIVE',
sortOrder: 60,
updatedAt: new Date('2026-11-04T02:00:00.000Z'),
updatedBy: '江岚',
},
{ itemCount: 11, activeItemCount: 10, industryCount: 2 },
);
expect(summary).toEqual({
code: 'script_direction',
name: '脚本方向',
group: 'content',
kind: 'list',
description: '脚本与素材的结构化方向',
industryScoped: true,
builtin: true,
status: 'active',
sortOrder: 60,
itemCount: 11,
activeItemCount: 10,
industryCount: 2,
updatedAt: '2026-11-04T02:00:00.000Z',
updatedBy: '江岚',
});
});
it('库内异常分组回退 content;kind / status 只映射两个分支', () => {
expect(mapDictGroup('unknown_group')).toBe('content');
expect(mapDictGroup('quality')).toBe('quality');
expect(mapDictKind('TREE')).toBe('tree');
expect(mapDictKind('LIST')).toBe('list');
expect(mapDictStatus('ACTIVE')).toBe('active');
expect(mapDictStatus('DISABLED')).toBe('disabled');
});
it('条目映射:source / overridden / parentName / industryPath / refCount=0', () => {
const item = toDictItem(
{
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
code: 'seeding',
name: '种草短视频',
parentCode: null,
industryId: 'ec',
sortOrder: 40,
status: 'ACTIVE',
remark: '父行业覆盖通用层',
updatedAt: new Date('2026-11-04T02:00:00.000Z'),
updatedBy: '江岚',
},
{
typeCode: 'script_direction',
source: 'parent',
overridden: true,
parentName: null,
industryPath: '电商',
},
);
expect(item).toMatchObject({
typeCode: 'script_direction',
code: 'seeding',
name: '种草短视频',
parentName: null,
industryId: 'ec',
industryPath: '电商',
status: 'active',
source: 'parent',
overridden: true,
refCount: 0,
updatedAt: '2026-11-04T02:00:00.000Z',
updatedBy: '江岚',
});
});
it('快照 R-10:先取快照再改字典名称,快照保留填写时名称', () => {
const item = toDictItem(
{
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
code: 'oral',
name: '口播',
parentCode: null,
industryId: null,
sortOrder: 10,
status: 'ACTIVE',
remark: '',
updatedAt: new Date('2026-11-04T02:00:00.000Z'),
updatedBy: '江岚',
},
{
typeCode: 'script_direction',
source: 'generic',
overridden: false,
parentName: null,
industryPath: null,
},
);
const snapshot = toDictSnapshot(item, '2026-11-04T10:00:00.000Z');
// 模拟字典改名 / 停用(历史引用方不感知)
const renamed = { ...item, name: '口播(改名后)', status: 'disabled' as const };
expect(renamed.name).not.toBe(snapshot.name);
expect(snapshot.name).toBe('口播');
expect(snapshot).toEqual({
typeCode: 'script_direction',
code: 'oral',
name: '口播',
parentCode: null,
industryId: null,
capturedAt: '2026-11-04T10:00:00.000Z',
});
expect(dictItemSnapshotSchema.parse(snapshot)).toEqual(snapshot);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* 数据字典 Prisma 行 -> 共享 DTO 映射(A4-BE-01 / A4-BE-04)。
*
* 只做字段映射与枚举转换;三层合并、行业路径与父级名称由 service 计算结果后传入。
*/
import {
DICT_GROUP_KEYS,
type DictGroupKey,
type DictItem,
type DictItemSnapshot,
type DictItemSource,
type DictStatus,
type DictTypeKind,
type DictTypeSummary,
toDictItemSnapshot,
} from '@lingxi/shared';
import type { DictKind, DictStatus as PrismaDictStatus } from '@prisma/client';
export function iso(value: Date): string {
return value.toISOString();
}
export function mapDictStatus(value: PrismaDictStatus): DictStatus {
return value === 'ACTIVE' ? 'active' : 'disabled';
}
export function mapDictKind(value: DictKind): DictTypeKind {
return value === 'TREE' ? 'tree' : 'list';
}
/** 分组键是共享契约的封闭枚举;库内异常值回退 content,避免整页 500 */
export function mapDictGroup(value: string): DictGroupKey {
return DICT_GROUP_KEYS.find((key): key is DictGroupKey => key === value) ?? 'content';
}
/** 类型统计(由 dict_items 聚合,A4 不跨模块取引用数) */
export interface DictTypeCounts {
itemCount: number;
activeItemCount: number;
industryCount: number;
}
export interface DictTypeRow {
code: string;
name: string;
groupKey: string;
kind: DictKind;
description: string;
industryScoped: boolean;
builtin: boolean;
status: PrismaDictStatus;
sortOrder: number;
updatedAt: Date;
updatedBy: string | null;
}
export function toDictTypeSummary(row: DictTypeRow, counts: DictTypeCounts): DictTypeSummary {
return {
code: row.code,
name: row.name,
group: mapDictGroup(row.groupKey),
kind: mapDictKind(row.kind),
description: row.description,
industryScoped: row.industryScoped,
builtin: row.builtin,
status: mapDictStatus(row.status),
sortOrder: row.sortOrder,
itemCount: counts.itemCount,
activeItemCount: counts.activeItemCount,
industryCount: counts.industryCount,
updatedAt: iso(row.updatedAt),
updatedBy: row.updatedBy,
};
}
export interface DictItemRow {
id: string;
code: string;
name: string;
parentCode: string | null;
industryId: string | null;
sortOrder: number;
status: PrismaDictStatus;
remark: string;
updatedAt: Date;
updatedBy: string | null;
}
export interface DictItemView {
/** 所属类型编码(行本身不带,由 service 传入) */
typeCode: string;
source: DictItemSource;
overridden: boolean;
parentName: string | null;
/** 行业路径:一级行业名 / 二级行业名;通用层为 null */
industryPath: string | null;
}
/** 字典引用数:A4 暂无引用表,B0 / B1 接入后改为真实统计(接口字段已保留) */
const DICT_ITEM_REF_COUNT = 0;
export function toDictItem(row: DictItemRow, view: DictItemView): DictItem {
return {
id: row.id,
typeCode: view.typeCode,
code: row.code,
name: row.name,
parentCode: row.parentCode,
parentName: view.parentName,
industryId: row.industryId,
industryPath: view.industryPath,
sortOrder: row.sortOrder,
status: mapDictStatus(row.status),
remark: row.remark,
source: view.source,
overridden: view.overridden,
refCount: DICT_ITEM_REF_COUNT,
updatedAt: iso(row.updatedAt),
updatedBy: row.updatedBy,
};
}
/**
* 填写时快照(R-10):引用字典的业务记录保存该快照,之后字典改名 / 停用不影响历史数据。
* 直接委托 shared 的 `toDictItemSnapshot`,保证前后端快照结构同源。
*/
export function toDictSnapshot(item: DictItem, capturedAt: string): DictItemSnapshot {
return toDictItemSnapshot(item, capturedAt);
}
@@ -0,0 +1,146 @@
import {
type CreateDictItemInput,
createDictItemInputSchema,
type CreateDictTypeInput,
createDictTypeInputSchema,
type DictItem,
type DictItemListQuery,
dictItemListQuerySchema,
type DictItemListResponse,
type DictTypeListQuery,
dictTypeListQuerySchema,
type DictTypeSummary,
ErrorCode,
type ListResponse,
Permission,
type SetDictTypeStatusInput,
setDictTypeStatusInputSchema,
type UpdateDictItemInput,
updateDictItemInputSchema,
type UpdateDictTypeInput,
updateDictTypeInputSchema,
} from '@lingxi/shared';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Inject,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppException } from '../../common/errors/app-exception';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import type { AuthContext } from '../../common/security/auth.types';
import { Audit, CurrentUser, RequirePermissions } from '../../common/security/decorators';
import { DictsService } from './dicts.service';
function requireAuth(auth: AuthContext | null): AuthContext {
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
return auth;
}
/**
* 数据字典管理(A4-BE-01 / A4-BE-04):全部端点要求 `dict:manage`;
* 写接口加 @Audit,Service 内通过 patchAudit 记录前后值。
*/
@ApiTags('catalog-dicts')
@ApiBearerAuth()
@Controller('dicts')
@RequirePermissions(Permission.DICT_MANAGE)
export class DictsController {
constructor(@Inject(DictsService) private readonly dicts: DictsService) {}
@Get()
@ApiOperation({ summary: '字典类型列表(分组顺序 + 排序;含条目 / 行业层统计)' })
list(
@Query(new ZodValidationPipe(dictTypeListQuerySchema)) query: DictTypeListQuery,
): Promise<ListResponse<DictTypeSummary>> {
return this.dicts.list(query);
}
@Get(':code')
@ApiOperation({ summary: '字典类型详情(不存在 40401)' })
detail(@Param('code') code: string): Promise<DictTypeSummary> {
return this.dicts.detail(code);
}
@Post()
@Audit('catalog.dict_type.create')
@ApiOperation({ summary: '新建字典类型(编码唯一;builtin=false)' })
create(
@Body(new ZodValidationPipe(createDictTypeInputSchema)) input: CreateDictTypeInput,
@CurrentUser() auth: AuthContext | null,
): Promise<DictTypeSummary> {
return this.dicts.create(input, requireAuth(auth).userId);
}
@Patch(':code')
@Audit('catalog.dict_type.update')
@ApiOperation({ summary: '更新字典类型(编码 / 形态 / 行业作用域不可改)' })
update(
@Param('code') code: string,
@Body(new ZodValidationPipe(updateDictTypeInputSchema)) input: UpdateDictTypeInput,
@CurrentUser() auth: AuthContext | null,
): Promise<DictTypeSummary> {
return this.dicts.update(code, input, requireAuth(auth).userId);
}
@Patch(':code/status')
@Audit('catalog.dict_type.status')
@ApiOperation({ summary: '启用 / 停用字典类型(内置类型允许停用)' })
setStatus(
@Param('code') code: string,
@Body(new ZodValidationPipe(setDictTypeStatusInputSchema)) input: SetDictTypeStatusInput,
@CurrentUser() auth: AuthContext | null,
): Promise<DictTypeSummary> {
return this.dicts.setStatus(code, input, requireAuth(auth).userId);
}
@Delete(':code')
@HttpCode(204)
@Audit('catalog.dict_type.delete')
@ApiOperation({ summary: '删除自定义字典类型(内置 / 有子条目时不可删)' })
async remove(@Param('code') code: string): Promise<void> {
await this.dicts.remove(code);
}
@Get(':code/items')
@ApiOperation({ summary: '生效条目列表(industry_id 三层合并;非行业类型忽略该参数)' })
listItems(
@Param('code') code: string,
@Query(new ZodValidationPipe(dictItemListQuerySchema)) query: DictItemListQuery,
): Promise<DictItemListResponse> {
return this.dicts.listItems(code, query);
}
@Post(':code/items')
@Audit('catalog.dict_item.create')
@ApiOperation({ summary: '新建字典条目(编码 / 父级 / 行业校验;编码创建后不可改)' })
createItem(
@Param('code') code: string,
@Body(new ZodValidationPipe(createDictItemInputSchema)) input: CreateDictItemInput,
@CurrentUser() auth: AuthContext | null,
): Promise<DictItem> {
return this.dicts.createItem(code, input, requireAuth(auth).userId);
}
@Patch(':code/items/:itemId')
@Audit('catalog.dict_item.update')
@ApiOperation({ summary: '更新字典条目(仅名称 / 排序 / 启停 / 备注)' })
updateItem(
@Param('code') code: string,
@Param('itemId') itemId: string,
@Body(new ZodValidationPipe(updateDictItemInputSchema)) input: UpdateDictItemInput,
@CurrentUser() auth: AuthContext | null,
): Promise<DictItem> {
return this.dicts.updateItem(code, itemId, input, requireAuth(auth).userId);
}
}
@@ -0,0 +1,562 @@
import { ErrorCode } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../common/errors/app-exception';
import type { RequestContextService } from '../../common/security/request-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { DictsService } from './dicts.service';
const NOW = new Date('2026-11-04T02:00:00.000Z');
interface DictTypeFixture {
id: string;
code: string;
name: string;
groupKey: string;
kind: 'LIST' | 'TREE';
description: string;
industryScoped: boolean;
builtin: boolean;
status: 'ACTIVE' | 'DISABLED';
sortOrder: number;
updatedAt: Date;
updatedBy: string | null;
}
function typeFixture(overrides: Partial<DictTypeFixture> = {}): DictTypeFixture {
return {
id: 't1',
code: 'script_direction',
name: '脚本方向',
groupKey: 'content',
kind: 'LIST',
description: '脚本与素材的结构化方向',
industryScoped: false,
builtin: true,
status: 'ACTIVE',
sortOrder: 60,
updatedAt: NOW,
updatedBy: '江岚',
...overrides,
};
}
interface ItemFixture {
id: string;
typeId: string;
code: string;
name: string;
parentCode: string | null;
industryId: string | null;
sortOrder: number;
status: 'ACTIVE' | 'DISABLED';
remark: string;
updatedAt: Date;
updatedBy: string | null;
}
function itemFixture(overrides: Partial<ItemFixture> = {}): ItemFixture {
return {
id: `i-${overrides.code ?? 'oral'}-${overrides.industryId ?? 'generic'}`,
typeId: 't1',
code: 'oral',
name: '口播',
parentCode: null,
industryId: null,
sortOrder: 10,
status: 'ACTIVE',
remark: '',
updatedAt: NOW,
updatedBy: '江岚',
...overrides,
};
}
interface Harness {
service: DictsService;
prisma: {
dictType: Record<string, ReturnType<typeof vi.fn>>;
dictItem: Record<string, ReturnType<typeof vi.fn>>;
industry: Record<string, ReturnType<typeof vi.fn>>;
user: Record<string, ReturnType<typeof vi.fn>>;
};
patchAudit: ReturnType<typeof vi.fn>;
}
function harness(
prismaInput: Record<string, Record<string, ReturnType<typeof vi.fn>>> = {},
): Harness {
const dictType = {
findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
...prismaInput.dictType,
};
const dictItem = {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
findUniqueOrThrow: vi.fn(),
create: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
count: vi.fn().mockResolvedValue(0),
groupBy: vi.fn().mockResolvedValue([]),
...prismaInput.dictItem,
};
const industry = {
findUnique: vi.fn().mockResolvedValue(null),
...prismaInput.industry,
};
const user = {
findUnique: vi.fn().mockResolvedValue({ name: '江岚' }),
...prismaInput.user,
};
const patchAudit = vi.fn();
const service = new DictsService(
{ dictType, dictItem, industry, user } as unknown as PrismaService,
{ patchAudit } as unknown as RequestContextService,
);
return { service, prisma: { dictType, dictItem, industry, user }, patchAudit };
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
describe('DictsService · 类型列表 / 详情', () => {
it('按 DICT_GROUPS 顺序 + sortOrder 排序,并返回条目 / 启用 / 行业层统计', async () => {
const rows = [
typeFixture({
id: 't-quality',
code: 'quality_banned',
name: '禁用词',
groupKey: 'quality',
sortOrder: 10,
}),
typeFixture({ id: 't-content', code: 'hook_type', name: '钩子类型', sortOrder: 40 }),
typeFixture({ id: 't-content-2', code: 'shot_type', name: '景别', sortOrder: 50 }),
];
const { service } = harness({
dictType: { findMany: vi.fn().mockResolvedValue(rows) },
dictItem: {
groupBy: vi.fn().mockResolvedValue([
{ typeId: 't-content', status: 'ACTIVE', industryId: null, _count: { _all: 6 } },
{ typeId: 't-content', status: 'DISABLED', industryId: 'ec', _count: { _all: 1 } },
{ typeId: 't-content-2', status: 'ACTIVE', industryId: null, _count: { _all: 3 } },
{ typeId: 't-quality', status: 'ACTIVE', industryId: null, _count: { _all: 15 } },
]),
},
});
const result = await service.list({ status: 'all', limit: 20 });
expect(result.items.map((item) => item.code)).toEqual([
'hook_type',
'shot_type',
'quality_banned',
]);
expect(result.items[0]).toMatchObject({
itemCount: 7,
activeItemCount: 6,
industryCount: 1,
group: 'content',
kind: 'list',
status: 'active',
});
expect(result.next_cursor).toBeNull();
});
it('游标分页:limit 截断并返回 next_cursor;非法游标 40001', async () => {
const rows = [
typeFixture({ id: 't1', code: 'a_type', groupKey: 'content', sortOrder: 10 }),
typeFixture({ id: 't2', code: 'b_type', groupKey: 'content', sortOrder: 20 }),
typeFixture({ id: 't3', code: 'c_type', groupKey: 'content', sortOrder: 30 }),
];
const { service } = harness({ dictType: { findMany: vi.fn().mockResolvedValue(rows) } });
const first = await service.list({ status: 'all', limit: 2 });
expect(first.items.map((item) => item.code)).toEqual(['a_type', 'b_type']);
expect(first.next_cursor).toBe('b_type');
const second = await service.list({ status: 'all', limit: 2, cursor: 'b_type' });
expect(second.items.map((item) => item.code)).toEqual(['c_type']);
expect(second.next_cursor).toBeNull();
await expectRejected(
service.list({ status: 'all', limit: 2, cursor: 'missing' }),
ErrorCode.PARAM_INVALID,
);
});
it('详情不存在返回 40401', async () => {
const { service } = harness();
await expectRejected(service.detail('missing_type'), ErrorCode.NOT_FOUND);
});
});
describe('DictsService · 类型写操作', () => {
it('新建:编码重复 40902;成功后 builtin=false、updatedBy 为操作人姓名、写审计', async () => {
const duplicate = harness({
dictType: { findUnique: vi.fn().mockResolvedValue({ id: 'existing' }) },
});
await expectRejected(
duplicate.service.create(
{
code: 'new_type',
name: '新类型',
group: 'content',
kind: 'list',
description: '',
industryScoped: false,
sortOrder: 10,
},
'u1',
),
ErrorCode.DUPLICATE_SUBMIT,
);
const created = typeFixture({
id: 't-new',
code: 'new_type',
name: '新类型',
builtin: false,
sortOrder: 10,
updatedBy: '江岚',
});
const success = harness({
dictType: {
findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(created),
create: vi.fn().mockResolvedValue({}),
},
});
const summary = await success.service.create(
{
code: 'new_type',
name: '新类型',
group: 'content',
kind: 'list',
description: '',
industryScoped: false,
sortOrder: 10,
},
'u1',
);
expect(summary).toMatchObject({ code: 'new_type', builtin: false, updatedBy: '江岚' });
expect(success.prisma.dictType.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ builtin: false, status: 'ACTIVE', updatedBy: '江岚' }),
}),
);
expect(success.patchAudit).toHaveBeenCalledWith(
expect.objectContaining({ targetType: 'dict_type', before: null }),
);
});
it('内置类型允许改名 / 停用;删除内置类型 40001', async () => {
const updated = typeFixture({ name: '脚本方向(新)', updatedBy: '江岚' });
const edit = harness({
dictType: {
findUnique: vi.fn().mockResolvedValueOnce(typeFixture()).mockResolvedValue(updated),
},
});
const summary = await edit.service.update('script_direction', { name: '脚本方向(新)' }, 'u1');
expect(summary.name).toBe('脚本方向(新)');
expect(edit.prisma.dictType.update).toHaveBeenCalled();
const disabled = typeFixture({ status: 'DISABLED' });
const status = harness({
dictType: {
findUnique: vi.fn().mockResolvedValueOnce(typeFixture()).mockResolvedValue(disabled),
},
});
await expect(
status.service.setStatus('script_direction', { status: 'disabled' }, 'u1'),
).resolves.toMatchObject({ status: 'disabled' });
const remove = harness({ dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) } });
await expectRejected(remove.service.remove('script_direction'), ErrorCode.PARAM_INVALID);
});
it('自定义类型有子条目不可删,无条目可删并写审计', async () => {
const withItems = harness({
dictType: {
findUnique: vi.fn().mockResolvedValue(typeFixture({ builtin: false, id: 't-custom' })),
},
dictItem: { count: vi.fn().mockResolvedValue(3) },
});
await expectRejected(withItems.service.remove('custom_type'), ErrorCode.PARAM_INVALID);
expect(withItems.prisma.dictType.delete).not.toHaveBeenCalled();
const empty = harness({
dictType: {
findUnique: vi.fn().mockResolvedValue(typeFixture({ builtin: false, id: 't-custom' })),
},
});
await expect(empty.service.remove('custom_type')).resolves.toBeUndefined();
expect(empty.prisma.dictType.delete).toHaveBeenCalledWith({ where: { id: 't-custom' } });
expect(empty.patchAudit).toHaveBeenCalledWith(
expect.objectContaining({ targetType: 'dict_type', after: null }),
);
});
});
describe('DictsService · 条目三层合并', () => {
it('非行业作用域类型强制忽略 industry_id,只返回通用层', async () => {
const { service, prisma } = harness({
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: false })) },
dictItem: {
findMany: vi
.fn()
.mockResolvedValue([
itemFixture({ code: 'oral', name: '口播' }),
itemFixture({ code: 'drama', name: '剧情', sortOrder: 20 }),
]),
},
});
const result = await service.listItems('script_direction', { industry_id: 'ec' });
expect(result.items).toHaveLength(2);
expect(result.items.every((item) => item.source === 'generic')).toBe(true);
expect(result.layers).toEqual([
{ source: 'generic', industryId: null, label: '通用', count: 2 },
]);
expect(prisma.industry.findUnique).not.toHaveBeenCalled();
});
it('行业不存在返回 40401;一级行业只叠加通用 + 本行业', async () => {
const missing = harness({
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: true })) },
});
await expectRejected(
missing.service.listItems('script_direction', { industry_id: 'missing' }),
ErrorCode.NOT_FOUND,
);
const root = harness({
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: true })) },
industry: {
findUnique: vi
.fn()
.mockResolvedValue({ id: 'ec', name: '电商', parentId: null, parent: null }),
},
dictItem: {
findMany: vi
.fn()
.mockResolvedValue([
itemFixture({ code: 'oral', name: '口播' }),
itemFixture({ code: 'live_clip', name: '直播切片', industryId: 'ec', sortOrder: 60 }),
]),
},
});
const result = await root.service.listItems('script_direction', { industry_id: 'ec' });
expect(result.layers.map((layer) => layer.source)).toEqual(['generic', 'current']);
expect(result.items.map((item) => [item.code, item.source])).toEqual([
['oral', 'generic'],
['live_clip', 'current'],
]);
});
it('二级行业三层合并:通用 5 + 父行业 2 + 本行业 1,同 code 覆盖后 7 条', async () => {
const { service } = harness({
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: true })) },
industry: {
findUnique: vi.fn().mockResolvedValue({
id: 'leaf',
name: '服饰鞋包',
parentId: 'ec',
parent: { id: 'ec', name: '电商' },
}),
},
dictItem: {
findMany: vi
.fn()
.mockResolvedValue([
itemFixture({ code: 'oral', name: '口播' }),
itemFixture({ code: 'drama', name: '剧情', sortOrder: 20 }),
itemFixture({ code: 'review', name: '测评', sortOrder: 30 }),
itemFixture({ code: 'seeding', name: '种草', sortOrder: 40 }),
itemFixture({ code: 'scene', name: '情景', sortOrder: 50 }),
itemFixture({ code: 'live_clip', name: '直播切片', industryId: 'ec', sortOrder: 60 }),
itemFixture({ code: 'seeding', name: '种草短视频', industryId: 'ec', sortOrder: 40 }),
itemFixture({ code: 'outfit', name: '穿搭教程', industryId: 'leaf', sortOrder: 70 }),
]),
},
});
const result = await service.listItems('script_direction', { industry_id: 'leaf' });
expect(result.items).toHaveLength(7);
expect(result.effectiveCount).toBe(7);
expect(result.overriddenCount).toBe(1);
expect(result.layers).toEqual([
{ source: 'generic', industryId: null, label: '通用', count: 5 },
{ source: 'parent', industryId: 'ec', label: '电商', count: 2 },
{ source: 'current', industryId: 'leaf', label: '服饰鞋包', count: 1 },
]);
const seeding = result.items.find((item) => item.code === 'seeding');
expect(seeding).toMatchObject({
name: '种草短视频',
source: 'parent',
overridden: true,
industryPath: '电商',
});
expect(result.items.find((item) => item.code === 'outfit')).toMatchObject({
source: 'current',
industryPath: '电商 / 服饰鞋包',
});
expect(result.items.find((item) => item.code === 'oral')).toMatchObject({
industryPath: null,
parentName: null,
refCount: 0,
});
});
});
describe('DictsService · 条目写操作', () => {
it('新建条目:非行业类型传 industryId / 列表型传 parentCode / 父级不存在 / 重复编码均 40001 或 40902', async () => {
const nonScoped = harness({
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: false })) },
});
await expectRejected(
nonScoped.service.createItem(
'script_direction',
{
code: 'new_item',
name: '新条目',
parentCode: null,
industryId: 'ec',
sortOrder: 10,
status: 'active',
remark: '',
},
'u1',
),
ErrorCode.PARAM_INVALID,
);
const listType = harness({
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ kind: 'LIST' })) },
});
await expectRejected(
listType.service.createItem(
'script_direction',
{
code: 'new_item',
name: '新条目',
parentCode: 'oral',
industryId: null,
sortOrder: 10,
status: 'active',
remark: '',
},
'u1',
),
ErrorCode.PARAM_INVALID,
);
const missingParent = harness({
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ kind: 'TREE' })) },
});
await expectRejected(
missingParent.service.createItem(
'script_direction',
{
code: 'new_item',
name: '新条目',
parentCode: 'missing',
industryId: null,
sortOrder: 10,
status: 'active',
remark: '',
},
'u1',
),
ErrorCode.PARAM_INVALID,
);
const duplicate = harness({
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) },
dictItem: { findFirst: vi.fn().mockResolvedValue({ id: 'existing' }) },
});
await expectRejected(
duplicate.service.createItem(
'script_direction',
{
code: 'oral',
name: '口播',
parentCode: null,
industryId: null,
sortOrder: 10,
status: 'active',
remark: '',
},
'u1',
),
ErrorCode.DUPLICATE_SUBMIT,
);
});
it('新建条目成功:写审计前后值并返回条目 DTO', async () => {
const row = itemFixture({ id: 'i-new', code: 'new_item', name: '新条目' });
const { service, prisma, patchAudit } = harness({
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) },
dictItem: { findUniqueOrThrow: vi.fn().mockResolvedValue(row) },
});
const item = await service.createItem(
'script_direction',
{
code: 'new_item',
name: '新条目',
parentCode: null,
industryId: null,
sortOrder: 10,
status: 'active',
remark: '',
},
'u1',
);
expect(item).toMatchObject({
code: 'new_item',
source: 'generic',
overridden: false,
refCount: 0,
});
expect(prisma.dictItem.create).toHaveBeenCalled();
expect(patchAudit).toHaveBeenCalledWith(
expect.objectContaining({ targetType: 'dict_item', before: null }),
);
});
it('编辑条目:不存在 40401;成功只更新名称 / 排序 / 启停 / 备注并写前后值', async () => {
const missing = harness({
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) },
});
await expectRejected(
missing.service.updateItem('script_direction', 'missing', { name: '新名' }, 'u1'),
ErrorCode.NOT_FOUND,
);
const existing = itemFixture({ id: 'i1', code: 'oral', name: '口播' });
const updated = itemFixture({ id: 'i1', code: 'oral', name: '口播(改)', status: 'DISABLED' });
const { service, patchAudit } = harness({
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) },
dictItem: {
findFirst: vi.fn().mockResolvedValue(existing),
findUniqueOrThrow: vi.fn().mockResolvedValue(updated),
},
});
const item = await service.updateItem(
'script_direction',
'i1',
{ name: '口播(改)', status: 'disabled' },
'u1',
);
expect(item).toMatchObject({ code: 'oral', name: '口播(改)', status: 'disabled' });
expect(patchAudit).toHaveBeenCalledWith(
expect.objectContaining({
targetType: 'dict_item',
before: expect.objectContaining({ name: '口播' }),
after: expect.objectContaining({ name: '口播(改)' }),
}),
);
});
});
@@ -0,0 +1,619 @@
/* eslint-disable max-lines-per-function -- 字典类型 / 条目 / 三层合并集中在一个服务,按方法拆分后仍需共享校验与审计口径 */
import {
type CreateDictItemInput,
type CreateDictTypeInput,
DICT_GROUPS,
type DictGroupKey,
type DictItem,
type DictItemListQuery,
type DictItemListResponse,
type DictItemSource,
type DictTypeListQuery,
type DictTypeSummary,
ErrorCode,
type ListResponse,
type SetDictTypeStatusInput,
ulid,
type UpdateDictItemInput,
type UpdateDictTypeInput,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import type { DictItem as PrismaDictItem, DictType as PrismaDictType } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import { RequestContextService } from '../../common/security/request-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import {
type DictTypeCounts,
iso,
mapDictGroup,
mapDictStatus,
toDictItem,
toDictTypeSummary,
} from './dict.mapper';
import { type DictMergeLayer, type DictMergeRow, mergeDictLayers } from './dict-merge';
interface IndustryLayerContext {
layers: { source: DictItemSource; industryId: string | null; label: string }[];
industryIds: string[];
/** 行业 id -> 展示路径(一级名 / 二级名) */
paths: Map<string, string>;
}
interface DictTypeAuditSnapshot {
code: string;
name: string;
group: DictGroupKey;
description: string;
sortOrder: number;
status: 'active' | 'disabled';
}
const GROUP_ORDER: ReadonlyMap<DictGroupKey, number> = new Map(
DICT_GROUPS.map((group, index) => [group.key, index]),
);
const EMPTY_COUNTS: DictTypeCounts = { itemCount: 0, activeItemCount: 0, industryCount: 0 };
function isUniqueViolation(error: unknown): boolean {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
}
function layerCodeKey(industryId: string | null, code: string): string {
return `${industryId ?? ''}\u0000${code}`;
}
function typeSortRank(row: PrismaDictType): number {
return GROUP_ORDER.get(mapDictGroup(row.groupKey)) ?? DICT_GROUPS.length;
}
function compareTypeRows(left: PrismaDictType, right: PrismaDictType): number {
const byGroup = typeSortRank(left) - typeSortRank(right);
if (byGroup !== 0) return byGroup;
if (left.sortOrder !== right.sortOrder) return left.sortOrder - right.sortOrder;
return left.code.localeCompare(right.code);
}
function typeRowAudit(row: PrismaDictType): DictTypeAuditSnapshot {
return {
code: row.code,
name: row.name,
group: mapDictGroup(row.groupKey),
description: row.description,
sortOrder: row.sortOrder,
status: mapDictStatus(row.status),
};
}
function itemAudit(row: PrismaDictItem): Record<string, unknown> {
return {
code: row.code,
name: row.name,
parentCode: row.parentCode,
industryId: row.industryId,
sortOrder: row.sortOrder,
status: mapDictStatus(row.status),
remark: row.remark,
};
}
function toMergeRow(row: PrismaDictItem): DictMergeRow {
return {
id: row.id,
code: row.code,
name: row.name,
parentCode: row.parentCode,
industryId: row.industryId,
sortOrder: row.sortOrder,
status: mapDictStatus(row.status),
remark: row.remark,
updatedAt: iso(row.updatedAt),
updatedBy: row.updatedBy,
};
}
/**
* 数据字典(A4-BE-01 / A4-BE-04):
* - 类型:列表 / 详情 / 新建 / 编辑 / 启停 / 删除(编码、形态、行业作用域创建后不可改);
* - 条目:行业视角三层合并(通用 → 父行业 → 本行业)、新建 / 编辑;
* - 写操作统一 `RequestContextService.patchAudit` 记录前后值;
* - 快照由 `dict.mapper#toDictSnapshot` 提供,业务记录落库在 B0 / M1 接入(R-10)。
*/
@Injectable()
export class DictsService {
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
async list(query: DictTypeListQuery): Promise<ListResponse<DictTypeSummary>> {
const where: Prisma.DictTypeWhereInput = {
...(query.keyword === undefined
? {}
: {
OR: [
{ name: { contains: query.keyword, mode: 'insensitive' } },
{ code: { contains: query.keyword, mode: 'insensitive' } },
],
}),
...(query.group === undefined ? {} : { groupKey: query.group }),
...(query.status === 'all'
? {}
: { status: query.status === 'active' ? 'ACTIVE' : 'DISABLED' }),
};
const rows = await this.prisma.dictType.findMany({ where });
const counts = await this.countsByType(rows.map((row) => row.id));
const sorted = [...rows].sort(compareTypeRows);
const start = this.cursorStart(sorted, query.cursor);
const page = sorted.slice(start, start + query.limit);
const hasMore = start + query.limit < sorted.length;
return {
items: page.map((row) => toDictTypeSummary(row, counts.get(row.id) ?? EMPTY_COUNTS)),
next_cursor: hasMore ? (page[page.length - 1]?.code ?? null) : null,
};
}
async detail(code: string): Promise<DictTypeSummary> {
const row = await this.findTypeOrThrow(code);
const counts = await this.countsByType([row.id]);
return toDictTypeSummary(row, counts.get(row.id) ?? EMPTY_COUNTS);
}
async create(input: CreateDictTypeInput, actorId: string): Promise<DictTypeSummary> {
const duplicate = await this.prisma.dictType.findUnique({
where: { code: input.code },
select: { id: true },
});
if (duplicate !== null) {
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '字典类型编码已存在' });
}
const id = ulid();
const updatedBy = await this.actorName(actorId);
try {
await this.prisma.dictType.create({
data: {
id,
code: input.code,
name: input.name,
groupKey: input.group,
kind: input.kind === 'tree' ? 'TREE' : 'LIST',
description: input.description,
industryScoped: input.industryScoped,
builtin: false,
status: 'ACTIVE',
sortOrder: input.sortOrder,
updatedBy,
},
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '字典类型编码已存在' });
}
throw error;
}
const summary = await this.detail(input.code);
this.requestContext.patchAudit({
targetType: 'dict_type',
targetId: id,
before: null,
after: summary,
});
return summary;
}
async update(
code: string,
input: UpdateDictTypeInput,
actorId: string,
): Promise<DictTypeSummary> {
const existing = await this.findTypeOrThrow(code);
const updatedBy = await this.actorName(actorId);
await this.prisma.dictType.update({
where: { id: existing.id },
data: {
...(input.name === undefined ? {} : { name: input.name }),
...(input.group === undefined ? {} : { groupKey: input.group }),
...(input.description === undefined ? {} : { description: input.description }),
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
updatedBy,
},
});
const summary = await this.detail(code);
this.requestContext.patchAudit({
targetType: 'dict_type',
targetId: existing.id,
before: typeRowAudit(existing),
after: {
code: summary.code,
name: summary.name,
group: summary.group,
description: summary.description,
sortOrder: summary.sortOrder,
status: summary.status,
},
});
return summary;
}
async setStatus(
code: string,
input: SetDictTypeStatusInput,
actorId: string,
): Promise<DictTypeSummary> {
const existing = await this.findTypeOrThrow(code);
const updatedBy = await this.actorName(actorId);
await this.prisma.dictType.update({
where: { id: existing.id },
data: {
status: input.status === 'active' ? 'ACTIVE' : 'DISABLED',
updatedBy,
},
});
const summary = await this.detail(code);
this.requestContext.patchAudit({
targetType: 'dict_type',
targetId: existing.id,
before: typeRowAudit(existing),
after: {
code: summary.code,
name: summary.name,
group: summary.group,
description: summary.description,
sortOrder: summary.sortOrder,
status: summary.status,
},
});
return summary;
}
async remove(code: string): Promise<void> {
const existing = await this.findTypeOrThrow(code);
if (existing.builtin) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '内置类型不可删除' });
}
const itemCount = await this.prisma.dictItem.count({ where: { typeId: existing.id } });
if (itemCount > 0) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: '请先删除条目或改为停用',
details: { itemCount },
});
}
await this.prisma.dictType.delete({ where: { id: existing.id } });
this.requestContext.patchAudit({
targetType: 'dict_type',
targetId: existing.id,
before: typeRowAudit(existing),
after: null,
});
}
async listItems(code: string, query: DictItemListQuery): Promise<DictItemListResponse> {
const type = await this.findTypeOrThrow(code);
const view = await this.industryView(type, query.industry_id);
const rows = await this.prisma.dictItem.findMany({
where: {
typeId: type.id,
OR: [
{ industryId: null },
...(view.industryIds.length === 0 ? [] : [{ industryId: { in: view.industryIds } }]),
],
},
});
const mergeLayers: DictMergeLayer[] = view.layers.map((definition) => ({
source: definition.source,
industryId: definition.industryId,
label: definition.label,
items: rows
.filter((row) => row.industryId === definition.industryId)
.map((row) => toMergeRow(row)),
}));
const merged = mergeDictLayers(mergeLayers);
const nameByLayerCode = new Map<string, string>();
for (const row of rows) {
nameByLayerCode.set(layerCodeKey(row.industryId, row.code), row.name);
}
const effectiveNameByCode = new Map(merged.items.map((item) => [item.code, item.name]));
const items: DictItem[] = merged.items.map((item) => ({
id: item.id,
typeCode: type.code,
code: item.code,
name: item.name,
parentCode: item.parentCode,
parentName:
item.parentCode === null
? null
: (nameByLayerCode.get(layerCodeKey(item.industryId, item.parentCode)) ??
effectiveNameByCode.get(item.parentCode) ??
null),
industryId: item.industryId,
industryPath: item.industryId === null ? null : (view.paths.get(item.industryId) ?? null),
sortOrder: item.sortOrder,
status: item.status,
remark: item.remark,
source: item.source,
overridden: item.overridden,
// 引用数:A4 尚无引用表,B0 行业资产 / B1 素材接入后改为真实统计(接口字段已保留)
refCount: 0,
updatedAt: item.updatedAt,
updatedBy: item.updatedBy,
}));
return {
items,
next_cursor: null,
layers: merged.layers,
effectiveCount: merged.effectiveCount,
overriddenCount: merged.overriddenCount,
disabledCount: merged.disabledCount,
};
}
async createItem(code: string, input: CreateDictItemInput, actorId: string): Promise<DictItem> {
const type = await this.findTypeOrThrow(code);
const industryId = input.industryId ?? null;
if (!type.industryScoped && industryId !== null) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: '该字典类型不支持行业作用域,不能指定行业',
});
}
if (industryId !== null) {
await this.assertIndustryExists(industryId);
}
const parentCode = input.parentCode ?? null;
let parentName: string | null = null;
if (parentCode !== null) {
if (type.kind !== 'TREE') {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: '列表型字典不允许设置父级编码',
});
}
const parent = await this.prisma.dictItem.findFirst({
where: { typeId: type.id, code: parentCode, industryId },
select: { name: true },
});
if (parent === null) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: '父级条目不存在或不属于同一行业层',
});
}
parentName = parent.name;
}
const duplicate = await this.prisma.dictItem.findFirst({
where: { typeId: type.id, code: input.code, industryId },
select: { id: true },
});
if (duplicate !== null) {
throw new AppException({
code: ErrorCode.DUPLICATE_SUBMIT,
message: '同一字典类型与行业层下条目编码已存在',
});
}
const id = ulid();
const updatedBy = await this.actorName(actorId);
try {
await this.prisma.dictItem.create({
data: {
id,
typeId: type.id,
code: input.code,
name: input.name,
parentCode,
industryId,
sortOrder: input.sortOrder,
status: input.status === 'active' ? 'ACTIVE' : 'DISABLED',
remark: input.remark,
updatedBy,
},
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new AppException({
code: ErrorCode.DUPLICATE_SUBMIT,
message: '同一字典类型与行业层下条目编码已存在',
});
}
throw error;
}
const row = await this.prisma.dictItem.findUniqueOrThrow({ where: { id } });
const item = toDictItem(row, {
typeCode: type.code,
source: industryId === null ? 'generic' : 'current',
overridden: false,
parentName,
industryPath: await this.industryPathOf(industryId),
});
this.requestContext.patchAudit({
targetType: 'dict_item',
targetId: id,
before: null,
after: itemAudit(row),
});
return item;
}
async updateItem(
code: string,
itemId: string,
input: UpdateDictItemInput,
actorId: string,
): Promise<DictItem> {
const type = await this.findTypeOrThrow(code);
const existing = await this.prisma.dictItem.findFirst({
where: { id: itemId, typeId: type.id },
});
if (existing === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字典条目不存在' });
}
const updatedBy = await this.actorName(actorId);
await this.prisma.dictItem.update({
where: { id: existing.id },
data: {
...(input.name === undefined ? {} : { name: input.name }),
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
...(input.status === undefined
? {}
: { status: input.status === 'active' ? 'ACTIVE' : 'DISABLED' }),
...(input.remark === undefined ? {} : { remark: input.remark }),
updatedBy,
},
});
const row = await this.prisma.dictItem.findUniqueOrThrow({ where: { id: existing.id } });
const parent =
row.parentCode === null
? null
: await this.prisma.dictItem.findFirst({
where: { typeId: type.id, code: row.parentCode, industryId: row.industryId },
select: { name: true },
});
const item = toDictItem(row, {
typeCode: type.code,
source: row.industryId === null ? 'generic' : 'current',
overridden: false,
parentName: parent?.name ?? null,
industryPath: await this.industryPathOf(row.industryId),
});
this.requestContext.patchAudit({
targetType: 'dict_item',
targetId: row.id,
before: itemAudit(existing),
after: itemAudit(row),
});
return item;
}
private async findTypeOrThrow(code: string): Promise<PrismaDictType> {
const row = await this.prisma.dictType.findUnique({ where: { code } });
if (row === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字典类型不存在' });
}
return row;
}
private cursorStart(rows: readonly PrismaDictType[], cursor: string | undefined): number {
if (cursor === undefined) return 0;
const index = rows.findIndex((row) => row.code === cursor);
if (index < 0) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '分页游标无效' });
}
return index + 1;
}
/** 按类型聚合:总条目、启用条目、出现过的行业层数(distinct industry_id) */
private async countsByType(typeIds: readonly string[]): Promise<Map<string, DictTypeCounts>> {
if (typeIds.length === 0) return new Map();
const grouped = await this.prisma.dictItem.groupBy({
by: ['typeId', 'status', 'industryId'],
where: { typeId: { in: [...typeIds] } },
_count: { _all: true },
});
const counts = new Map<
string,
{ itemCount: number; activeItemCount: number; industries: Set<string> }
>();
for (const row of grouped) {
const entry = counts.get(row.typeId) ?? {
itemCount: 0,
activeItemCount: 0,
industries: new Set<string>(),
};
entry.itemCount += row._count._all;
if (row.status === 'ACTIVE') entry.activeItemCount += row._count._all;
if (row.industryId !== null) entry.industries.add(row.industryId);
counts.set(row.typeId, entry);
}
return new Map(
[...counts].map(([typeId, entry]) => [
typeId,
{
itemCount: entry.itemCount,
activeItemCount: entry.activeItemCount,
industryCount: entry.industries.size,
},
]),
);
}
/**
* 行业视角:非行业作用域类型强制忽略 industry_id(只返回通用层);
* 一级行业 = 通用 + 本行业;二级行业 = 通用 + 父行业 + 本行业。
*/
private async industryView(
type: PrismaDictType,
industryId: string | undefined,
): Promise<IndustryLayerContext> {
const generic: { source: DictItemSource; industryId: string | null; label: string } = {
source: 'generic',
industryId: null,
label: '通用',
};
if (!type.industryScoped || industryId === undefined) {
return { layers: [generic], industryIds: [], paths: new Map() };
}
const industry = await this.prisma.industry.findUnique({
where: { id: industryId },
include: { parent: true },
});
if (industry === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '行业不存在' });
}
const paths = new Map<string, string>();
paths.set(
industry.id,
industry.parent === null ? industry.name : `${industry.parent.name} / ${industry.name}`,
);
const industryIds = [industry.id];
const layers: { source: DictItemSource; industryId: string | null; label: string }[] = [
generic,
];
if (industry.parent !== null) {
layers.push({
source: 'parent',
industryId: industry.parent.id,
label: industry.parent.name,
});
industryIds.push(industry.parent.id);
paths.set(industry.parent.id, industry.parent.name);
}
layers.push({ source: 'current', industryId: industry.id, label: industry.name });
return { layers, industryIds, paths };
}
private async assertIndustryExists(industryId: string): Promise<void> {
const industry = await this.prisma.industry.findUnique({
where: { id: industryId },
select: { id: true },
});
if (industry === null) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '行业不存在' });
}
}
private async industryPathOf(industryId: string | null): Promise<string | null> {
if (industryId === null) return null;
const industry = await this.prisma.industry.findUnique({
where: { id: industryId },
include: { parent: true },
});
if (industry === null) return null;
return industry.parent === null ? industry.name : `${industry.parent.name} / ${industry.name}`;
}
/** updated_by 存展示名(种子 / 前端均按姓名展示);用户不存在时回退 actorId,避免写入丢失 */
private async actorName(actorId: string): Promise<string> {
const user = await this.prisma.user.findUnique({
where: { id: actorId },
select: { name: true },
});
return user?.name ?? actorId;
}
}
@@ -0,0 +1,451 @@
import { API_PREFIX, ErrorCode, Permission, ulid } from '@lingxi/shared';
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp, type CreatedApiApp } from '../../bootstrap';
import { loadDotenv } from '../../config/load-dotenv';
const PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
const PASSWORD = 'lingxi2026';
const PREFIX = 'itqa3.';
const API = API_PREFIX;
loadDotenv();
const prisma = new PrismaClient();
interface TokenBody {
accessToken: string;
}
interface JsonResult<T> {
status: number;
body: T;
}
interface FixtureUser {
id: string;
account: string;
}
let phoneCounter = 0;
let app: CreatedApiApp['app'];
let superToken = '';
let scopedToken = '';
let approverToken = '';
let scopedUserId = '';
let rootIndustryId = '';
let leafIndustryId = '';
let customerId = '';
let groupId = '';
const createdUserIds: string[] = [];
const createdRoleIds: string[] = [];
function nextPhone(): string {
phoneCounter += 1;
return `136${String(phoneCounter).padStart(8, '0')}`;
}
async function createRole(
suffix: string,
permissions: string[],
dataScope: { type: string; targetId?: string },
): Promise<string> {
const id = ulid();
await prisma.role.create({
data: {
id,
code: `${PREFIX}role_${suffix}`,
name: `${PREFIX}角色-${suffix}`,
description: 'A3-QA-01 集成测试角色',
status: 'ACTIVE',
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
dataScopes: {
create: [{ id: ulid(), scopeType: dataScope.type, targetId: dataScope.targetId ?? null }],
},
},
});
createdRoleIds.push(id);
return id;
}
async function createUser(suffix: string, roleId: string): Promise<FixtureUser> {
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
const account = `${PREFIX}${suffix}`;
const id = ulid();
await prisma.user.create({
data: {
id,
name: `A3 集成测试${suffix}`,
account,
email: `${account}@lingxi.cn`,
phone: nextPhone(),
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId }] },
},
});
createdUserIds.push(id);
return { id, account };
}
async function login(account: string): Promise<string> {
const response = await app.inject({
method: 'POST',
url: `${API}/auth/login`,
payload: { account, password: PASSWORD },
});
if (response.statusCode !== 200 && response.statusCode !== 201) {
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
}
return response.json<TokenBody>().accessToken;
}
async function call<T>(
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
url: string,
token: string,
payload?: unknown,
): Promise<JsonResult<T>> {
const response = await app.inject({
method,
url: `${API}${url}`,
headers: { authorization: `Bearer ${token}` },
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
});
const body = response.body.length === 0 ? (null as T) : response.json<T>();
return { status: response.statusCode, body };
}
beforeAll(async () => {
await prisma.customerField.deleteMany({
where: { customer: { name: { startsWith: 'ITQA3-' } } },
});
await prisma.customerGroupMember.deleteMany({
where: { group: { name: { startsWith: 'ITQA3-' } } },
});
await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
await prisma.user.deleteMany({ where: { account: { startsWith: PREFIX } } });
await prisma.role.deleteMany({ where: { code: { startsWith: `${PREFIX}role_` } } });
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
await createUser('admin', superAdminRole.id);
const scopedRole = await createRole('scoped', [Permission.CUSTOMER_READ], {
type: 'created_by_me',
});
const scopedUser = await createUser('scoped', scopedRole);
scopedUserId = scopedUser.id;
const approverRole = await createRole(
'approver',
[Permission.CUSTOMER_READ, Permission.CUSTOMER_FIELD_APPROVE],
{ type: 'all' },
);
await createUser('approver', approverRole);
const created = await createApp();
app = created.app;
await app.init();
const adminRow = await prisma.user.findUnique({ where: { account: `${PREFIX}admin` } });
if (adminRow === null) throw new Error('fixture user missing');
superToken = await login(`${PREFIX}admin`);
scopedToken = await login(`${PREFIX}scoped`);
approverToken = await login(`${PREFIX}approver`);
});
afterAll(async () => {
await prisma.customerField.deleteMany({
where: { customer: { name: { startsWith: 'ITQA3-' } } },
});
await prisma.customerGroupMember.deleteMany({
where: { group: { name: { startsWith: 'ITQA3-' } } },
});
await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
if (createdRoleIds.length > 0) {
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
}
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
if (app) await app.close();
await prisma.$disconnect();
});
describe('A3 客户与行业目录 · 唯一性 / 状态 / 范围 / 字段审批', () => {
it('两级行业:新建一级与二级,重名 40902,有子行业禁止停用/删除', async () => {
const root = await call<{ id: string; level: number; status: string }>(
'POST',
'/industries',
superToken,
{
name: 'ITQA3-电商',
sortOrder: 10,
},
);
expect(root.status).toBe(201);
expect(root.body.level).toBe(1);
rootIndustryId = root.body.id;
const child = await call<{ id: string; parentId: string; level: number }>(
'POST',
'/industries',
superToken,
{
name: 'ITQA3-服饰',
parentId: rootIndustryId,
sortOrder: 10,
},
);
expect(child.status).toBe(201);
expect(child.body.level).toBe(2);
leafIndustryId = child.body.id;
const duplicate = await call<{ code: number }>('POST', '/industries', superToken, {
name: 'ITQA3-电商',
});
expect(duplicate.status).toBe(409);
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
const blockedStop = await call<{ code: number }>(
'PATCH',
`/industries/${rootIndustryId}/status`,
superToken,
{ status: 'disabled' },
);
expect(blockedStop.status).toBe(409);
expect(blockedStop.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
const blockedDelete = await call<{ code: number }>(
'DELETE',
`/industries/${rootIndustryId}`,
superToken,
);
expect(blockedDelete.status).toBe(422);
expect(blockedDelete.body.code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
});
it('客户:简称必填 42201、同末级行业重名 40902、跨客户 404', async () => {
const missingShort = await call<{ code: number }>('POST', '/customers', superToken, {
industryId: leafIndustryId,
name: 'ITQA3-花间集',
ownerUserId: scopedUserId,
});
expect(missingShort.status).toBe(400);
expect(missingShort.body.code).toBe(ErrorCode.PARAM_INVALID);
const customer = await call<{ id: string; genFieldCount: number }>(
'POST',
'/customers',
superToken,
{
industryId: leafIndustryId,
name: 'ITQA3-花间集',
shortName: 'HJ3',
ownerUserId: scopedUserId,
baseFields: [{ key: '门店', value: '沪-001' }],
},
);
expect(customer.status).toBe(201);
customerId = customer.body.id;
expect(customer.body.genFieldCount).toBe(0);
const duplicate = await call<{ code: number }>('POST', '/customers', superToken, {
industryId: leafIndustryId,
name: 'ITQA3-花间集',
shortName: 'HJ3B',
ownerUserId: scopedUserId,
});
expect(duplicate.status).toBe(409);
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
const crossCustomer = await call<{ code: number }>(
'GET',
`/customers/${customerId}`,
scopedToken,
);
expect(crossCustomer.status).toBe(404);
expect(crossCustomer.body.code).toBe(ErrorCode.NOT_FOUND);
});
it('客户组:创建、幂等添加成员、数据范围解析到客户', async () => {
const group = await call<{ id: string; memberCount: number }>(
'POST',
'/customer-groups',
superToken,
{
name: 'ITQA3-客户组',
description: 'A3 集成测试客户组',
customerIds: [customerId],
},
);
expect(group.status).toBe(201);
groupId = group.body.id;
expect(group.body.memberCount).toBe(1);
const again = await call<{ memberCount: number }>(
'POST',
`/customer-groups/${groupId}/members`,
superToken,
{
addCustomerIds: [customerId],
removeCustomerIds: [],
},
);
expect(again.status).toBe(200);
expect(again.body.memberCount).toBe(1);
const groupRole = await createRole('group', [Permission.CUSTOMER_READ], {
type: 'customer_groups',
targetId: groupId,
});
await createUser('group', groupRole);
const groupToken = await login(`${PREFIX}group`);
const visible = await call<{ items: { id: string }[] }>(
'GET',
`/customers?limit=50`,
groupToken,
);
expect(visible.status).toBe(200);
expect(visible.body.items.some((item) => item.id === customerId)).toBe(true);
});
it('客户字段:进入生成申请待审批、审批通过生效、驳回原因必填、超限 40001', async () => {
const apply = await call<{ id: string; status: string; entersGeneration: boolean }>(
'POST',
`/customers/${customerId}/fields`,
superToken,
{
name: 'ITQA3-活动编码',
code: 'itqa3_campaign',
type: 'text',
purpose: '活动素材命名与检索',
entersGeneration: true,
},
);
expect(apply.status).toBe(201);
expect(apply.body.status).toBe('pending');
expect(apply.body.entersGeneration).toBe(true);
const pending = await call<{ items: { id: string; status: string }[] }>(
'GET',
'/customer-fields?status=pending&limit=50',
approverToken,
);
expect(pending.status).toBe(200);
expect(pending.body.items.some((item) => item.id === apply.body.id)).toBe(true);
const rejectWithoutReason = await call<{ code: number }>(
'POST',
`/customer-fields/${apply.body.id}/approve`,
approverToken,
{ decision: 'reject' },
);
expect(rejectWithoutReason.status).toBe(400);
expect(rejectWithoutReason.body.code).toBe(ErrorCode.PARAM_INVALID);
const approve = await call<{ status: string }>(
'POST',
`/customer-fields/${apply.body.id}/approve`,
approverToken,
{ decision: 'approve' },
);
expect(approve.status).toBe(200);
expect(approve.body.status).toBe('active');
for (let index = 0; index < 4; index += 1) {
const extra = await call<{ code?: number }>(
'POST',
`/customers/${customerId}/fields`,
superToken,
{
name: `ITQA3-字段${index}`,
code: `itqa3_extra_${index}`,
type: 'text',
purpose: '补足进入生成字段上限',
entersGeneration: true,
},
);
expect(extra.status).toBe(201);
}
const overflow = await call<{ code: number }>(
'POST',
`/customers/${customerId}/fields`,
superToken,
{
name: 'ITQA3-超限字段',
code: 'itqa3_overflow',
type: 'text',
purpose: '第六个进入生成字段应被拒绝',
entersGeneration: true,
},
);
expect(overflow.status).toBe(400);
expect(overflow.body.code).toBe(ErrorCode.PARAM_INVALID);
});
it('客户转交:无 customer:transfer 返回 403;超级管理员转交成功', async () => {
const forbidden = await call<{ code: number }>(
'POST',
`/customers/${customerId}/transfer`,
scopedToken,
{
ownerUserId: scopedUserId,
},
);
expect(forbidden.status).toBe(403);
expect(forbidden.body.code).toBe(ErrorCode.PERMISSION_DENIED);
const transferred = await call<{ ownerUserId: string }>(
'POST',
`/customers/${customerId}/transfer`,
superToken,
{ ownerUserId: createdUserIds[0] },
);
expect(transferred.status).toBe(200);
expect(transferred.body.ownerUserId).toBe(createdUserIds[0]);
});
it('客户状态机:暂停 / 结束只读', async () => {
const paused = await call<{ status: string }>(
'PATCH',
`/customers/${customerId}/status`,
superToken,
{
status: 'paused',
},
);
expect(paused.status).toBe(200);
expect(paused.body.status).toBe('paused');
const editPaused = await call<{ code: number }>(
'PATCH',
`/customers/${customerId}`,
superToken,
{
shortName: 'HJ3C',
},
);
expect(editPaused.status).toBe(409);
expect(editPaused.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
const ended = await call<{ status: string }>(
'PATCH',
`/customers/${customerId}/status`,
superToken,
{
status: 'ended',
},
);
expect(ended.status).toBe(200);
expect(ended.body.status).toBe('ended');
const editEnded = await call<{ code: number }>(
'PATCH',
`/customers/${customerId}`,
superToken,
{
shortName: 'HJ3D',
},
);
expect(editEnded.status).toBe(409);
});
});
@@ -0,0 +1,241 @@
import {
baseFieldSchema,
type CustomerDetail,
type CustomerField,
type CustomerGroupDetail,
type CustomerGroupSummary,
customerPreferencesSchema,
type CustomerStatus,
type CustomerSummary,
type IndustryNode,
type IndustryStatus,
} from '@lingxi/shared';
import type { CustomerFieldStatus, IndustryStatus as PrismaIndustryStatus } from '@prisma/client';
import { z } from 'zod';
export function iso(value: Date): string {
return value.toISOString();
}
export function isoOrNull(value: Date | null): string | null {
return value === null ? null : value.toISOString();
}
export function mapIndustryStatus(value: PrismaIndustryStatus): IndustryStatus {
return value === 'ACTIVE' ? 'active' : 'disabled';
}
export function mapCustomerStatus(value: string): CustomerStatus {
if (value === 'PAUSED') return 'paused';
if (value === 'ENDED') return 'ended';
return 'active';
}
export function mapFieldStatus(value: CustomerFieldStatus): CustomerField['status'] {
if (value === 'ACTIVE') return 'active';
if (value === 'REJECTED') return 'rejected';
if (value === 'DISABLED') return 'disabled';
return 'pending';
}
export interface IndustryRow {
id: string;
parentId: string | null;
level: number;
name: string;
sortOrder: number;
status: PrismaIndustryStatus;
remark: string;
updatedAt: Date;
}
export function toIndustryNode(
row: IndustryRow,
customerCount: number,
assetCount = 0,
): IndustryNode {
return {
id: row.id,
parentId: row.parentId,
level: row.level === 2 ? 2 : 1,
name: row.name,
sortOrder: row.sortOrder,
status: mapIndustryStatus(row.status),
remark: row.remark,
customerCount,
assetCount,
updatedAt: iso(row.updatedAt),
};
}
export interface CustomerSummaryRow {
id: string;
name: string;
shortName: string;
industryId: string;
status: string;
ownerUserId: string;
updatedAt: Date;
industry: { name: string; parent: { name: string } | null };
owner: { name: string };
customFields: unknown;
groupMembers: { group: { id: string; name: string } }[];
fields: { entersGeneration: boolean; status: CustomerFieldStatus }[];
}
function industryPath(row: Pick<CustomerSummaryRow, 'industry'>): string {
return row.industry.parent === null
? row.industry.name
: `${row.industry.parent.name} / ${row.industry.name}`;
}
function genFieldCount(row: Pick<CustomerSummaryRow, 'fields'>): number {
return row.fields.filter((field) => field.entersGeneration && field.status !== 'REJECTED').length;
}
const baseFieldsSchema = z.array(baseFieldSchema);
export function toCustomerSummary(row: CustomerSummaryRow): CustomerSummary {
return {
id: row.id,
name: row.name,
shortName: row.shortName,
industryId: row.industryId,
industryName: row.industry.name,
industryPath: industryPath(row),
status: mapCustomerStatus(row.status),
ownerUserId: row.ownerUserId,
ownerName: row.owner.name,
groups: row.groupMembers.map((member) => ({ id: member.group.id, name: member.group.name })),
baseFieldCount: parseBaseFields(row.customFields).length,
genFieldCount: genFieldCount(row),
updatedAt: iso(row.updatedAt),
};
}
export interface CustomerDetailRow extends CustomerSummaryRow {
contactName: string | null;
contactPhone: string | null;
preferences: unknown;
customFields: unknown;
remark: string | null;
createdAt: Date;
fields: CustomerFieldRow[];
}
export interface CustomerFieldRow {
id: string;
customerId: string;
name: string;
code: string;
type: string;
options: unknown;
purpose: string;
entersGeneration: boolean;
status: CustomerFieldStatus;
applicant: { name: string };
approver: { name: string } | null;
appliedAt: Date;
approvedAt: Date | null;
rejectReason: string | null;
}
const fieldTypeSchema = z.enum(['text', 'number', 'date', 'select']);
const optionsSchema = z.array(z.string());
export function toCustomerField(row: CustomerFieldRow): CustomerField {
const options = optionsSchema.safeParse(row.options);
return {
id: row.id,
customerId: row.customerId,
name: row.name,
code: row.code,
type: fieldTypeSchema.safeParse(row.type).success ? fieldTypeSchema.parse(row.type) : 'text',
options: options.success ? options.data : [],
purpose: row.purpose,
entersGeneration: row.entersGeneration,
status: mapFieldStatus(row.status),
applicantName: row.applicant.name,
approverName: row.approver?.name ?? null,
appliedAt: iso(row.appliedAt),
approvedAt: isoOrNull(row.approvedAt),
rejectReason: row.rejectReason,
};
}
function parseBaseFields(value: unknown): CustomerDetail['baseFields'] {
const parsed = baseFieldsSchema.safeParse(value);
return parsed.success ? parsed.data : [];
}
function parsePreferences(value: unknown): CustomerDetail['preferences'] {
const parsed = customerPreferencesSchema.safeParse(value);
return parsed.success ? parsed.data : { naming: '', directory: '', specs: [], taboo: [] };
}
export function toCustomerDetail(row: CustomerDetailRow): CustomerDetail {
const baseFields = parseBaseFields(row.customFields);
return {
...toCustomerSummary({
...row,
fields: row.fields,
}),
baseFieldCount: baseFields.length,
contactName: row.contactName,
contactPhone: row.contactPhone,
preferences: parsePreferences(row.preferences),
baseFields,
fields: row.fields.map((field) => toCustomerField(field)),
remark: row.remark,
createdAt: iso(row.createdAt),
};
}
export interface CustomerGroupRow {
id: string;
name: string;
description: string;
updatedAt: Date;
_count: { members: number };
}
export function toCustomerGroupSummary(row: CustomerGroupRow): CustomerGroupSummary {
return {
id: row.id,
name: row.name,
description: row.description,
memberCount: row._count.members,
updatedAt: iso(row.updatedAt),
};
}
export interface CustomerGroupMemberRow {
customer: {
id: string;
name: string;
shortName: string;
status: string;
industry: { name: string; parent: { name: string } | null };
owner: { name: string };
};
}
export function toCustomerGroupDetail(
row: CustomerGroupRow,
members: CustomerGroupMemberRow[],
): CustomerGroupDetail {
return {
...toCustomerGroupSummary(row),
members: members.map((member) => ({
id: member.customer.id,
name: member.customer.name,
shortName: member.customer.shortName,
industryPath:
member.customer.industry.parent === null
? member.customer.industry.name
: `${member.customer.industry.parent.name} / ${member.customer.industry.name}`,
status: mapCustomerStatus(member.customer.status),
ownerName: member.customer.owner.name,
})),
};
}
@@ -0,0 +1,43 @@
import { Global, Module } from '@nestjs/common';
import { CUSTOMER_DIRECTORY } from '../../common/security/scope/customer-directory';
import {
CustomerFieldsController,
CustomerFieldsOfCustomerController,
} from './customer-fields.controller';
import { CustomerFieldsService } from './customer-fields.service';
import { CustomerGroupsController } from './customer-groups.controller';
import { CustomerGroupsService } from './customer-groups.service';
import { CustomersController, CustomerTransferController } from './customers.controller';
import { CustomersService } from './customers.service';
import { IndustriesController } from './industries.controller';
import { IndustriesService } from './industries.service';
import { PrismaCustomerDirectory } from './prisma-customer-directory.service';
/**
* client 模块(A3):两级行业、客户档案与状态机、客户组、客户级字段。
*
* 全局导出 `CUSTOMER_DIRECTORY` 真实实现,供 SecurityModule 的 ScopeResolver 解析六类
* 数据范围;client 本身不反向依赖 security 模块,避免循环依赖。
*/
@Global()
@Module({
controllers: [
IndustriesController,
CustomersController,
CustomerTransferController,
CustomerGroupsController,
CustomerFieldsController,
CustomerFieldsOfCustomerController,
],
providers: [
IndustriesService,
CustomersService,
CustomerGroupsService,
CustomerFieldsService,
PrismaCustomerDirectory,
{ provide: CUSTOMER_DIRECTORY, useExisting: PrismaCustomerDirectory },
],
exports: [CUSTOMER_DIRECTORY, IndustriesService, CustomersService],
})
export class ClientModule {}
@@ -0,0 +1,93 @@
import {
type ApplyCustomerFieldInput,
applyCustomerFieldInputSchema,
type ApproveCustomerFieldInput,
approveCustomerFieldInputSchema,
type CustomerField,
type CustomerFieldListItem,
type CustomerFieldListQuery,
customerFieldListQuerySchema,
ErrorCode,
type ListResponse,
Permission,
} from '@lingxi/shared';
import { Body, Controller, Get, HttpCode, Inject, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppException } from '../../common/errors/app-exception';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import type { AuthContext } from '../../common/security/auth.types';
import {
Audit,
CurrentScope,
CurrentUser,
RequirePermissions,
} from '../../common/security/decorators';
import type { ScopeContext } from '../../common/security/scope/scope-context';
import { CustomerFieldsService } from './customer-fields.service';
function requireAuth(auth: AuthContext | null): AuthContext {
if (auth === null) throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
return auth;
}
@ApiTags('client-customer-fields')
@ApiBearerAuth()
@Controller('customer-fields')
export class CustomerFieldsController {
constructor(@Inject(CustomerFieldsService) private readonly fields: CustomerFieldsService) {}
@Get()
@RequirePermissions(Permission.CUSTOMER_FIELD_APPROVE, Permission.CUSTOMER_READ)
@ApiOperation({ summary: '字段审批队列(待审批 / 已生效 / 已驳回)' })
list(
@Query(new ZodValidationPipe(customerFieldListQuerySchema)) query: CustomerFieldListQuery,
@CurrentScope() scope: ScopeContext | null,
): Promise<ListResponse<CustomerFieldListItem>> {
return this.fields.list(query, scope);
}
@Post(':id/approve')
@HttpCode(200)
@RequirePermissions(Permission.CUSTOMER_FIELD_APPROVE)
@Audit('client.customer_field.approve')
@ApiOperation({ summary: '审批客户级字段(通过 / 驳回;驳回原因必填)' })
approve(
@Param('id') id: string,
@Body(new ZodValidationPipe(approveCustomerFieldInputSchema)) input: ApproveCustomerFieldInput,
@CurrentUser() auth: AuthContext | null,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerField> {
return this.fields.approve(id, input, requireAuth(auth).userId, scope);
}
}
@ApiTags('client-customer-fields')
@ApiBearerAuth()
@Controller('customers')
export class CustomerFieldsOfCustomerController {
constructor(@Inject(CustomerFieldsService) private readonly fields: CustomerFieldsService) {}
@Get(':id/fields')
@RequirePermissions(Permission.CUSTOMER_READ)
@ApiOperation({ summary: '客户级字段列表(基础字段在 customers.custom_fields)' })
list(
@Param('id') id: string,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerField[]> {
return this.fields.listByCustomer(id, scope);
}
@Post(':id/fields')
@RequirePermissions(Permission.CUSTOMER_FIELD_APPLY)
@Audit('client.customer_field.apply')
@ApiOperation({ summary: '申请客户级字段(进入生成字段需审批;每客户 ≤ 5)' })
apply(
@Param('id') id: string,
@Body(new ZodValidationPipe(applyCustomerFieldInputSchema)) input: ApplyCustomerFieldInput,
@CurrentUser() auth: AuthContext | null,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerField> {
return this.fields.apply(id, input, requireAuth(auth).userId, scope);
}
}
@@ -0,0 +1,252 @@
/* eslint-disable max-lines-per-function -- 申请 / 审批共用字段状态与上限校验,集中便于审计 */
import {
type ApplyCustomerFieldInput,
type ApproveCustomerFieldInput,
CUSTOMER_GEN_FIELD_LIMIT,
type CustomerField,
type CustomerFieldListItem,
type CustomerFieldListQuery,
ErrorCode,
type ListResponse,
ulid,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { ScopeContext } from '../../common/security/scope/scope-context';
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { SystemConfigService } from '../platform/system-config/system-config.service';
import { type CustomerFieldRow, toCustomerField } from './client.mapper';
const FIELD_INCLUDE = {
applicant: { select: { name: true } },
approver: { select: { name: true } },
} satisfies Prisma.CustomerFieldInclude;
const FIELD_LIST_INCLUDE = {
...FIELD_INCLUDE,
customer: {
select: {
id: true,
name: true,
shortName: true,
industry: { include: { parent: true } },
},
},
} satisfies Prisma.CustomerFieldInclude;
function industryPath(customer: {
industry: { name: string; parent: { name: string } | null };
}): string {
return customer.industry.parent === null
? customer.industry.name
: `${customer.industry.parent.name} / ${customer.industry.name}`;
}
/**
* 客户级字段(A3-BE-04):基础字段走 customers.custom_fields(≤10,免审批);
* 本表只承载进入生成 / 需要审批的字段(≤5)。
*/
@Injectable()
export class CustomerFieldsService {
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
) {}
async list(
query: CustomerFieldListQuery,
scopeInput: ScopeContext | null,
): Promise<ListResponse<CustomerFieldListItem>> {
const scope = requireScopeContext(scopeInput);
const rows = await this.prisma.customerField.findMany({
where: {
customer: {
deletedAt: null,
...(scope.customerIds === null ? {} : { id: { in: [...scope.customerIds] } }),
},
...(query.customer_id === undefined ? {} : { customerId: query.customer_id }),
...(query.status === 'all'
? {}
: {
status:
query.status === 'pending'
? 'PENDING'
: query.status === 'active'
? 'ACTIVE'
: query.status === 'rejected'
? 'REJECTED'
: 'DISABLED',
}),
},
include: FIELD_LIST_INCLUDE,
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
take: query.limit + 1,
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
});
const page = rows.slice(0, query.limit);
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
return {
items: page.map((row) => ({
...toCustomerField(row as CustomerFieldRow),
customerName: row.customer.name,
customerShortName: row.customer.shortName,
industryPath: industryPath(row.customer),
})),
next_cursor: next,
};
}
async listByCustomer(
customerId: string,
scopeInput: ScopeContext | null,
): Promise<CustomerField[]> {
const scope = requireScopeContext(scopeInput);
if (!isCustomerVisible(scope, customerId)) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
const rows = await this.prisma.customerField.findMany({
where: { customerId, customer: { deletedAt: null } },
include: FIELD_INCLUDE,
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
});
return rows.map((row) => toCustomerField(row as CustomerFieldRow));
}
async apply(
customerId: string,
input: ApplyCustomerFieldInput,
actorId: string,
scopeInput: ScopeContext | null,
): Promise<CustomerField> {
const scope = requireScopeContext(scopeInput);
if (!isCustomerVisible(scope, customerId)) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
const customer = await this.prisma.customer.findFirst({
where: { id: customerId, deletedAt: null },
select: { id: true, status: true },
});
if (customer === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
if (customer.status !== 'ACTIVE') {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: customer.status === 'PAUSED' ? '客户已暂停,字段只读' : '客户已结束,字段只读归档',
});
}
const duplicate = await this.prisma.customerField.findUnique({
where: { customerId_code: { customerId, code: input.code } },
select: { id: true },
});
if (duplicate !== null) {
throw new AppException({
code: ErrorCode.DUPLICATE_SUBMIT,
message: '该客户下字段编码已存在',
});
}
if (input.entersGeneration) {
const used = await this.genUsedCount(customerId);
await this.assertGenFieldLimit(used);
}
const id = ulid();
await this.prisma.customerField.create({
data: {
id,
customerId,
name: input.name,
code: input.code,
type: input.type,
options: input.options,
purpose: input.purpose,
entersGeneration: input.entersGeneration,
status: input.entersGeneration ? 'PENDING' : 'ACTIVE',
applicantUserId: actorId,
...(input.entersGeneration ? {} : { approvedAt: new Date(), approverUserId: actorId }),
},
});
const row = await this.prisma.customerField.findUniqueOrThrow({
where: { id },
include: FIELD_INCLUDE,
});
return toCustomerField(row);
}
async approve(
id: string,
input: ApproveCustomerFieldInput,
actorId: string,
scopeInput: ScopeContext | null,
): Promise<CustomerField> {
const scope = requireScopeContext(scopeInput);
const existing = await this.prisma.customerField.findUnique({
where: { id },
include: { customer: { select: { id: true, deletedAt: true } } },
});
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- 保留显式 null 分支,跨客户统一 404
if (existing === null || existing.customer.deletedAt !== null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' });
}
if (!isCustomerVisible(scope, existing.customerId)) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' });
}
if (existing.status !== 'PENDING') {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '该申请已处理' });
}
if (input.decision === 'approve' && existing.entersGeneration) {
const used = await this.genUsedCount(existing.customerId, id);
await this.assertGenFieldLimit(used, '审批后仍超限');
}
await this.prisma.customerField.update({
where: { id },
data:
input.decision === 'approve'
? {
status: 'ACTIVE',
approverUserId: actorId,
approvedAt: new Date(),
rejectReason: null,
}
: {
status: 'REJECTED',
approverUserId: actorId,
approvedAt: new Date(),
rejectReason: input.reason ?? '',
},
});
const row = await this.prisma.customerField.findUniqueOrThrow({
where: { id },
include: FIELD_INCLUDE,
});
return toCustomerField(row);
}
/** 进入生成字段上限读 system_configs(R-26);shared 常量仅作读失败兜底(默认 5) */
private async assertGenFieldLimit(used: number, hint = '请先停用或驳回其他字段'): Promise<void> {
const limit = await this.systemConfig.getNumber(
'limit.customer.gen_field',
CUSTOMER_GEN_FIELD_LIMIT,
);
if (used >= limit) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `进入生成字段已达 ${String(limit)} 个上限,${hint}`,
});
}
}
private async genUsedCount(customerId: string, excludeId?: string): Promise<number> {
return this.prisma.customerField.count({
where: {
customerId,
entersGeneration: true,
status: { not: 'REJECTED' },
...(excludeId === undefined ? {} : { id: { not: excludeId } }),
},
});
}
}
@@ -0,0 +1,97 @@
import {
type CreateCustomerGroupInput,
createCustomerGroupInputSchema,
type CustomerGroupDetail,
type CustomerGroupListQuery,
customerGroupListQuerySchema,
type CustomerGroupSummary,
type ListResponse,
Permission,
type UpdateCustomerGroupInput,
updateCustomerGroupInputSchema,
type UpdateCustomerGroupMembersInput,
updateCustomerGroupMembersInputSchema,
} from '@lingxi/shared';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Inject,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { Audit, RequirePermissions } from '../../common/security/decorators';
import { CustomerGroupsService } from './customer-groups.service';
@ApiTags('client-customer-groups')
@ApiBearerAuth()
@Controller('customer-groups')
export class CustomerGroupsController {
constructor(@Inject(CustomerGroupsService) private readonly groups: CustomerGroupsService) {}
@Get()
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
@ApiOperation({ summary: '客户组列表(授权与数据范围最小单位)' })
list(
@Query(new ZodValidationPipe(customerGroupListQuerySchema)) query: CustomerGroupListQuery,
): Promise<ListResponse<CustomerGroupSummary>> {
return this.groups.list(query);
}
@Get(':id')
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
@ApiOperation({ summary: '客户组详情与成员' })
detail(@Param('id') id: string): Promise<CustomerGroupDetail> {
return this.groups.detail(id);
}
@Post()
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
@Audit('client.customer_group.create')
@ApiOperation({ summary: '新建客户组' })
create(
@Body(new ZodValidationPipe(createCustomerGroupInputSchema)) input: CreateCustomerGroupInput,
): Promise<CustomerGroupDetail> {
return this.groups.create(input);
}
@Patch(':id')
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
@Audit('client.customer_group.update')
@ApiOperation({ summary: '更新客户组名称 / 说明' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateCustomerGroupInputSchema)) input: UpdateCustomerGroupInput,
): Promise<CustomerGroupDetail> {
return this.groups.update(id, input);
}
@Delete(':id')
@HttpCode(204)
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
@Audit('client.customer_group.delete')
@ApiOperation({ summary: '删除客户组(被角色数据范围引用时拒绝)' })
async remove(@Param('id') id: string): Promise<void> {
await this.groups.remove(id);
}
@Post(':id/members')
@HttpCode(200)
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
@Audit('client.customer_group.members')
@ApiOperation({ summary: '批量添加 / 移除成员(幂等)' })
updateMembers(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateCustomerGroupMembersInputSchema))
input: UpdateCustomerGroupMembersInput,
): Promise<CustomerGroupDetail> {
return this.groups.updateMembers(id, input);
}
}
@@ -0,0 +1,187 @@
import {
type CreateCustomerGroupInput,
type CustomerGroupDetail,
type CustomerGroupListQuery,
type CustomerGroupSummary,
ErrorCode,
type ListResponse,
ulid,
type UpdateCustomerGroupInput,
type UpdateCustomerGroupMembersInput,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import {
type CustomerGroupRow,
toCustomerGroupDetail,
toCustomerGroupSummary,
} from './client.mapper';
const GROUP_INCLUDE = {
_count: { select: { members: true } },
} satisfies Prisma.CustomerGroupInclude;
const GROUP_MEMBER_INCLUDE = {
customer: {
select: {
id: true,
name: true,
shortName: true,
status: true,
industry: { include: { parent: true } },
owner: { select: { name: true } },
},
},
} satisfies Prisma.CustomerGroupMemberInclude;
function isUniqueViolation(error: unknown): boolean {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
}
/** 客户组(A3-BE-03):授权与数据范围最小单位;成员增删幂等。 */
@Injectable()
export class CustomerGroupsService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async list(query: CustomerGroupListQuery): Promise<ListResponse<CustomerGroupSummary>> {
const rows = await this.prisma.customerGroup.findMany({
where:
query.keyword === undefined
? {}
: { name: { contains: query.keyword, mode: 'insensitive' } },
include: GROUP_INCLUDE,
orderBy: { updatedAt: 'desc' },
take: query.limit + 1,
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
});
const page = rows.slice(0, query.limit);
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
return {
items: page.map((row) => toCustomerGroupSummary(row as CustomerGroupRow)),
next_cursor: next,
};
}
async detail(id: string): Promise<CustomerGroupDetail> {
const row = await this.findByIdOrThrow(id);
const members = await this.prisma.customerGroupMember.findMany({
where: { groupId: id },
include: GROUP_MEMBER_INCLUDE,
orderBy: { createdAt: 'asc' },
});
return toCustomerGroupDetail(row, members);
}
async create(input: CreateCustomerGroupInput): Promise<CustomerGroupDetail> {
await this.assertNameUnique(input.name, null);
const id = ulid();
try {
await this.prisma.customerGroup.create({
data: {
id,
name: input.name,
description: input.description ?? '',
},
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '客户组名称已存在' });
}
throw error;
}
if (input.customerIds !== undefined && input.customerIds.length > 0) {
await this.addMembers(id, input.customerIds);
}
return this.detail(id);
}
async update(id: string, input: UpdateCustomerGroupInput): Promise<CustomerGroupDetail> {
await this.findByIdOrThrow(id);
if (input.name !== undefined) await this.assertNameUnique(input.name, id);
await this.prisma.customerGroup.update({
where: { id },
data: {
...(input.name === undefined ? {} : { name: input.name }),
...(input.description === undefined ? {} : { description: input.description }),
},
});
return this.detail(id);
}
async remove(id: string): Promise<void> {
await this.findByIdOrThrow(id);
const referenced = await this.prisma.dataScope.count({ where: { targetId: id } });
if (referenced > 0) {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: '该客户组已被角色数据范围引用,无法删除;请先调整角色范围',
});
}
await this.prisma.customerGroup.delete({ where: { id } });
}
async updateMembers(
id: string,
input: UpdateCustomerGroupMembersInput,
): Promise<CustomerGroupDetail> {
await this.findByIdOrThrow(id);
if (input.addCustomerIds.length > 0) await this.assertCustomersExist(input.addCustomerIds);
await this.prisma.$transaction([
this.prisma.customerGroupMember.deleteMany({
where: { groupId: id, customerId: { in: [...new Set(input.removeCustomerIds)] } },
}),
this.prisma.customerGroupMember.createMany({
data: [...new Set(input.addCustomerIds)].map((customerId) => ({
groupId: id,
customerId,
})),
skipDuplicates: true,
}),
this.prisma.customerGroup.update({ where: { id }, data: { updatedAt: new Date() } }),
]);
return this.detail(id);
}
private async addMembers(id: string, customerIds: readonly string[]): Promise<void> {
await this.assertCustomersExist(customerIds);
await this.prisma.customerGroupMember.createMany({
data: [...new Set(customerIds)].map((customerId) => ({ groupId: id, customerId })),
skipDuplicates: true,
});
}
private async assertCustomersExist(customerIds: readonly string[]): Promise<void> {
const found = await this.prisma.customer.findMany({
where: { id: { in: [...new Set(customerIds)] }, deletedAt: null },
select: { id: true },
});
if (found.length !== new Set(customerIds).size) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '存在不存在的客户' });
}
}
private async findByIdOrThrow(id: string): Promise<CustomerGroupRow> {
const row = await this.prisma.customerGroup.findUnique({
where: { id },
include: GROUP_INCLUDE,
});
if (row === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户组不存在' });
}
return row;
}
private async assertNameUnique(name: string, excludeId: string | null): Promise<void> {
const duplicate = await this.prisma.customerGroup.findFirst({
where: { name, ...(excludeId === null ? {} : { id: { not: excludeId } }) },
select: { id: true },
});
if (duplicate !== null) {
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '客户组名称已存在' });
}
}
}
@@ -0,0 +1,178 @@
import {
type CreateCustomerInput,
createCustomerInputSchema,
type CustomerDetail,
type CustomerListQuery,
customerListQuerySchema,
type CustomerSummary,
ErrorCode,
type ListResponse,
Permission,
type SetCustomerGroupsInput,
setCustomerGroupsInputSchema,
type SetCustomerStatusInput,
setCustomerStatusInputSchema,
type TransferCustomerInput,
transferCustomerInputSchema,
type TransferCustomersInput,
transferCustomersInputSchema,
type UpdateCustomerInput,
updateCustomerInputSchema,
} from '@lingxi/shared';
import {
Body,
Controller,
Get,
HttpCode,
Inject,
Param,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppException } from '../../common/errors/app-exception';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import type { AuthContext } from '../../common/security/auth.types';
import {
Audit,
CurrentScope,
CurrentUser,
RequirePermissions,
} from '../../common/security/decorators';
import type { ScopeContext } from '../../common/security/scope/scope-context';
import { CustomersService } from './customers.service';
function requireAuth(auth: AuthContext | null): AuthContext {
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
return auth;
}
@ApiTags('client-customers')
@ApiBearerAuth()
@Controller('customers')
export class CustomersController {
constructor(@Inject(CustomersService) private readonly customers: CustomersService) {}
@Get()
@RequirePermissions(Permission.CUSTOMER_READ)
@ApiOperation({ summary: '客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选)' })
list(
@Query(new ZodValidationPipe(customerListQuerySchema)) query: CustomerListQuery,
@CurrentScope() scope: ScopeContext | null,
): Promise<ListResponse<CustomerSummary>> {
return this.customers.list(query, scope);
}
@Get(':id')
@RequirePermissions(Permission.CUSTOMER_READ)
@ApiOperation({ summary: '客户详情(跨客户访问统一 404)' })
detail(
@Param('id') id: string,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerDetail> {
return this.customers.detail(id, scope);
}
@Post()
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.customer.create')
@ApiOperation({ summary: '新建客户(简称必填;行业必须为末级行业)' })
create(
@Body(new ZodValidationPipe(createCustomerInputSchema)) input: CreateCustomerInput,
@CurrentUser() auth: AuthContext | null,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerDetail> {
return this.customers.create(input, requireAuth(auth).userId, scope);
}
@Patch(':id')
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.customer.update')
@ApiOperation({ summary: '更新客户档案(所属行业不可改;暂停 / 结束只读)' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateCustomerInputSchema)) input: UpdateCustomerInput,
@CurrentUser() auth: AuthContext | null,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerDetail> {
return this.customers.update(id, input, scope, requireAuth(auth).permissions);
}
@Patch(':id/status')
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.customer.status')
@ApiOperation({ summary: '客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆)' })
setStatus(
@Param('id') id: string,
@Body(new ZodValidationPipe(setCustomerStatusInputSchema)) input: SetCustomerStatusInput,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerDetail> {
return this.customers.setStatus(id, input, scope);
}
@Post(':id/transfer')
@HttpCode(200)
@RequirePermissions(Permission.CUSTOMER_TRANSFER)
@Audit('client.customer.transfer')
@ApiOperation({ summary: '变更客户负责人(写审计)' })
transfer(
@Param('id') id: string,
@Body(new ZodValidationPipe(transferCustomerInputSchema)) input: TransferCustomerInput,
@CurrentUser() auth: AuthContext | null,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerDetail> {
return this.customers.transfer(id, input, scope, requireAuth(auth).permissions);
}
@Put(':id/groups')
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE, Permission.CUSTOMER_MANAGE)
@Audit('client.customer.groups')
@ApiOperation({ summary: '设置客户所属客户组(覆盖式)' })
setGroups(
@Param('id') id: string,
@Body(new ZodValidationPipe(setCustomerGroupsInputSchema)) input: SetCustomerGroupsInput,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerDetail> {
return this.customers.setGroups(id, input, scope);
}
@Post(':id/apply-industry-pack')
@HttpCode(200)
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.customer.apply_industry_pack')
@ApiOperation({ summary: '套用行业资产包(B0 批次提供;当前返回业务提示)' })
applyIndustryPack(): never {
return this.customers.applyIndustryPack();
}
}
/** 用户停用前的客户转交:保留 A1 路径 /users/{id}/transfer-customers,由 client 模块落地 */
@ApiTags('client-customers')
@ApiBearerAuth()
@Controller('users')
export class CustomerTransferController {
constructor(@Inject(CustomersService) private readonly customers: CustomersService) {}
@Post(':id/transfer-customers')
@HttpCode(200)
@RequirePermissions(Permission.CUSTOMER_TRANSFER, Permission.USER_MANAGE)
@Audit('identity.user.transfer_customers')
@ApiOperation({ summary: '把用户名下客户转交给另一负责人' })
async transfer(
@Param('id') fromUserId: string,
@Body(new ZodValidationPipe(transferCustomersInputSchema)) input: TransferCustomersInput,
@CurrentUser() auth: AuthContext | null,
): Promise<{ transferred: number }> {
return this.customers.transferByUser(
fromUserId,
input.toUserId,
input.customerIds,
requireAuth(auth).permissions,
);
}
}
@@ -0,0 +1,421 @@
/* eslint-disable max-lines-per-function, complexity -- 客户状态机 / 权限与字段上限集中校验,拆分会丢失事务边界 */
import {
type CreateCustomerInput,
CUSTOMER_BASE_FIELD_LIMIT,
type CustomerDetail,
type CustomerListQuery,
type CustomerSummary,
ErrorCode,
type ListResponse,
Permission,
type SetCustomerGroupsInput,
type SetCustomerStatusInput,
type TransferCustomerInput,
ulid,
type UpdateCustomerInput,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { ScopeContext } from '../../common/security/scope/scope-context';
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { SystemConfigService } from '../platform/system-config/system-config.service';
import { type CustomerSummaryRow, toCustomerDetail, toCustomerSummary } from './client.mapper';
import { IndustriesService } from './industries.service';
const CUSTOMER_INCLUDE = {
industry: { include: { parent: true } },
owner: { select: { name: true } },
groupMembers: { include: { group: true } },
fields: { select: { entersGeneration: true, status: true } },
} satisfies Prisma.CustomerInclude;
const CUSTOMER_DETAIL_INCLUDE = {
...CUSTOMER_INCLUDE,
fields: {
include: { applicant: { select: { name: true } }, approver: { select: { name: true } } },
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
},
} satisfies Prisma.CustomerInclude;
function isUniqueViolation(error: unknown): boolean {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
}
function scopeWhere(scope: ScopeContext): Prisma.CustomerWhereInput {
return scope.customerIds === null ? {} : { id: { in: [...scope.customerIds] } };
}
function hasPermission(permissions: readonly string[], code: string): boolean {
return permissions.includes(code);
}
/**
* 客户档案(A3-BE-02 / A3-BE-05):状态机、负责人 / 对接人、交付偏好、
* 客户组归属、客户转交;所有读写都带 ScopeContext 客户维度。
*/
@Injectable()
export class CustomersService {
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(IndustriesService) private readonly industries: IndustriesService,
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
) {}
async list(
query: CustomerListQuery,
scopeInput: ScopeContext | null,
): Promise<ListResponse<CustomerSummary>> {
const scope = requireScopeContext(scopeInput);
const industryIds =
query.industry_id === undefined
? undefined
: await this.industrySubtreeIds(query.industry_id);
const rows = await this.prisma.customer.findMany({
where: {
deletedAt: null,
...scopeWhere(scope),
...(query.keyword === undefined
? {}
: {
OR: [
{ name: { contains: query.keyword, mode: 'insensitive' } },
{ shortName: { contains: query.keyword, mode: 'insensitive' } },
],
}),
...(industryIds === undefined ? {} : { industryId: { in: industryIds } }),
...(query.status === 'all'
? {}
: {
status:
query.status === 'active'
? 'ACTIVE'
: query.status === 'paused'
? 'PAUSED'
: 'ENDED',
}),
...(query.owner_user_id === undefined ? {} : { ownerUserId: query.owner_user_id }),
...(query.group_id === undefined
? {}
: { groupMembers: { some: { groupId: query.group_id } } }),
},
include: CUSTOMER_INCLUDE,
orderBy: { id: 'desc' },
take: query.limit + 1,
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
});
const page = rows.slice(0, query.limit);
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
return {
items: page.map((row) => toCustomerSummary(row as CustomerSummaryRow)),
next_cursor: next,
};
}
async detail(id: string, scopeInput: ScopeContext | null): Promise<CustomerDetail> {
return this.findVisibleOrThrow(id, requireScopeContext(scopeInput));
}
async create(
input: CreateCustomerInput,
actorId: string,
scopeInput: ScopeContext | null,
): Promise<CustomerDetail> {
const scope = requireScopeContext(scopeInput);
await this.industries.assertLeafIndustry(input.industryId);
await this.ensureOwner(input.ownerUserId);
const baseFields = input.baseFields ?? [];
await this.assertBaseFieldLimit(baseFields.length);
const id = ulid();
try {
await this.prisma.customer.create({
data: {
id,
industryId: input.industryId,
name: input.name,
shortName: input.shortName,
ownerUserId: input.ownerUserId,
contactName: input.contactName ?? null,
contactPhone: input.contactPhone ?? null,
preferences: input.preferences ?? {},
customFields: baseFields,
remark: input.remark ?? null,
createdBy: actorId,
},
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new AppException({
code: ErrorCode.DUPLICATE_SUBMIT,
message: '该末级行业下已存在同名客户',
});
}
throw error;
}
return this.detail(id, scope);
}
async update(
id: string,
input: UpdateCustomerInput,
scopeInput: ScopeContext | null,
permissions: readonly string[],
): Promise<CustomerDetail> {
const scope = requireScopeContext(scopeInput);
const existing = await this.findRow(id);
this.assertVisible(scope, id);
if (existing.status !== 'ACTIVE') {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: existing.status === 'PAUSED' ? '客户已暂停,档案只读' : '客户已结束,数据只读归档',
});
}
if (
input.ownerUserId !== undefined &&
input.ownerUserId !== existing.ownerUserId &&
!hasPermission(permissions, Permission.CUSTOMER_TRANSFER)
) {
throw new AppException({
code: ErrorCode.PERMISSION_DENIED,
message: '变更负责人需要 customer:transfer 权限',
});
}
if (input.ownerUserId !== undefined && input.ownerUserId !== existing.ownerUserId) {
await this.ensureOwner(input.ownerUserId);
}
if (input.baseFields !== undefined) await this.assertBaseFieldLimit(input.baseFields.length);
if (input.name !== undefined && input.name !== existing.name) {
const duplicate = await this.prisma.customer.findFirst({
where: {
industryId: existing.industryId,
name: input.name,
id: { not: id },
deletedAt: null,
},
select: { id: true },
});
if (duplicate !== null) {
throw new AppException({
code: ErrorCode.DUPLICATE_SUBMIT,
message: '该末级行业下已存在同名客户',
});
}
}
await this.prisma.customer.update({
where: { id },
data: {
...(input.name === undefined ? {} : { name: input.name }),
...(input.shortName === undefined ? {} : { shortName: input.shortName }),
...(input.ownerUserId === undefined ? {} : { ownerUserId: input.ownerUserId }),
...(input.contactName === undefined ? {} : { contactName: input.contactName }),
...(input.contactPhone === undefined ? {} : { contactPhone: input.contactPhone }),
...(input.preferences === undefined ? {} : { preferences: input.preferences }),
...(input.baseFields === undefined ? {} : { customFields: input.baseFields }),
...(input.remark === undefined ? {} : { remark: input.remark }),
},
});
return this.detail(id, scope);
}
async setStatus(
id: string,
input: SetCustomerStatusInput,
scopeInput: ScopeContext | null,
): Promise<CustomerDetail> {
const scope = requireScopeContext(scopeInput);
const existing = await this.findRow(id);
this.assertVisible(scope, id);
if (existing.status === 'ENDED') {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: '客户已结束,不能再变更状态',
});
}
if (existing.status === input.status) {
return this.detail(id, scope);
}
const target =
input.status === 'active' ? 'ACTIVE' : input.status === 'paused' ? 'PAUSED' : 'ENDED';
await this.prisma.customer.update({ where: { id }, data: { status: target } });
return this.detail(id, scope);
}
async transfer(
id: string,
input: TransferCustomerInput,
scopeInput: ScopeContext | null,
permissions: readonly string[],
): Promise<CustomerDetail> {
const scope = requireScopeContext(scopeInput);
if (!hasPermission(permissions, Permission.CUSTOMER_TRANSFER)) {
throw new AppException({
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
message: '缺少客户转交权限',
});
}
const existing = await this.findRow(id);
this.assertVisible(scope, id);
if (existing.status === 'ENDED') {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: '客户已结束,不能转交',
});
}
await this.ensureOwner(input.ownerUserId);
await this.prisma.customer.update({ where: { id }, data: { ownerUserId: input.ownerUserId } });
return this.detail(id, scope);
}
/** 用户停用前批量转交:只转交 from 用户名下、且在指定列表中的客户 */
async transferByUser(
fromUserId: string,
toUserId: string,
customerIds: readonly string[] | undefined,
permissions: readonly string[],
): Promise<{ transferred: number }> {
if (
!hasPermission(permissions, Permission.CUSTOMER_TRANSFER) &&
!hasPermission(permissions, Permission.USER_MANAGE)
) {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '缺少客户转交权限' });
}
await this.ensureOwner(toUserId);
const result = await this.prisma.customer.updateMany({
where: {
ownerUserId: fromUserId,
deletedAt: null,
status: { not: 'ENDED' },
...(customerIds === undefined ? {} : { id: { in: [...customerIds] } }),
},
data: { ownerUserId: toUserId },
});
return { transferred: result.count };
}
async setGroups(
id: string,
input: SetCustomerGroupsInput,
scopeInput: ScopeContext | null,
): Promise<CustomerDetail> {
const scope = requireScopeContext(scopeInput);
await this.findRow(id);
this.assertVisible(scope, id);
if (input.groupIds.length > 0) {
const found = await this.prisma.customerGroup.findMany({
where: { id: { in: input.groupIds } },
select: { id: true },
});
if (found.length !== new Set(input.groupIds).size) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '客户组不存在' });
}
}
await this.prisma.$transaction([
this.prisma.customerGroupMember.deleteMany({ where: { customerId: id } }),
this.prisma.customerGroupMember.createMany({
data: [...new Set(input.groupIds)].map((groupId) => ({ groupId, customerId: id })),
}),
]);
return this.detail(id, scope);
}
applyIndustryPack(): never {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '行业资产包将在 B0 批次提供;当前可先完成客户创建',
});
}
private async findRow(id: string): Promise<{
id: string;
status: string;
ownerUserId: string;
industryId: string;
name: string;
}> {
const row = await this.prisma.customer.findFirst({
where: { id, deletedAt: null },
select: { id: true, status: true, ownerUserId: true, industryId: true, name: true },
});
if (row === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
return row;
}
private async findVisibleOrThrow(id: string, scope: ScopeContext): Promise<CustomerDetail> {
if (!isCustomerVisible(scope, id)) {
// A3-BE-05:跨客户访问统一 404,不暴露存在性
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
const row = await this.prisma.customer.findFirst({
where: { id, deletedAt: null },
include: CUSTOMER_DETAIL_INCLUDE,
});
if (row === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
return toCustomerDetail(row);
}
private assertVisible(scope: ScopeContext, id: string): void {
if (!isCustomerVisible(scope, id)) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
}
private async ensureOwner(userId: string): Promise<void> {
const user = await this.prisma.user.findFirst({
where: { id: userId, status: 'ACTIVE' },
select: { id: true },
});
if (user === null) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: '客户负责人不存在或已停用',
});
}
}
/** 基础字段上限读 system_configs(R-26);shared 常量仅作读失败兜底(默认 10) */
private async assertBaseFieldLimit(count: number): Promise<void> {
const limit = await this.systemConfig.getNumber(
'limit.customer.base_field',
CUSTOMER_BASE_FIELD_LIMIT,
);
if (count > limit) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `基础字段最多 ${String(limit)} 个,当前 ${String(count)} 个`,
});
}
}
private async industrySubtreeIds(industryId: string): Promise<string[]> {
const industry = await this.prisma.industry.findUnique({
where: { id: industryId },
select: { id: true },
});
if (industry === null) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '行业不存在' });
}
const children = await this.prisma.industry.findMany({
where: { parentId: industryId },
select: { id: true },
});
return [industryId, ...children.map((child) => child.id)];
}
/** 供其他模块复用的归属校验(A3-BE-05):不泄露跨客户资源存在性 */
async assertCustomerVisible(customerId: string, scope: ScopeContext): Promise<void> {
if (!isCustomerVisible(scope, customerId)) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
const count = await this.prisma.customer.count({ where: { id: customerId, deletedAt: null } });
if (count === 0) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
}
}
@@ -0,0 +1,95 @@
import {
type CreateIndustryInput,
createIndustryInputSchema,
type IndustryDetail,
type IndustryListQuery,
industryListQuerySchema,
type IndustryTreeItem,
type ListResponse,
Permission,
type SetIndustryStatusInput,
setIndustryStatusInputSchema,
type UpdateIndustryInput,
updateIndustryInputSchema,
} from '@lingxi/shared';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Inject,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { Audit, RequirePermissions } from '../../common/security/decorators';
import { IndustriesService } from './industries.service';
@ApiTags('client-industries')
@ApiBearerAuth()
@Controller('industries')
export class IndustriesController {
constructor(@Inject(IndustriesService) private readonly industries: IndustriesService) {}
@Get()
@RequirePermissions(Permission.CUSTOMER_READ)
@ApiOperation({ summary: '两级行业树(一级 → 二级)' })
list(
@Query(new ZodValidationPipe(industryListQuerySchema)) query: IndustryListQuery,
): Promise<ListResponse<IndustryTreeItem>> {
return this.industries.list(query);
}
@Get(':id')
@RequirePermissions(Permission.CUSTOMER_READ)
@ApiOperation({ summary: '行业详情与引用(子行业 / 客户 / 资产)' })
detail(@Param('id') id: string): Promise<IndustryDetail> {
return this.industries.detail(id);
}
@Post()
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.industry.create')
@ApiOperation({ summary: '新建一级 / 二级行业' })
create(
@Body(new ZodValidationPipe(createIndustryInputSchema)) input: CreateIndustryInput,
): Promise<IndustryDetail> {
return this.industries.create(input);
}
@Patch(':id')
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.industry.update')
@ApiOperation({ summary: '更新行业名称 / 排序 / 备注(层级与上级不可变)' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateIndustryInputSchema)) input: UpdateIndustryInput,
): Promise<IndustryDetail> {
return this.industries.update(id, input);
}
@Patch(':id/status')
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.industry.status')
@ApiOperation({ summary: '停用 / 启用行业(有启用子行业时禁止停用)' })
setStatus(
@Param('id') id: string,
@Body(new ZodValidationPipe(setIndustryStatusInputSchema)) input: SetIndustryStatusInput,
): Promise<IndustryDetail> {
return this.industries.setStatus(id, input);
}
@Delete(':id')
@HttpCode(204)
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.industry.delete')
@ApiOperation({ summary: '删除行业(仅无子行业 / 客户 / 资产时)' })
async remove(@Param('id') id: string): Promise<void> {
await this.industries.remove(id);
}
}
@@ -0,0 +1,282 @@
import {
type CreateIndustryInput,
ErrorCode,
type IndustryDetail,
type IndustryListQuery,
type IndustryReferences,
type IndustryTreeItem,
type ListResponse,
type SetIndustryStatusInput,
ulid,
type UpdateIndustryInput,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { type IndustryRow, toIndustryNode } from './client.mapper';
function isUniqueViolation(error: unknown): boolean {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
}
/**
* 行业目录(A3-BE-01 / ADR-002):两级树、名称唯一、停用 / 删除引用保护。
* 资产数在 B1 资产表落地前固定为 0;接口已保留字段,后续接入不改契约。
*/
@Injectable()
export class IndustriesService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async list(query: IndustryListQuery): Promise<ListResponse<IndustryTreeItem>> {
const rows = await this.prisma.industry.findMany({
where: {
...(query.keyword === undefined
? {}
: { name: { contains: query.keyword, mode: 'insensitive' } }),
...(query.status === 'all'
? {}
: { status: query.status === 'active' ? 'ACTIVE' : 'DISABLED' }),
...(query.parent_id === undefined ? {} : { parentId: query.parent_id }),
},
orderBy: [{ level: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
});
const counts = await this.customerCounts();
const roots = rows.filter((row) => row.level === 1);
const childrenByParent = new Map<string, IndustryRow[]>();
for (const row of rows) {
if (row.level !== 2 || row.parentId === null) continue;
const list = childrenByParent.get(row.parentId) ?? [];
list.push(row);
childrenByParent.set(row.parentId, list);
}
const items = roots.map((root) => {
const children = (childrenByParent.get(root.id) ?? []).map((child) =>
toIndustryNode(child, counts.get(child.id) ?? 0),
);
const own = counts.get(root.id) ?? 0;
const total = children.reduce((sum, child) => sum + child.customerCount, own);
return {
...toIndustryNode(root, total),
children,
};
});
return { items, next_cursor: null };
}
async detail(id: string): Promise<IndustryDetail> {
const row = await this.findOrThrow(id);
const parent =
row.parentId === null
? null
: await this.prisma.industry.findUnique({ where: { id: row.parentId } });
const references = await this.references(id);
return {
...toIndustryNode(row, references.customerCount),
parentName: parent?.name ?? null,
references,
};
}
async references(id: string): Promise<IndustryReferences> {
const children = await this.prisma.industry.findMany({
where: { parentId: id },
select: { id: true, status: true },
});
const customerWhere: Prisma.CustomerWhereInput = {
deletedAt: null,
OR: [{ industryId: id }, { industry: { parentId: id } }],
};
const [customerCount, customers] = await Promise.all([
this.prisma.customer.count({ where: customerWhere }),
this.prisma.customer.findMany({
where: customerWhere,
select: { id: true, name: true, status: true },
take: 20,
orderBy: { updatedAt: 'desc' },
}),
]);
return {
childCount: children.length,
activeChildCount: children.filter((child) => child.status === 'ACTIVE').length,
customerCount,
customers: customers.map((customer) => ({
id: customer.id,
name: customer.name,
status:
customer.status === 'PAUSED'
? 'paused'
: customer.status === 'ENDED'
? 'ended'
: 'active',
})),
assetCount: 0,
};
}
async create(input: CreateIndustryInput): Promise<IndustryDetail> {
const level = input.parentId === undefined || input.parentId === null ? 1 : 2;
if (level === 2 && input.parentId !== undefined && input.parentId !== null) {
const parent = await this.findOrThrow(input.parentId);
if (parent.level !== 1) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: '行业最多两级,二级行业下不能再建子行业',
});
}
}
await this.assertNameUnique(input.name, level === 1 ? null : (input.parentId ?? null), null);
const id = ulid();
try {
await this.prisma.industry.create({
data: {
id,
parentId: level === 1 ? null : (input.parentId ?? null),
level,
name: input.name,
sortOrder: input.sortOrder,
remark: input.remark,
},
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new AppException({
code: ErrorCode.DUPLICATE_SUBMIT,
message: level === 1 ? '行业名称已存在' : '该上级行业下已存在同名行业',
});
}
throw error;
}
return this.detail(id);
}
async update(id: string, input: UpdateIndustryInput): Promise<IndustryDetail> {
const existing = await this.findOrThrow(id);
if (input.name !== undefined && input.name !== existing.name) {
await this.assertNameUnique(input.name, existing.parentId, id);
}
await this.prisma.industry.update({
where: { id },
data: {
...(input.name === undefined ? {} : { name: input.name }),
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
...(input.remark === undefined ? {} : { remark: input.remark }),
},
});
return this.detail(id);
}
async setStatus(id: string, input: SetIndustryStatusInput): Promise<IndustryDetail> {
const existing = await this.findOrThrow(id);
if (existing.status === 'DISABLED' && input.status === 'active') {
const parent =
existing.parentId === null
? null
: await this.prisma.industry.findUnique({ where: { id: existing.parentId } });
if (parent !== null && parent.status === 'DISABLED') {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: '上级行业已停用,请先启用上级行业',
});
}
}
if (input.status === 'disabled') {
const activeChildren = await this.prisma.industry.count({
where: { parentId: id, status: 'ACTIVE' },
});
if (activeChildren > 0) {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: `该行业下仍有 ${activeChildren} 个启用中的二级行业,请先停用或调整子级`,
});
}
}
await this.prisma.industry.update({
where: { id },
data: { status: input.status === 'active' ? 'ACTIVE' : 'DISABLED' },
});
return this.detail(id);
}
async remove(id: string): Promise<void> {
const references = await this.references(id);
if (references.childCount > 0 || references.customerCount > 0 || references.assetCount > 0) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: `无法删除:该行业下有 ${references.childCount} 个二级行业、${references.customerCount} 个客户`,
details: references,
});
}
try {
await this.prisma.industry.delete({ where: { id } });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2003') {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '该行业仍被引用,无法删除,只能停用',
});
}
throw error;
}
}
/** 客户归属校验:客户只能挂末级行业(A3-BE-02 / ADR-002 D2) */
async assertLeafIndustry(id: string): Promise<void> {
const industry = await this.findOrThrow(id);
if (industry.status === 'DISABLED') {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '行业已停用,新建客户不可选择',
});
}
if (industry.level === 1) {
const childCount = await this.prisma.industry.count({ where: { parentId: id } });
if (childCount > 0) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '该一级行业下存在二级行业,客户只能归属到末级行业',
});
}
}
}
private async findOrThrow(id: string): Promise<IndustryRow> {
const row = await this.prisma.industry.findUnique({ where: { id } });
if (row === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '行业不存在' });
}
return row;
}
private async assertNameUnique(
name: string,
parentId: string | null,
excludeId: string | null,
): Promise<void> {
const duplicate = await this.prisma.industry.findFirst({
where: {
name,
parentId,
...(excludeId === null ? {} : { id: { not: excludeId } }),
},
select: { id: true },
});
if (duplicate !== null) {
throw new AppException({
code: ErrorCode.DUPLICATE_SUBMIT,
message: parentId === null ? '行业名称已存在' : '该上级行业下已存在同名行业',
});
}
}
private async customerCounts(): Promise<Map<string, number>> {
const grouped = await this.prisma.customer.groupBy({
by: ['industryId'],
where: { deletedAt: null },
_count: { _all: true },
});
return new Map(grouped.map((item) => [item.industryId, item._count._all]));
}
}
@@ -0,0 +1,51 @@
import { Inject, Injectable } from '@nestjs/common';
import type { CustomerDirectory } from '../../common/security/scope/customer-directory';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
/**
* 真实客户目录(A3 替换 A2 的 EmptyCustomerDirectory)。
* 只读,供 ScopeResolver 解析六类数据范围;不参与业务写操作。
*/
@Injectable()
export class PrismaCustomerDirectory implements CustomerDirectory {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async findOwnedCustomerIds(userId: string): Promise<string[]> {
const rows = await this.prisma.customer.findMany({
where: { ownerUserId: userId, deletedAt: null },
select: { id: true },
});
return rows.map((row) => row.id);
}
async findCustomerGroupCustomerIds(customerGroupIds: readonly string[]): Promise<string[]> {
if (customerGroupIds.length === 0) return [];
const rows = await this.prisma.customerGroupMember.findMany({
where: { groupId: { in: [...customerGroupIds] }, customer: { deletedAt: null } },
select: { customerId: true },
});
return [...new Set(rows.map((row) => row.customerId))].sort();
}
async findDepartmentCustomerIds(departmentIds: readonly string[]): Promise<string[]> {
if (departmentIds.length === 0) return [];
const rows = await this.prisma.customer.findMany({
where: {
deletedAt: null,
owner: { departmentId: { in: [...departmentIds] } },
},
select: { id: true },
});
return rows.map((row) => row.id);
}
async findCreatedCustomerIds(userId: string): Promise<string[]> {
const rows = await this.prisma.customer.findMany({
where: { createdBy: userId, deletedAt: null },
select: { id: true },
});
return rows.map((row) => row.id);
}
}
@@ -616,7 +616,7 @@ describe('identity 集成(真实 PG / Redis)', () => {
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200); expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
}); });
it('/me:dataScopes 多角色并集;超管固定 all;A3 前客户集合为空数组', async () => { it('/me:dataScopes 多角色并集;超管固定 all;A3 接入真实客户目录', async () => {
const adminLogin = await login(created.app, manager.account); const adminLogin = await login(created.app, manager.account);
expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]); expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]);
const adminMe = await callJson<MeBody>(created.app, 'GET', '/me', adminLogin.body.accessToken); const adminMe = await callJson<MeBody>(created.app, 'GET', '/me', adminLogin.body.accessToken);
@@ -639,7 +639,9 @@ describe('identity 集成(真实 PG / Redis)', () => {
const scopes = userLogin.body.me.dataScopes; const scopes = userLogin.body.me.dataScopes;
expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']); expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']);
for (const scope of scopes) { for (const scope of scopes) {
expect(scope.customerIds).toEqual([]); expect(Array.isArray(scope.customerIds)).toBe(true);
} }
// A3 起 CustomerDirectory 返回真实客户;本用例用户没有负责 / 创建的客户
expect(scopes.find((scope) => scope.type === 'owned_customers')?.customerIds).toEqual([]);
}); });
}); });
@@ -5,6 +5,7 @@ import { AppException } from '../../common/errors/app-exception';
import type { UserAuthRecord } from '../../common/security/permission.service'; import type { UserAuthRecord } from '../../common/security/permission.service';
import type { RequestContextService } from '../../common/security/request-context'; import type { RequestContextService } from '../../common/security/request-context';
import type { PrismaService } from '../../infra/prisma/prisma.service'; import type { PrismaService } from '../../infra/prisma/prisma.service';
import type { SystemConfigService } from '../platform/system-config/system-config.service';
import { DepartmentsService } from './departments.service'; import { DepartmentsService } from './departments.service';
import { LoginFailureService } from './login-failure.service'; import { LoginFailureService } from './login-failure.service';
import { MeService } from './me.service'; import { MeService } from './me.service';
@@ -213,10 +214,15 @@ describe('LoginFailureService', () => {
describe('UsersService', () => { describe('UsersService', () => {
const passwords = { hash: vi.fn().mockResolvedValue('hashed') }; const passwords = { hash: vi.fn().mockResolvedValue('hashed') };
const systemConfig = {
getNumber: vi.fn((_key: string, fallback?: number) => Promise.resolve(fallback ?? 0)),
} as unknown as SystemConfigService;
function serviceOf(prisma: Record<string, unknown>): UsersService { function serviceOf(prisma: Record<string, unknown>): UsersService {
return new UsersService( return new UsersService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
passwords as unknown as PasswordService, passwords as unknown as PasswordService,
systemConfig,
); );
} }
@@ -259,8 +265,6 @@ describe('UsersService', () => {
const disabled = userFixture({ status: 'DISABLED' }); const disabled = userFixture({ status: 'DISABLED' });
const service2 = serviceOf({ user: { findUnique: vi.fn().mockResolvedValue(disabled) } }); const service2 = serviceOf({ user: { findUnique: vi.fn().mockResolvedValue(disabled) } });
await expectRejected(service2.disable('u1', {}, 'u2', []), ErrorCode.STATE_NOT_ALLOWED); await expectRejected(service2.disable('u1', {}, 'u2', []), ErrorCode.STATE_NOT_ALLOWED);
expect(() => service.transferCustomers()).toThrow(AppException);
}); });
it('停用成功会同步吊销 refresh token;启用恢复状态', async () => { it('停用成功会同步吊销 refresh token;启用恢复状态', async () => {
@@ -600,6 +604,9 @@ describe('MeService / RolesService', () => {
const service = new RolesService( const service = new RolesService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
{ get: () => null, patchAudit: vi.fn() } as unknown as RequestContextService, { get: () => null, patchAudit: vi.fn() } as unknown as RequestContextService,
{
getNumber: vi.fn((_key: string, fallback?: number) => Promise.resolve(fallback ?? 0)),
} as unknown as SystemConfigService,
); );
const result = await service.list({ const result = await service.list({
status: 'active', status: 'active',
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../common/errors/app-exception'; import { AppException } from '../../common/errors/app-exception';
import type { RequestContextService } from '../../common/security/request-context'; import type { RequestContextService } from '../../common/security/request-context';
import type { PrismaService } from '../../infra/prisma/prisma.service'; import type { PrismaService } from '../../infra/prisma/prisma.service';
import type { SystemConfigService } from '../platform/system-config/system-config.service';
import { RolesService } from './roles.service'; import { RolesService } from './roles.service';
const NOW = new Date('2026-11-03T02:00:00.000Z'); const NOW = new Date('2026-11-03T02:00:00.000Z');
@@ -102,10 +103,14 @@ function harness(): Harness {
get: vi.fn().mockReturnValue({ traceId: 'trace-1' }), get: vi.fn().mockReturnValue({ traceId: 'trace-1' }),
patchAudit: vi.fn(), patchAudit: vi.fn(),
}; };
const systemConfig = {
getNumber: vi.fn((_key: string, fallback?: number) => Promise.resolve(fallback ?? 0)),
} as unknown as SystemConfigService;
return { return {
service: new RolesService( service: new RolesService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
requestContext as unknown as RequestContextService, requestContext as unknown as RequestContextService,
systemConfig,
), ),
prisma, prisma,
requestContext, requestContext,
+12 -5
View File
@@ -28,6 +28,7 @@ import { AppException } from '../../common/errors/app-exception';
import { RequestContextService } from '../../common/security/request-context'; import { RequestContextService } from '../../common/security/request-context';
import type { PrismaService } from '../../infra/prisma/prisma.service'; import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens'; import { PRISMA } from '../../infra/tokens';
import { SystemConfigService } from '../platform/system-config/system-config.service';
import { iso, maskPhone } from './user.mapper'; import { iso, maskPhone } from './user.mapper';
const ROLE_LIST_INCLUDE = { const ROLE_LIST_INCLUDE = {
@@ -238,6 +239,7 @@ export class RolesService {
constructor( constructor(
@Inject(PRISMA) private readonly prisma: PrismaService, @Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(RequestContextService) private readonly requestContext: RequestContextService, @Inject(RequestContextService) private readonly requestContext: RequestContextService,
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
) {} ) {}
async list(query: RoleListQuery): Promise<ListResponse<RoleListItem>> { async list(query: RoleListQuery): Promise<ListResponse<RoleListItem>> {
@@ -694,22 +696,27 @@ export class RolesService {
} }
} }
/** 软提示(不阻断):角色数量 / 超管人数超过 PRD 建议值;A4 迁 system_configs */ /** 软提示(不阻断):角色 / 超管人数超过建议值;阈值读 system_configs(R-26),常量仅作读失败兜底 */
private async buildWarnings(roleCode: string): Promise<string[]> { private async buildWarnings(roleCode: string): Promise<string[]> {
const warnings: string[] = []; const warnings: string[] = [];
const roleSoftLimit = await this.systemConfig.getNumber('limit.role.max', ROLE_SOFT_LIMIT);
const activeRoleCount = await this.prisma.role.count({ where: { status: 'ACTIVE' } }); const activeRoleCount = await this.prisma.role.count({ where: { status: 'ACTIVE' } });
if (activeRoleCount > ROLE_SOFT_LIMIT) { if (activeRoleCount > roleSoftLimit) {
warnings.push( warnings.push(
`启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(ROLE_SOFT_LIMIT)},建议合并角色`, `启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(roleSoftLimit)},建议合并角色`,
); );
} }
if (roleCode === 'super_admin') { if (roleCode === 'super_admin') {
const superAdminSoftLimit = await this.systemConfig.getNumber(
'limit.super_admin.max',
SUPER_ADMIN_SOFT_LIMIT,
);
const superAdminCount = await this.prisma.user.count({ const superAdminCount = await this.prisma.user.count({
where: { status: 'ACTIVE', roles: { some: { role: { code: 'super_admin' } } } }, where: { status: 'ACTIVE', roles: { some: { role: { code: 'super_admin' } } } },
}); });
if (superAdminCount > SUPER_ADMIN_SOFT_LIMIT) { if (superAdminCount > superAdminSoftLimit) {
warnings.push( warnings.push(
`超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人`, `超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(superAdminSoftLimit)} 人`,
); );
} }
} }
@@ -6,7 +6,6 @@ import {
ErrorCode, ErrorCode,
type ListResponse, type ListResponse,
Permission, Permission,
transferCustomersInputSchema,
type UpdateUserInput, type UpdateUserInput,
updateUserInputSchema, updateUserInputSchema,
type UserDetail, type UserDetail,
@@ -110,15 +109,4 @@ export class UsersController {
): Promise<UserDetail> { ): Promise<UserDetail> {
return this.users.enable(id, requireAuth(auth).permissions); return this.users.enable(id, requireAuth(auth).permissions);
} }
@Post(':id/transfer-customers')
@HttpCode(200)
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.user.transfer_customers')
@ApiOperation({ summary: '客户转交(占位)', description: 'A3 客户模块落地前返回 42201' })
transferCustomers(
@Body(new ZodValidationPipe(transferCustomersInputSchema)) _input: unknown,
): never {
return this.users.transferCustomers();
}
} }
+10 -12
View File
@@ -17,6 +17,7 @@ import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception'; import { AppException } from '../../common/errors/app-exception';
import type { PrismaService } from '../../infra/prisma/prisma.service'; import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens'; import { PRISMA } from '../../infra/tokens';
import { SystemConfigService } from '../platform/system-config/system-config.service';
import { PasswordService } from './password.service'; import { PasswordService } from './password.service';
import { toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper'; import { toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
@@ -37,6 +38,7 @@ export class UsersService {
constructor( constructor(
@Inject(PRISMA) private readonly prisma: PrismaService, @Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(PasswordService) private readonly passwords: PasswordService, @Inject(PasswordService) private readonly passwords: PasswordService,
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
) {} ) {}
async list(query: UserListQuery): Promise<ListResponse<UserSummary>> { async list(query: UserListQuery): Promise<ListResponse<UserSummary>> {
@@ -180,17 +182,9 @@ export class UsersService {
return this.detail(id, permissions); return this.detail(id, permissions);
} }
/** 客户转交占位:A3 客户模块落地前按契约返回 42201(不自造 5xx 错误码) */
transferCustomers(): never {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '客户转交将在客户模块(A3)提供',
});
}
/** /**
* 超管人数软上限(PRD §5.4.1 建议 ≤ 2 人):只记录告警,不阻断保存; * 超管人数软上限(PRD §5.4.1):只记录告警,不阻断保存;
* 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。 * 前端「超管授予二次确认」是主要提示手段。阈值读 system_configs(R-26),常量仅作读失败兜底。
*/ */
private async warnIfSuperAdminExceeded(roleIds: readonly string[]): Promise<void> { private async warnIfSuperAdminExceeded(roleIds: readonly string[]): Promise<void> {
const superAdminRole = await this.prisma.role.findUnique({ const superAdminRole = await this.prisma.role.findUnique({
@@ -200,12 +194,16 @@ export class UsersService {
if (superAdminRole === null || !roleIds.includes(superAdminRole.id)) { if (superAdminRole === null || !roleIds.includes(superAdminRole.id)) {
return; return;
} }
const superAdminSoftLimit = await this.systemConfig.getNumber(
'limit.super_admin.max',
SUPER_ADMIN_SOFT_LIMIT,
);
const count = await this.prisma.user.count({ const count = await this.prisma.user.count({
where: { status: 'ACTIVE', roles: { some: { roleId: superAdminRole.id } } }, where: { status: 'ACTIVE', roles: { some: { roleId: superAdminRole.id } } },
}); });
if (count > SUPER_ADMIN_SOFT_LIMIT) { if (count > superAdminSoftLimit) {
this.logger.warn( this.logger.warn(
`超级管理员 ${String(count)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人(仅提示,不阻断)`, `超级管理员 ${String(count)} 人已超过建议上限 ${String(superAdminSoftLimit)} 人(仅提示,不阻断)`,
); );
} }
} }
@@ -0,0 +1,159 @@
import { ErrorCode, formatNamingDate, NAMING_MAX_LENGTH } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../../common/errors/app-exception';
import type { SystemConfigService } from '../system-config/system-config.service';
import { type NamingNameContext, NamingService } from './naming.service';
const SCRIPT_PATTERN = '{行业}-{客户}-{脚本方向}-{日期}-{序号}';
const IMAGE_PATTERN = '{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}';
const VIDEO_PATTERN = '{行业}_{客户}_{日期}_{序号}';
const BASE_CONTEXT: NamingNameContext = {
industry: '电商',
customer: '花间集',
direction: '口播',
date: '20261104',
};
interface HarnessOptions {
script?: string;
image?: string;
video?: string;
maxLength?: number;
duplicateStart?: number;
patternError?: boolean;
}
function harness(options: HarnessOptions = {}) {
const patterns: Record<string, string> = {
script: options.script ?? SCRIPT_PATTERN,
image: options.image ?? IMAGE_PATTERN,
video: options.video ?? VIDEO_PATTERN,
};
const getNamingPattern = vi.fn((kind: string) => {
if (options.patternError === true) return Promise.reject(new Error('配置读取失败'));
return Promise.resolve(patterns[kind] ?? SCRIPT_PATTERN);
});
const getNumber = vi.fn((key: string, fallback?: number) => {
if (key === 'naming.max_length') {
return Promise.resolve(options.maxLength ?? fallback ?? NAMING_MAX_LENGTH);
}
return Promise.resolve(options.duplicateStart ?? fallback ?? 2);
});
const configs = { getNamingPattern, getNumber } as unknown as SystemConfigService;
return { service: new NamingService(configs), getNamingPattern, getNumber };
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
describe('NamingService · 默认模板', () => {
it('脚本:占位符替换 + 序号补零为 01,日期可显式传入', async () => {
const { service } = harness();
await expect(service.resolveScriptName(BASE_CONTEXT)).resolves.toBe(
'电商-花间集-口播-20261104-01',
);
});
it('未传日期时取生成当天(YYYYMMDD)', async () => {
const { service } = harness();
const name = await service.resolveScriptName({
industry: '电商',
customer: '花间集',
direction: '口播',
});
expect(name).toContain(formatNamingDate(new Date()));
expect(name).toMatch(/^电商-花间集-口播-\d{8}-01$/);
});
it('图片:追加规格后缀;视频:下划线分隔', async () => {
const { service } = harness();
await expect(service.resolveImageName({ ...BASE_CONTEXT, spec: '1080x1920' })).resolves.toBe(
'电商-花间集-口播-20261104-01-1080x1920',
);
await expect(service.resolveVideoName(BASE_CONTEXT)).resolves.toBe('电商_花间集_20261104_01');
});
it('批次序号可传入(数值补零为两位)', async () => {
const { service } = harness();
await expect(service.resolveScriptName({ ...BASE_CONTEXT, sequence: 5 })).resolves.toBe(
'电商-花间集-口播-20261104-05',
);
});
});
describe('NamingService · 同名序号(R-01)', () => {
it('默认从 -02 起连续递增,已占用则继续 +1', async () => {
const { service } = harness();
const base = '电商-花间集-口播-20261104-01';
await expect(service.resolveScriptName(BASE_CONTEXT, [base])).resolves.toBe(
'电商-花间集-口播-20261104-02',
);
await expect(
service.resolveScriptName(BASE_CONTEXT, [base, '电商-花间集-口播-20261104-02']),
).resolves.toBe('电商-花间集-口播-20261104-03');
});
it('无同名冲突时保持原名(不追加序号)', async () => {
const { service } = harness();
await expect(service.resolveScriptName(BASE_CONTEXT, ['其它名称'])).resolves.toBe(
'电商-花间集-口播-20261104-01',
);
});
it('naming.duplicate_start 可配置起始序号(配置 3 时从 -03 起)', async () => {
const { service } = harness({ duplicateStart: 3 });
await expect(
service.resolveScriptName(BASE_CONTEXT, ['电商-花间集-口播-20261104-01']),
).resolves.toBe('电商-花间集-口播-20261104-03');
});
});
describe('NamingService · 校验与业务错误', () => {
it('缺失占位符值返回 40001(缺 {客户})', async () => {
const { service } = harness();
await expectRejected(
service.resolveScriptName({ industry: '电商', direction: '口播', date: '20261104' }),
ErrorCode.PARAM_INVALID,
);
});
it('解析结果含非法字符返回 42201', async () => {
const { service } = harness();
await expectRejected(
service.resolveScriptName({ ...BASE_CONTEXT, customer: '花间集*' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
});
it('超过 naming.max_length 返回 42201;正常名称长度 ≤ 100', async () => {
const { service } = harness();
await expectRejected(
service.resolveScriptName({ ...BASE_CONTEXT, customer: '花'.repeat(110) }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
const name = await service.resolveScriptName(BASE_CONTEXT);
expect(name.length).toBeLessThanOrEqual(NAMING_MAX_LENGTH);
});
it('配置读取失败时向上抛出(由调用方决定重试 / 降级)', async () => {
const { service } = harness({ patternError: true });
await expect(service.resolveScriptName(BASE_CONTEXT)).rejects.toThrow('配置读取失败');
});
it('读取参数使用 naming.* 配置键', async () => {
const { service, getNamingPattern, getNumber } = harness();
await service.resolveScriptName(BASE_CONTEXT, []);
expect(getNamingPattern).toHaveBeenCalledWith('script');
expect(getNumber).toHaveBeenCalledWith('naming.max_length', NAMING_MAX_LENGTH);
expect(getNumber).toHaveBeenCalledWith('naming.duplicate_start', 2);
});
});
@@ -0,0 +1,139 @@
import {
dedupeName,
ErrorCode,
formatNamingDate,
formatNamingSequence,
NAMING_MAX_LENGTH,
type NamingContext,
type NamingTokenKey,
resolveNamingTemplate,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { AppException } from '../../../common/errors/app-exception';
import { SystemConfigService } from '../system-config/system-config.service';
/** 调用方只传自己知道的占位符;未传的占位符由 resolveNamingTemplate 报「缺少值」 */
export type NamingNameContext = Partial<Record<NamingTokenKey, string | number>>;
export type NamingKind = 'script' | 'image' | 'video';
/**
* 命名规则服务(A4-BE-03 / PRD R-01)。
*
* 读 `naming.{script|image|video}.pattern` + `naming.max_length` + `naming.duplicate_start`,
* 调用 shared 的 `resolveNamingTemplate` / `dedupeName` 生成最终文件名;
* 占位符缺失、非法字符、超长、同名过多统一转业务异常(40001 / 42201)。
* 供脚本、图片、视频、导出共用,避免各模块各写一套命名。
*/
@Injectable()
export class NamingService {
constructor(@Inject(SystemConfigService) private readonly configs: SystemConfigService) {}
resolveScriptName(
context: NamingNameContext,
existingNames?: readonly string[],
): Promise<string> {
return this.resolve('script', context, existingNames);
}
resolveImageName(context: NamingNameContext, existingNames?: readonly string[]): Promise<string> {
return this.resolve('image', context, existingNames);
}
resolveVideoName(context: NamingNameContext, existingNames?: readonly string[]): Promise<string> {
return this.resolve('video', context, existingNames);
}
private async resolve(
kind: NamingKind,
input: NamingNameContext,
existingNames: readonly string[] | undefined,
): Promise<string> {
const [pattern, maxLength, duplicateStart] = await Promise.all([
this.configs.getNamingPattern(kind),
this.configs.getNumber('naming.max_length', NAMING_MAX_LENGTH),
this.configs.getNumber('naming.duplicate_start', 2),
]);
const resolved = this.resolvePattern(pattern, this.contextOf(input), maxLength);
const existing = new Set(existingNames ?? []);
if (existing.size === 0) return resolved;
return this.dedupe(resolved, existing, duplicateStart);
}
/** 缺省值:日期取生成当天、序号取 01;其余占位符缺失交给 shared 报错 */
private contextOf(input: NamingNameContext, now: Date = new Date()): NamingContext {
const rawSequence = input.sequence ?? 1;
const sequence =
typeof rawSequence === 'number' ? formatNamingSequence(rawSequence) : rawSequence;
return {
industry: input.industry ?? '',
customer: input.customer ?? '',
direction: input.direction ?? '',
date: input.date === undefined ? formatNamingDate(now) : String(input.date),
sequence,
spec: input.spec ?? '',
};
}
private resolvePattern(pattern: string, context: NamingContext, maxLength: number): string {
try {
return resolveNamingTemplate(pattern, context, { maxLength });
} catch (error) {
throw this.toAppException(error);
}
}
private dedupe(name: string, existing: ReadonlySet<string>, start: number): string {
let candidate: string;
try {
candidate = dedupeName(name, existing);
} catch (error) {
throw this.toAppException(error);
}
return this.applyDuplicateStart(candidate, name, existing, start);
}
/**
* `dedupeName` 固定从 -02 起;`naming.duplicate_start` 可配 2–9。
* 配置大于 2 时,把候选序号抬到配置起点,保证「同名序号起始」生效。
*/
private applyDuplicateStart(
candidate: string,
base: string,
existing: ReadonlySet<string>,
start: number,
): string {
if (start <= 2 || candidate === base) return candidate;
const dotIndex = candidate.lastIndexOf('.');
const stem = dotIndex > 0 ? candidate.slice(0, dotIndex) : candidate;
const extension = dotIndex > 0 ? candidate.slice(dotIndex) : '';
const dashIndex = stem.lastIndexOf('-');
if (dashIndex <= 0) return candidate;
const digits = stem.slice(dashIndex + 1);
if (!/^\d{2,}$/.test(digits)) return candidate;
const sequence = Number(digits);
if (sequence >= start) return candidate;
const root = stem.slice(0, dashIndex);
for (let next = start; next <= 999; next += 1) {
const nextName = `${root}-${String(next).padStart(2, '0')}${extension}`;
if (!existing.has(nextName)) return nextName;
}
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: `同名文件过多,无法为「${base}」生成序号`,
});
}
private toAppException(error: unknown): AppException {
if (error instanceof AppException) return error;
const message = error instanceof Error ? error.message : '命名生成失败';
if (message.includes('占位符缺少值')) {
return new AppException({ code: ErrorCode.PARAM_INVALID, message });
}
return new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: `命名规则校验未通过:${message}`,
});
}
}
@@ -1,14 +1,20 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { NamingService } from './naming/naming.service';
import { SystemConfigController } from './system-config/system-config.controller';
import { SystemConfigService } from './system-config/system-config.service';
/** /**
* platform 模块:健康检查、系统配置、审计、AI 网关等平台能力的归属模块(技术架构 §3)。 * platform 模块:健康检查、系统配置、命名规则、审计、AI 网关等平台能力的归属模块
* A0 只落地健康检查;其余能力按里程碑在本模块内新增。 * (技术架构 §3)。@Global 导出 SystemConfigService / NamingService,供 identity / client /
* 后续 script / media / delivery 直接构造器注入,避免跨模块重复 import(R-26 阈值同源)。
*/ */
@Global() @Global()
@Module({ @Module({
imports: [HealthModule], imports: [HealthModule],
exports: [HealthModule], controllers: [SystemConfigController],
providers: [SystemConfigService, NamingService],
exports: [HealthModule, SystemConfigService, NamingService],
}) })
export class PlatformModule {} export class PlatformModule {}
@@ -0,0 +1,72 @@
import {
type EffectiveSystemConfigsResponse,
ErrorCode,
Permission,
type SystemConfigHistoryQuery,
systemConfigHistoryQuerySchema,
type SystemConfigHistoryResponse,
type SystemConfigListResponse,
type UpdateSystemConfigsInput,
updateSystemConfigsInputSchema,
} from '@lingxi/shared';
import { Body, Controller, Get, Inject, Put, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppException } from '../../../common/errors/app-exception';
import { ZodValidationPipe } from '../../../common/pipes/zod-validation.pipe';
import type { AuthContext } from '../../../common/security/auth.types';
import { Audit, CurrentUser, RequirePermissions } from '../../../common/security/decorators';
import { SystemConfigService } from './system-config.service';
function requireAuth(auth: AuthContext | null): AuthContext {
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
return auth;
}
/**
* 系统配置(A4-BE-02 / PRD R-26):
* - `/system/configs` 全量读写需要 `system:configure`;
* - `/system/configs/effective` 仅要求登录,供前端读取上限(阈值单一来源)。
*/
@ApiTags('platform-system-configs')
@ApiBearerAuth()
@Controller('system')
export class SystemConfigController {
constructor(@Inject(SystemConfigService) private readonly configs: SystemConfigService) {}
@Get('configs')
@RequirePermissions(Permission.SYSTEM_CONFIGURE)
@ApiOperation({ summary: '全量系统配置(定义元数据 + 当前值 / 版本)' })
list(): Promise<SystemConfigListResponse> {
return this.configs.list();
}
@Get('configs/effective')
@ApiOperation({ summary: '生效配置(仅登录;只返回 public 项,前端上限读取入口)' })
effective(): Promise<EffectiveSystemConfigsResponse> {
return this.configs.getEffective();
}
@Put('configs')
@RequirePermissions(Permission.SYSTEM_CONFIGURE)
@Audit('platform.system_config.update')
@ApiOperation({ summary: '批量保存配置(乐观锁 expectedVersion;写版本历史与审计)' })
update(
@Body(new ZodValidationPipe(updateSystemConfigsInputSchema)) input: UpdateSystemConfigsInput,
@CurrentUser() auth: AuthContext | null,
): Promise<SystemConfigListResponse> {
return this.configs.update(input, requireAuth(auth).userId);
}
@Get('configs/history')
@RequirePermissions(Permission.SYSTEM_CONFIGURE)
@ApiOperation({ summary: '配置变更记录(按变更时间倒序;操作人为用户姓名)' })
history(
@Query(new ZodValidationPipe(systemConfigHistoryQuerySchema))
query: SystemConfigHistoryQuery,
): Promise<SystemConfigHistoryResponse> {
return this.configs.history(query);
}
}
@@ -0,0 +1,376 @@
import {
API_PREFIX,
ErrorCode,
Permission,
SYSTEM_CONFIG_DEFINITIONS,
systemConfigDefinition,
ulid,
} from '@lingxi/shared';
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp, type CreatedApiApp } from '../../../bootstrap';
import { loadDotenv } from '../../../config/load-dotenv';
const PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
const PASSWORD = 'lingxi2026';
const PREFIX = 'itqa4c.';
const REASON = 'ITQA4 集成测试';
const CONFIG_KEY = 'limit.page.max';
const API = API_PREFIX;
loadDotenv();
const prisma = new PrismaClient();
interface TokenBody {
accessToken: string;
}
interface JsonResult<T> {
status: number;
body: T;
}
interface SystemConfigBody {
key: string;
group: string;
label: string;
value: string | number | boolean;
valueType: string;
version: number;
updatedBy: string | null;
editable: boolean;
isPublic: boolean;
min: number | null;
max: number | null;
maxLength: number | null;
}
interface SystemConfigChangeBody {
key: string;
label: string;
version: number;
oldValue: unknown;
newValue: unknown;
reason: string | null;
operator: string | null;
changedAt: string;
result: string;
}
interface OriginalConfig {
configValue: string | number | boolean;
version: number;
updatedBy: string | null;
description: string;
}
let phoneCounter = 0;
let app: CreatedApiApp['app'];
let adminToken = '';
let readerToken = '';
let original: OriginalConfig | null = null;
const createdRoleIds: string[] = [];
const createdUserIds: string[] = [];
function nextPhone(): string {
phoneCounter += 1;
return `134${String(phoneCounter).padStart(8, '0')}`;
}
async function createRole(suffix: string, permissions: string[]): Promise<string> {
const id = ulid();
await prisma.role.create({
data: {
id,
code: `${PREFIX}role_${suffix}`,
name: `${PREFIX}角色-${suffix}`,
description: 'A4 系统配置集成测试角色',
status: 'ACTIVE',
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
dataScopes: { create: [{ id: ulid(), scopeType: 'all', targetId: null }] },
},
});
createdRoleIds.push(id);
return id;
}
async function createUser(suffix: string, roleId: string): Promise<void> {
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
const account = `${PREFIX}${suffix}`;
const id = ulid();
await prisma.user.create({
data: {
id,
name: `A4 集成测试${suffix}`,
account,
email: `${account}@lingxi.cn`,
phone: nextPhone(),
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId }] },
},
});
createdUserIds.push(id);
}
async function login(account: string): Promise<string> {
const response = await app.inject({
method: 'POST',
url: `${API}/auth/login`,
payload: { account, password: PASSWORD },
});
if (response.statusCode !== 200 && response.statusCode !== 201) {
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
}
return response.json<TokenBody>().accessToken;
}
async function call<T>(
method: 'GET' | 'PUT',
url: string,
token?: string,
payload?: unknown,
): Promise<JsonResult<T>> {
const response = await app.inject({
method,
url: `${API}${url}`,
...(token === undefined ? {} : { headers: { authorization: `Bearer ${token}` } }),
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
});
const body = response.body.length === 0 ? (null as T) : response.json<T>();
return { status: response.statusCode, body };
}
function configOf(items: SystemConfigBody[], key: string): SystemConfigBody {
const item = items.find((config) => config.key === key);
if (item === undefined) throw new Error(`配置项缺失:${key}`);
return item;
}
function primitive(value: unknown): string | number | boolean {
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
? value
: '';
}
async function cleanupFixtures(): Promise<void> {
await prisma.user.deleteMany({ where: { account: { startsWith: PREFIX } } });
if (createdRoleIds.length > 0) {
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
}
await prisma.systemConfigVersion.deleteMany({
where: { configKey: CONFIG_KEY, reason: { startsWith: REASON } },
});
}
beforeAll(async () => {
await cleanupFixtures();
const adminRole = await createRole('admin', [Permission.SYSTEM_CONFIGURE]);
await createUser('admin', adminRole);
const readerRole = await createRole('reader', [Permission.CUSTOMER_READ]);
await createUser('reader', readerRole);
const row = await prisma.systemConfig.findUnique({ where: { configKey: CONFIG_KEY } });
original =
row === null
? null
: {
configValue: primitive(row.configValue),
version: row.version,
updatedBy: row.updatedBy,
description: row.description,
};
const created = await createApp();
app = created.app;
await app.init();
adminToken = await login(`${PREFIX}admin`);
readerToken = await login(`${PREFIX}reader`);
});
afterAll(async () => {
if (original !== null) {
await prisma.systemConfig.update({
where: { configKey: CONFIG_KEY },
data: {
configValue: original.configValue,
version: original.version,
updatedBy: original.updatedBy,
description: original.description,
},
});
}
await cleanupFixtures();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
if (app) await app.close();
await prisma.$disconnect();
});
describe('A4 系统配置 · 读取与权限三态', () => {
it('全量配置:定义清单为准,返回阈值元数据 / 版本 / 公开标记', async () => {
const result = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
expect(result.status).toBe(200);
expect(result.body.items).toHaveLength(SYSTEM_CONFIG_DEFINITIONS.length);
const limitPage = configOf(result.body.items, CONFIG_KEY);
expect(limitPage).toMatchObject({
group: 'limits',
valueType: 'number',
editable: true,
isPublic: true,
min: 20,
max: 500,
});
expect(limitPage.version).toBeGreaterThanOrEqual(1);
const naming = configOf(result.body.items, 'naming.script.pattern');
expect(naming.isPublic).toBe(false);
expect(naming.value).toBe(systemConfigDefinition('naming.script.pattern')?.defaultValue);
});
it('权限三态:无权限 403、未登录 401、有权限 200', async () => {
const forbidden = await call<{ code: number }>('GET', '/system/configs', readerToken);
expect(forbidden.status).toBe(403);
expect(forbidden.body.code).toBe(ErrorCode.PERMISSION_DENIED);
const anonymous = await call<{ code: number }>('GET', '/system/configs');
expect(anonymous.status).toBe(401);
expect(anonymous.body.code).toBe(ErrorCode.UNAUTHENTICATED);
});
it('生效配置:仅登录即可读取,只返回 public 项(前端上限入口 R-26)', async () => {
const result = await call<{ items: { key: string; value: string | number | boolean }[] }>(
'GET',
'/system/configs/effective',
readerToken,
);
expect(result.status).toBe(200);
const keys = result.body.items.map((item) => item.key);
expect(keys).toContain(CONFIG_KEY);
expect(keys).toContain('limit.customer.base_field');
expect(keys).not.toContain('naming.script.pattern');
expect(keys).not.toContain('platform.export.signed_url_ttl_seconds');
const anonymous = await call<{ code: number }>('GET', '/system/configs/effective');
expect(anonymous.status).toBe(401);
});
});
describe('A4 系统配置 · 保存 / 版本历史 / 审计', () => {
it('未定义 key、非法值均 40001', async () => {
const unknownKey = await call<{ code: number }>('PUT', '/system/configs', adminToken, {
changes: [{ key: 'unknown.config', value: 1 }],
});
expect(unknownKey.status).toBe(400);
expect(unknownKey.body.code).toBe(ErrorCode.PARAM_INVALID);
const invalidValue = await call<{ code: number }>('PUT', '/system/configs', adminToken, {
changes: [{ key: CONFIG_KEY, value: 9999 }],
});
expect(invalidValue.status).toBe(400);
expect(invalidValue.body.code).toBe(ErrorCode.PARAM_INVALID);
const noPermission = await call<{ code: number }>('PUT', '/system/configs', readerToken, {
changes: [{ key: CONFIG_KEY, value: 120 }],
});
expect(noPermission.status).toBe(403);
});
it('expectedVersion 不一致返回 40901', async () => {
const current = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
const staleVersion = configOf(current.body.items, CONFIG_KEY).version + 99;
const conflict = await call<{ code: number }>('PUT', '/system/configs', adminToken, {
changes: [{ key: CONFIG_KEY, value: 120, expectedVersion: staleVersion }],
});
expect(conflict.status).toBe(409);
expect(conflict.body.code).toBe(ErrorCode.VERSION_CONFLICT);
});
it('保存生效:version+1、写版本历史、生效配置同步、操作人解析为姓名', async () => {
const before = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
const current = configOf(before.body.items, CONFIG_KEY);
const targetValue = current.value === 120 ? 130 : 120;
const saved = await call<{ items: SystemConfigBody[] }>('PUT', '/system/configs', adminToken, {
changes: [
{ key: CONFIG_KEY, value: targetValue, expectedVersion: current.version, reason: REASON },
],
});
expect(saved.status).toBe(200);
const updated = configOf(saved.body.items, CONFIG_KEY);
expect(updated).toMatchObject({ value: targetValue, version: current.version + 1 });
const effective = await call<{ items: { key: string; value: unknown }[] }>(
'GET',
'/system/configs/effective',
readerToken,
);
expect(effective.body.items.find((item) => item.key === CONFIG_KEY)?.value).toBe(targetValue);
const history = await call<{ items: SystemConfigChangeBody[]; next_cursor: string | null }>(
'GET',
`/system/configs/history?config_key=${CONFIG_KEY}&limit=20`,
adminToken,
);
expect(history.status).toBe(200);
expect(history.body.next_cursor).toBeNull();
const latest = history.body.items[0];
expect(latest).toMatchObject({
key: CONFIG_KEY,
label: systemConfigDefinition(CONFIG_KEY)?.label,
version: current.version + 1,
oldValue: current.value,
newValue: targetValue,
reason: REASON,
operator: 'A4 集成测试admin',
result: '已生效',
});
expect(Number.isNaN(new Date(latest?.changedAt ?? '').getTime())).toBe(false);
});
it('相同值跳过:版本号不变、不新增历史', async () => {
const before = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
const current = configOf(before.body.items, CONFIG_KEY);
const saved = await call<{ items: SystemConfigBody[] }>('PUT', '/system/configs', adminToken, {
changes: [{ key: CONFIG_KEY, value: current.value, expectedVersion: current.version }],
});
expect(saved.status).toBe(200);
expect(configOf(saved.body.items, CONFIG_KEY).version).toBe(current.version);
});
it('恢复测试前的配置值', async () => {
const before = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
const current = configOf(before.body.items, CONFIG_KEY);
const originalValue = original?.configValue ?? 100;
const restored = await call<{ items: SystemConfigBody[] }>(
'PUT',
'/system/configs',
adminToken,
{
changes: [
{
key: CONFIG_KEY,
value: originalValue,
expectedVersion: current.version,
reason: `${REASON}-恢复`,
},
],
},
);
expect(restored.status).toBe(200);
expect(configOf(restored.body.items, CONFIG_KEY).value).toBe(originalValue);
});
it('历史查询:无权限 403;limit 越界 40001', async () => {
const forbidden = await call<{ code: number }>('GET', '/system/configs/history', readerToken);
expect(forbidden.status).toBe(403);
const invalidLimit = await call<{ code: number }>(
'GET',
'/system/configs/history?limit=999',
adminToken,
);
expect(invalidLimit.status).toBe(400);
expect(invalidLimit.body.code).toBe(ErrorCode.PARAM_INVALID);
});
});
@@ -0,0 +1,78 @@
/**
* 系统配置 Prisma 行 -> 共享 DTO 映射(A4-BE-02)。
*
* 元数据(标签 / 说明 / 影响面 / 类型 / 上下限 / 是否公开)唯一来源是
* `SYSTEM_CONFIG_DEFINITIONS`;`system_configs` 只提供当前值与版本。
* DB 缺行时用定义默认值 + version 1 兜底,不写库。
*/
import {
type SystemConfig,
type SystemConfigChange,
type SystemConfigDefinition,
type SystemConfigValue,
systemConfigValueType,
} from '@lingxi/shared';
import type { Prisma } from '@prisma/client';
import type {
SystemConfig as PrismaSystemConfig,
SystemConfigVersion as PrismaSystemConfigVersion,
} from '@prisma/client';
/** 从未落库的配置:用 epoch 表示「未更新过」,避免伪造读取时间 */
const NEVER_UPDATED_AT = new Date(0).toISOString();
/** Prisma JsonB 只应存原始值;异常结构回退配置默认值,避免脏数据打穿前端 schema */
export function normalizeConfigValue(
raw: Prisma.JsonValue | undefined,
fallback: SystemConfigValue,
): SystemConfigValue {
if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
return raw;
}
return fallback;
}
export function toSystemConfig(
definition: SystemConfigDefinition,
row: PrismaSystemConfig | undefined,
updatedByName?: string | null,
): SystemConfig {
return {
key: definition.key,
group: definition.group,
label: definition.label,
description: definition.description,
impact: definition.impact,
type: definition.type,
value: normalizeConfigValue(row?.configValue, definition.defaultValue),
valueType: systemConfigValueType(definition),
unit: definition.unit ?? null,
version: row?.version ?? 1,
updatedAt: row === undefined ? NEVER_UPDATED_AT : row.updatedAt.toISOString(),
updatedBy: updatedByName ?? row?.updatedBy ?? null,
editable: definition.editable,
isPublic: definition.public,
min: definition.min ?? null,
max: definition.max ?? null,
step: definition.step ?? null,
maxLength: definition.maxLength ?? null,
};
}
export function toSystemConfigChange(
row: PrismaSystemConfigVersion,
label: string,
operator: string | null,
): SystemConfigChange {
return {
key: row.configKey,
label,
version: row.version,
oldValue: row.oldValue,
newValue: row.newValue,
reason: row.reason,
operator,
changedAt: row.changedAt.toISOString(),
result: '已生效',
};
}
@@ -0,0 +1,369 @@
import { ErrorCode, SYSTEM_CONFIG_DEFINITIONS, systemConfigDefinition } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../../common/errors/app-exception';
import type { RequestContextService } from '../../../common/security/request-context';
import type { PrismaService } from '../../../infra/prisma/prisma.service';
import { SystemConfigService } from './system-config.service';
const NOW = new Date('2026-11-04T02:00:00.000Z');
function configRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
configKey: 'platform.name',
configValue: '灵犀',
valueType: 'string',
description: '平台名称',
version: 1,
isEditable: true,
createdAt: NOW,
updatedAt: NOW,
updatedBy: null,
...overrides,
};
}
interface Harness {
service: SystemConfigService;
prisma: {
systemConfig: Record<string, ReturnType<typeof vi.fn>>;
systemConfigVersion: Record<string, ReturnType<typeof vi.fn>>;
user: Record<string, ReturnType<typeof vi.fn>>;
$transaction: ReturnType<typeof vi.fn>;
};
patchAudit: ReturnType<typeof vi.fn>;
}
function harness(
prismaInput: Record<string, Record<string, ReturnType<typeof vi.fn>>> = {},
): Harness {
const systemConfig = {
findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
...prismaInput.systemConfig,
};
const systemConfigVersion = {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({}),
...prismaInput.systemConfigVersion,
};
const user = { findMany: vi.fn().mockResolvedValue([]), ...prismaInput.user };
const $transaction = vi.fn(async (callback: unknown) => {
if (typeof callback === 'function') {
return (callback as (client: unknown) => Promise<unknown>)({
systemConfig,
systemConfigVersion,
});
}
return Promise.all(callback as Promise<unknown>[]);
});
const patchAudit = vi.fn();
const service = new SystemConfigService(
{ systemConfig, systemConfigVersion, user, $transaction } as unknown as PrismaService,
{ patchAudit } as unknown as RequestContextService,
);
return { service, prisma: { systemConfig, systemConfigVersion, user, $transaction }, patchAudit };
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
describe('SystemConfigService · 列表与生效配置', () => {
it('以定义清单为元数据源合并当前值;DB 缺行用默认值 + version 1 兜底', async () => {
const { service } = harness();
const result = await service.list();
expect(result.items).toHaveLength(SYSTEM_CONFIG_DEFINITIONS.length);
const platformName = result.items.find((item) => item.key === 'platform.name');
expect(platformName).toMatchObject({
label: '平台名称',
value: '灵犀',
valueType: 'string',
version: 1,
updatedBy: null,
isPublic: true,
maxLength: 32,
min: null,
});
expect(platformName?.updatedAt).toBe(new Date(0).toISOString());
});
it('DB 有行时使用库内值 / 版本 / 更新人,元数据仍取定义', async () => {
const { service } = harness({
systemConfig: {
findMany: vi
.fn()
.mockResolvedValue([configRow({ configValue: '灵犀 Pro', version: 4, updatedBy: 'u1' })]),
},
user: { findMany: vi.fn().mockResolvedValue([{ id: 'u1', name: '江岚' }]) },
});
const result = await service.list();
const platformName = result.items.find((item) => item.key === 'platform.name');
expect(platformName).toMatchObject({
value: '灵犀 Pro',
version: 4,
updatedBy: '江岚',
updatedAt: NOW.toISOString(),
});
});
it('生效配置只返回 public 项(不含命名模板等非公开配置)', async () => {
const { service } = harness({
systemConfig: {
findMany: vi.fn().mockResolvedValue([configRow({ configValue: '灵犀 Pro' })]),
},
});
const result = await service.getEffective();
const keys = result.items.map((item) => item.key);
expect(keys).toContain('platform.name');
expect(keys).toContain('limit.customer.base_field');
expect(keys).not.toContain('naming.script.pattern');
expect(result.items.find((item) => item.key === 'platform.name')?.value).toBe('灵犀 Pro');
expect(result.items.find((item) => item.key === 'limit.customer.base_field')?.value).toBe(10);
});
});
describe('SystemConfigService · 批量保存', () => {
it('未定义 key / 非法值返回 40001', async () => {
const { service } = harness();
await expectRejected(
service.update({ changes: [{ key: 'unknown.key', value: 1 }] }, 'u1'),
ErrorCode.PARAM_INVALID,
);
await expectRejected(
service.update({ changes: [{ key: 'limit.customer.base_field', value: 999 }] }, 'u1'),
ErrorCode.PARAM_INVALID,
);
});
it('expectedVersion 不一致返回 40901,且不写库', async () => {
const { service, prisma } = harness({
systemConfig: {
findUnique: vi
.fn()
.mockResolvedValue(
configRow({ configKey: 'limit.customer.base_field', configValue: 10, version: 2 }),
),
},
});
await expectRejected(
service.update(
{
changes: [
{ key: 'limit.customer.base_field', value: 12, expectedVersion: 1, reason: '调整上限' },
],
},
'u1',
),
ErrorCode.VERSION_CONFLICT,
);
expect(prisma.systemConfig.updateMany).not.toHaveBeenCalled();
expect(prisma.systemConfigVersion.create).not.toHaveBeenCalled();
});
it('条件更新命中 0 行(并发修改)返回 40901', async () => {
const { service } = harness({
systemConfig: {
findUnique: vi
.fn()
.mockResolvedValue(
configRow({ configKey: 'limit.customer.base_field', configValue: 10 }),
),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
});
await expectRejected(
service.update({ changes: [{ key: 'limit.customer.base_field', value: 12 }] }, 'u1'),
ErrorCode.VERSION_CONFLICT,
);
});
it('值相同跳过:不写库、不写版本历史', async () => {
const { service, prisma, patchAudit } = harness({
systemConfig: {
findUnique: vi
.fn()
.mockResolvedValue(
configRow({ configKey: 'limit.customer.base_field', configValue: 10 }),
),
},
});
await service.update({ changes: [{ key: 'limit.customer.base_field', value: 10 }] }, 'u1');
expect(prisma.systemConfig.updateMany).not.toHaveBeenCalled();
expect(prisma.systemConfigVersion.create).not.toHaveBeenCalled();
expect(patchAudit).not.toHaveBeenCalled();
});
it('成功保存:version+1、写版本历史(old/new/reason/changedBy)与审计前后值', async () => {
const before = configRow({
configKey: 'limit.customer.base_field',
configValue: 10,
version: 1,
});
const { service, prisma, patchAudit } = harness({
systemConfig: {
findUnique: vi.fn().mockResolvedValue(before),
findMany: vi
.fn()
.mockResolvedValue([
configRow({ configKey: 'limit.customer.base_field', configValue: 12, version: 2 }),
]),
},
});
const result = await service.update(
{
changes: [
{ key: 'limit.customer.base_field', value: 12, expectedVersion: 1, reason: 'A4 验收' },
],
},
'u1',
);
expect(prisma.systemConfig.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { configKey: 'limit.customer.base_field', version: 1 },
data: expect.objectContaining({
configValue: 12,
version: { increment: 1 },
updatedBy: 'u1',
}),
}),
);
expect(prisma.systemConfigVersion.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
configKey: 'limit.customer.base_field',
version: 2,
oldValue: 10,
newValue: 12,
reason: 'A4 验收',
changedBy: 'u1',
}),
}),
);
expect(patchAudit).toHaveBeenCalledWith(
expect.objectContaining({
targetType: 'system_config',
before: { 'limit.customer.base_field': 10 },
after: { 'limit.customer.base_field': 12 },
}),
);
expect(result.items).toHaveLength(SYSTEM_CONFIG_DEFINITIONS.length);
});
it('DB 缺行时首次落库 version 1 并写版本历史(oldValue 为空)', async () => {
const { service, prisma } = harness({
systemConfig: { findUnique: vi.fn().mockResolvedValue(null) },
});
await service.update({ changes: [{ key: 'platform.name', value: '灵犀 Pro' }] }, 'u1');
expect(prisma.systemConfig.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ version: 1, configValue: '灵犀 Pro' }),
}),
);
expect(prisma.systemConfigVersion.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ version: 1, newValue: '灵犀 Pro' }),
}),
);
});
});
describe('SystemConfigService · 变更历史', () => {
it('按倒序读取、解析操作人姓名、label 取定义、result 固定已生效', async () => {
const { service, prisma } = harness({
systemConfigVersion: {
findMany: vi.fn().mockResolvedValue([
{
id: 'v1',
configKey: 'limit.customer.base_field',
version: 2,
oldValue: 10,
newValue: 12,
reason: 'A4 验收',
changedBy: 'u1',
changedAt: NOW,
},
]),
},
user: { findMany: vi.fn().mockResolvedValue([{ id: 'u1', name: '江岚' }]) },
});
const result = await service.history({ limit: 50 });
expect(prisma.systemConfigVersion.findMany).toHaveBeenCalledWith(
expect.objectContaining({ orderBy: [{ changedAt: 'desc' }, { id: 'desc' }], take: 50 }),
);
expect(result.next_cursor).toBeNull();
expect(result.items[0]).toMatchObject({
key: 'limit.customer.base_field',
label: systemConfigDefinition('limit.customer.base_field')?.label,
version: 2,
oldValue: 10,
newValue: 12,
reason: 'A4 验收',
operator: '江岚',
changedAt: NOW.toISOString(),
result: '已生效',
});
});
it('支持 config_key 过滤;操作人用户已删除时 operator 为 null', async () => {
const { service, prisma } = harness({
systemConfigVersion: {
findMany: vi.fn().mockResolvedValue([
{
id: 'v2',
configKey: 'platform.name',
version: 3,
oldValue: '灵犀',
newValue: '灵犀 Pro',
reason: null,
changedBy: 'deleted-user',
changedAt: NOW,
},
]),
},
});
const result = await service.history({ config_key: 'platform.name', limit: 20 });
expect(prisma.systemConfigVersion.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { configKey: 'platform.name' } }),
);
expect(result.items[0]?.operator).toBeNull();
});
});
describe('SystemConfigService · 阈值读取(R-26)', () => {
it('缺行时回退定义默认值;读库异常时回退调用方兜底值', async () => {
const { service } = harness();
await expect(service.getNumber('limit.customer.base_field')).resolves.toBe(10);
await expect(service.getBoolean('notify.task.completed')).resolves.toBe(true);
await expect(service.getString('platform.name')).resolves.toBe('灵犀');
await expect(service.getNamingPattern('script')).resolves.toBe(
'{行业}-{客户}-{脚本方向}-{日期}-{序号}',
);
const broken = harness({
systemConfig: { findUnique: vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')) },
});
await expect(broken.service.getNumber('limit.role.max', 20)).resolves.toBe(20);
});
it('库内命名模板非法时回退定义默认模板', async () => {
const { service } = harness({
systemConfig: {
findUnique: vi.fn().mockResolvedValue(configRow({ configValue: '{未知占位符}*' })),
},
});
await expect(service.getNamingPattern('image')).resolves.toBe(
'{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}',
);
});
});
@@ -0,0 +1,307 @@
/* eslint-disable max-lines-per-function, complexity -- 批量事务更新需逐条校验 / 乐观锁 / 版本历史,集中保证事务边界与审计口径一致 */
import {
type EffectiveSystemConfigsResponse,
ErrorCode,
NAMING_MAX_LENGTH,
SYSTEM_CONFIG_DEFINITIONS,
systemConfigDefinition,
type SystemConfigHistoryQuery,
type SystemConfigHistoryResponse,
type SystemConfigListResponse,
type SystemConfigValue,
systemConfigValueType,
ulid,
type UpdateSystemConfigsInput,
validateNamingTemplate,
validateSystemConfigValue,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { AppException } from '../../../common/errors/app-exception';
import { RequestContextService } from '../../../common/security/request-context';
import type { PrismaService } from '../../../infra/prisma/prisma.service';
import { PRISMA } from '../../../infra/tokens';
import { normalizeConfigValue, toSystemConfig, toSystemConfigChange } from './system-config.mapper';
interface ConfigChangeSnapshot {
key: string;
oldValue: SystemConfigValue | null;
newValue: SystemConfigValue;
version: number;
}
export type NamingKind = 'script' | 'image' | 'video';
/**
* 系统配置(A4-BE-02 / PRD R-26):阈值 / 上限的唯一读取入口。
*
* - 元数据来自 `SYSTEM_CONFIG_DEFINITIONS`,值来自 `system_configs`;
* - 批量保存走事务 + 乐观锁(expectedVersion,冲突 40901),每次变更写 `system_config_versions`;
* - 其他模块只通过本 Service 读配置,禁止再硬编码阈值(R-26)。
*/
@Injectable()
export class SystemConfigService {
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
/** 全量配置(含非公开项,需 system:configure) */
async list(): Promise<SystemConfigListResponse> {
const rows = await this.prisma.systemConfig.findMany();
const byKey = new Map(rows.map((row) => [row.configKey, row]));
const nameById = await this.userNames(
rows.flatMap((row) => (row.updatedBy === null ? [] : [row.updatedBy])),
);
return {
items: SYSTEM_CONFIG_DEFINITIONS.map((definition) => {
const row = byKey.get(definition.key);
return toSystemConfig(
definition,
row,
row?.updatedBy === null || row?.updatedBy === undefined
? null
: (nameById.get(row.updatedBy) ?? null),
);
}),
};
}
/** 生效配置:只返回 public 项,供前端读取上限(R-26),仅登录即可 */
async getEffective(): Promise<EffectiveSystemConfigsResponse> {
const publicDefinitions = SYSTEM_CONFIG_DEFINITIONS.filter((definition) => definition.public);
const rows = await this.prisma.systemConfig.findMany({
where: { configKey: { in: publicDefinitions.map((definition) => definition.key) } },
});
const byKey = new Map(rows.map((row) => [row.configKey, row]));
return {
items: publicDefinitions.map((definition) => ({
key: definition.key,
value: normalizeConfigValue(
byKey.get(definition.key)?.configValue,
definition.defaultValue,
),
})),
};
}
/**
* 批量保存:事务内逐条校验 + 乐观锁 + 版本历史;值未变化可跳过(不写历史)。
* 返回保存后的全量配置,便于前端原地刷新版本号。
*/
async update(
input: UpdateSystemConfigsInput,
actorId: string,
): Promise<SystemConfigListResponse> {
const snapshots = await this.prisma.$transaction(async (tx) => {
const changed: ConfigChangeSnapshot[] = [];
for (const change of input.changes) {
const definition = systemConfigDefinition(change.key);
if (definition === undefined) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `配置项不存在:${change.key}`,
});
}
const validated = validateSystemConfigValue(definition, change.value);
if (!validated.ok) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `${definition.label}:${validated.message}`,
});
}
const row = await tx.systemConfig.findUnique({ where: { configKey: change.key } });
const baseVersion = row?.version ?? 1;
if (change.expectedVersion !== undefined && change.expectedVersion !== baseVersion) {
throw new AppException({
code: ErrorCode.VERSION_CONFLICT,
message: `配置「${definition.label}」已被他人修改,请刷新后重试`,
details: {
key: change.key,
expectedVersion: change.expectedVersion,
currentVersion: baseVersion,
},
});
}
const baseValue = normalizeConfigValue(row?.configValue, definition.defaultValue);
if (baseValue === validated.value) {
continue;
}
const nextVersion = row === null ? 1 : row.version + 1;
if (row === null) {
await tx.systemConfig.create({
data: {
id: ulid(),
configKey: change.key,
configValue: validated.value,
valueType: systemConfigValueType(definition),
description: definition.description,
version: 1,
isEditable: definition.editable,
updatedBy: actorId,
},
});
await tx.systemConfigVersion.create({
data: {
id: ulid(),
configKey: change.key,
version: 1,
newValue: validated.value,
...(change.reason === undefined ? {} : { reason: change.reason }),
changedBy: actorId,
},
});
} else {
// 条件更新(DB-29):并发保存时只有一个请求能命中旧 version,其余返回 40901
const conditional = await tx.systemConfig.updateMany({
where: { configKey: change.key, version: row.version },
data: {
configValue: validated.value,
version: { increment: 1 },
description: definition.description,
updatedBy: actorId,
},
});
if (conditional.count === 0) {
throw new AppException({
code: ErrorCode.VERSION_CONFLICT,
message: `配置「${definition.label}」已被他人修改,请刷新后重试`,
});
}
await tx.systemConfigVersion.create({
data: {
id: ulid(),
configKey: change.key,
version: nextVersion,
oldValue: baseValue,
newValue: validated.value,
reason: change.reason ?? null,
changedBy: actorId,
},
});
}
changed.push({
key: change.key,
oldValue: row === null ? null : baseValue,
newValue: validated.value,
version: nextVersion,
});
}
return changed;
});
if (snapshots.length > 0) {
this.requestContext.patchAudit({
targetType: 'system_config',
targetId: snapshots.map((snapshot) => snapshot.key).join(','),
before: Object.fromEntries(snapshots.map((snapshot) => [snapshot.key, snapshot.oldValue])),
after: Object.fromEntries(snapshots.map((snapshot) => [snapshot.key, snapshot.newValue])),
changes: snapshots,
});
}
return this.list();
}
/** 变更记录:按 changed_at desc;操作人解析为用户姓名 */
async history(query: SystemConfigHistoryQuery): Promise<SystemConfigHistoryResponse> {
const rows = await this.prisma.systemConfigVersion.findMany({
where: query.config_key === undefined ? {} : { configKey: query.config_key },
orderBy: [{ changedAt: 'desc' }, { id: 'desc' }],
take: query.limit,
});
const changedByIds = [
...new Set(rows.flatMap((row) => (row.changedBy === null ? [] : [row.changedBy]))),
];
const nameById = await this.userNames(changedByIds);
return {
items: rows.map((row) => {
const label = systemConfigDefinition(row.configKey)?.label ?? row.configKey;
const operator = row.changedBy === null ? null : (nameById.get(row.changedBy) ?? null);
return toSystemConfigChange(row, label, operator);
}),
next_cursor: null,
};
}
/** 批量解析 updated_by / changed_by(ULID)为用户姓名,供列表与历史展示 */
private async userNames(userIds: readonly string[]): Promise<Map<string, string>> {
const uniqueIds = [...new Set(userIds)];
if (uniqueIds.length === 0) return new Map();
const users = await this.prisma.user.findMany({
where: { id: { in: uniqueIds } },
select: { id: true, name: true },
});
return new Map(users.map((user) => [user.id, user.name]));
}
// -------------------------------------------------------------------------
// 供其他模块读取配置(R-26):读失败 / 类型不符时回退定义默认值或调用方兜底
// -------------------------------------------------------------------------
async getNumber(key: string, fallback?: number): Promise<number> {
const value = await this.readValue(key);
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (fallback !== undefined) return fallback;
const definition = systemConfigDefinition(key);
if (definition !== undefined && typeof definition.defaultValue === 'number') {
return definition.defaultValue;
}
throw new Error(`系统配置「${key}」不是数字且缺少兜底值`);
}
async getBoolean(key: string, fallback?: boolean): Promise<boolean> {
const value = await this.readValue(key);
if (typeof value === 'boolean') return value;
if (fallback !== undefined) return fallback;
const definition = systemConfigDefinition(key);
if (definition !== undefined && typeof definition.defaultValue === 'boolean') {
return definition.defaultValue;
}
throw new Error(`系统配置「${key}」不是布尔值且缺少兜底值`);
}
async getString(key: string, fallback?: string): Promise<string> {
const value = await this.readValue(key);
if (typeof value === 'string' && value.length > 0) return value;
if (fallback !== undefined) return fallback;
const definition = systemConfigDefinition(key);
if (definition !== undefined && typeof definition.defaultValue === 'string') {
return definition.defaultValue;
}
throw new Error(`系统配置「${key}」不是字符串且缺少兜底值`);
}
/** 读取命名模板类配置:库内模板非法时回退默认模板,避免脏配置导致命名全线失败 */
async getPattern(key: string, fallback?: string): Promise<string> {
const definition = systemConfigDefinition(key);
const value = await this.readValue(key);
const maxLength = definition?.maxLength ?? NAMING_MAX_LENGTH;
if (typeof value === 'string' && validateNamingTemplate(value, { maxLength }).valid) {
return value;
}
if (fallback !== undefined) return fallback;
if (definition !== undefined && typeof definition.defaultValue === 'string') {
return definition.defaultValue;
}
throw new Error(`系统配置「${key}」不是合法命名模板且缺少兜底值`);
}
async getNamingPattern(kind: NamingKind): Promise<string> {
return this.getPattern(`naming.${kind}.pattern`);
}
/** 读库失败(连接抖动等)不阻断业务:返回 undefined,由调用方兜底默认值 */
private async readValue(key: string): Promise<SystemConfigValue | undefined> {
const definition = systemConfigDefinition(key);
try {
const row = await this.prisma.systemConfig.findUnique({
where: { configKey: key },
select: { configValue: true },
});
if (row === null) return undefined;
return normalizeConfigValue(row.configValue, definition?.defaultValue ?? '');
} catch {
return undefined;
}
}
}
+121
View File
@@ -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');
});
});
+68
View File
@@ -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);
}
+236
View File
@@ -0,0 +1,236 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { setUnauthorizedHandler } from './client';
import {
applyCustomerField,
applyIndustryPack,
approveCustomerField,
createCustomer,
createCustomerGroup,
createIndustry,
deleteCustomerGroup,
deleteIndustry,
fetchCustomerDetail,
fetchCustomerFieldList,
fetchCustomerFields,
fetchCustomerGroupDetail,
fetchCustomerGroupList,
fetchCustomerList,
fetchIndustryDetail,
fetchIndustryList,
setCustomerGroups,
setCustomerStatus,
setIndustryStatus,
transferCustomer,
updateCustomer,
updateCustomerGroup,
updateCustomerGroupMembers,
updateIndustry,
} from './customers';
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ID2 = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const NOW = '2026-11-03T10:00:00.000Z';
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 industryNode = {
id: ID,
parentId: null,
level: 1,
name: '电商',
sortOrder: 10,
status: 'active',
remark: '',
customerCount: 1,
assetCount: 0,
updatedAt: NOW,
};
const industryTree = { ...industryNode, children: [] };
const industryDetail = {
...industryNode,
parentName: null,
references: {
childCount: 0,
activeChildCount: 0,
customerCount: 1,
customers: [],
assetCount: 0,
},
};
const customerSummary = {
id: ID,
name: '花间集',
shortName: 'HJ',
industryId: ID,
industryName: '服饰鞋包',
industryPath: '电商 / 服饰鞋包',
status: 'active',
ownerUserId: ID2,
ownerName: '陈默',
groups: [],
baseFieldCount: 1,
genFieldCount: 1,
updatedAt: NOW,
};
const customerDetail = {
...customerSummary,
contactName: '林晓',
contactPhone: '138****2210',
preferences: { naming: '', directory: '', specs: [], taboo: [] },
baseFields: [{ key: '门店', value: '沪-001' }],
fields: [],
remark: null,
createdAt: NOW,
};
const groupSummary = {
id: ID,
name: '电商核心组',
description: '',
memberCount: 0,
updatedAt: NOW,
};
const groupDetail = { ...groupSummary, members: [] };
const field = {
id: ID,
customerId: ID2,
name: '活动编码',
code: 'campaign_code',
type: 'text',
options: [],
purpose: '检索',
entersGeneration: true,
status: 'pending',
applicantName: '陈默',
approverName: null,
appliedAt: NOW,
approvedAt: null,
rejectReason: null,
};
const fieldListItem = {
...field,
customerName: '花间集',
customerShortName: 'HJ',
industryPath: '电商 / 服饰鞋包',
};
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: ID2, name: '陈默', account: 'chen.mo' },
permissions: ['customer:read'],
});
});
describe('客户与行业接口封装', () => {
it('行业:列表 / 详情 / 新建 / 更新 / 状态 / 删除', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [industryTree], next_cursor: null }))
.mockResolvedValueOnce(jsonResponse(industryDetail))
.mockResolvedValueOnce(jsonResponse(industryDetail, 201))
.mockResolvedValueOnce(jsonResponse(industryDetail))
.mockResolvedValueOnce(jsonResponse(industryDetail))
.mockResolvedValueOnce(jsonResponse(undefined, 204));
await fetchIndustryList({ keyword: '电商', limit: 100 });
expect(lastCall().url).toContain('/industries?keyword=');
await fetchIndustryDetail(ID);
await createIndustry({ name: '电商', sortOrder: 10, remark: '' });
expect(lastBody()).toEqual({ name: '电商', sortOrder: 10, remark: '' });
await updateIndustry(ID, { name: '电商2' });
await setIndustryStatus(ID, { status: 'disabled' });
await deleteIndustry(ID);
expect(lastCall().url).toContain(`/industries/${ID}`);
});
it('客户:列表 / 详情 / 新建 / 更新 / 状态 / 转交 / 组 / 资产包', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [customerSummary], next_cursor: null }))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse(customerDetail, 201))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse({}, 200));
await fetchCustomerList({ status: 'active', limit: 100 });
expect(lastCall().url).toContain('/customers?status=active');
await fetchCustomerDetail(ID);
await createCustomer({
industryId: ID,
name: '花间集',
shortName: 'HJ',
ownerUserId: ID2,
});
await updateCustomer(ID, { shortName: 'HJ2' });
await setCustomerStatus(ID, { status: 'paused' });
await transferCustomer(ID, { ownerUserId: ID2 });
await setCustomerGroups(ID, { groupIds: [ID] });
await applyIndustryPack(ID);
expect(lastCall().url).toContain(`/customers/${ID}/apply-industry-pack`);
});
it('客户组:列表 / 详情 / 新建 / 更新 / 成员 / 删除', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [groupSummary], next_cursor: null }))
.mockResolvedValueOnce(jsonResponse(groupDetail))
.mockResolvedValueOnce(jsonResponse(groupDetail, 201))
.mockResolvedValueOnce(jsonResponse(groupDetail))
.mockResolvedValueOnce(jsonResponse(groupDetail))
.mockResolvedValueOnce(jsonResponse(undefined, 204));
await fetchCustomerGroupList();
await fetchCustomerGroupDetail(ID);
await createCustomerGroup({ name: '电商核心组', description: '' });
await updateCustomerGroup(ID, { description: '说明' });
await updateCustomerGroupMembers(ID, { addCustomerIds: [ID2], removeCustomerIds: [] });
await deleteCustomerGroup(ID);
});
it('客户字段:审批队列 / 客户字段 / 申请 / 审批', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [fieldListItem], next_cursor: null }))
.mockResolvedValueOnce(jsonResponse([field]))
.mockResolvedValueOnce(jsonResponse(field, 201))
.mockResolvedValueOnce(jsonResponse(field));
await fetchCustomerFieldList({ status: 'pending', limit: 100 });
await fetchCustomerFields(ID);
await applyCustomerField(ID, {
name: '活动编码',
code: 'campaign_code',
type: 'text',
options: [],
purpose: '检索',
entersGeneration: true,
});
await approveCustomerField(ID, { decision: 'approve' });
expect(lastCall().url).toContain(`/customer-fields/${ID}/approve`);
});
});
+277
View File
@@ -0,0 +1,277 @@
import {
type ApplyCustomerFieldInput,
applyCustomerFieldInputSchema,
type ApproveCustomerFieldInput,
approveCustomerFieldInputSchema,
type CreateCustomerGroupInput,
createCustomerGroupInputSchema,
type CreateCustomerInput,
createCustomerInputSchema,
type CreateIndustryInput,
createIndustryInputSchema,
type CustomerDetail,
customerDetailSchema,
type CustomerField,
type CustomerFieldListItem,
customerFieldListItemSchema,
type CustomerFieldListQuery,
customerFieldSchema,
type CustomerGroupDetail,
customerGroupDetailSchema,
type CustomerGroupListQuery,
type CustomerGroupSummary,
customerGroupSummarySchema,
type CustomerListQuery,
type CustomerSummary,
customerSummarySchema,
type IndustryDetail,
industryDetailSchema,
type IndustryListQuery,
type IndustryTreeItem,
industryTreeItemSchema,
listResponseSchema,
type SetCustomerGroupsInput,
setCustomerGroupsInputSchema,
type SetCustomerStatusInput,
setCustomerStatusInputSchema,
type SetIndustryStatusInput,
setIndustryStatusInputSchema,
type TransferCustomerInput,
transferCustomerInputSchema,
type UpdateCustomerGroupInput,
updateCustomerGroupInputSchema,
type UpdateCustomerGroupMembersInput,
updateCustomerGroupMembersInputSchema,
type UpdateCustomerInput,
updateCustomerInputSchema,
type UpdateIndustryInput,
updateIndustryInputSchema,
} from '@lingxi/shared';
import { request } from './client';
import { ENDPOINTS } from './endpoints';
const industryListResponseSchema = listResponseSchema(industryTreeItemSchema);
const customerListResponseSchema = listResponseSchema(customerSummarySchema);
const groupListResponseSchema = listResponseSchema(customerGroupSummarySchema);
const fieldListResponseSchema = listResponseSchema(customerFieldListItemSchema);
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}`;
}
export async function fetchIndustryList(
query: Partial<IndustryListQuery> = {},
): Promise<{ items: IndustryTreeItem[]; next_cursor: string | null }> {
const payload: unknown = await request(
`${ENDPOINTS.industries}${searchParams({
keyword: query.keyword,
status: query.status,
parent_id: query.parent_id,
limit: query.limit,
cursor: query.cursor,
})}`,
);
return industryListResponseSchema.parse(payload);
}
export async function fetchIndustryDetail(id: string): Promise<IndustryDetail> {
const payload: unknown = await request(ENDPOINTS.industry(id));
return industryDetailSchema.parse(payload);
}
export async function createIndustry(input: CreateIndustryInput): Promise<IndustryDetail> {
const body = createIndustryInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.industries, { method: 'POST', body });
return industryDetailSchema.parse(payload);
}
export async function updateIndustry(
id: string,
input: UpdateIndustryInput,
): Promise<IndustryDetail> {
const body = updateIndustryInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.industry(id), { method: 'PATCH', body });
return industryDetailSchema.parse(payload);
}
export async function setIndustryStatus(
id: string,
input: SetIndustryStatusInput,
): Promise<IndustryDetail> {
const body = setIndustryStatusInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.industryStatus(id), { method: 'PATCH', body });
return industryDetailSchema.parse(payload);
}
export async function deleteIndustry(id: string): Promise<void> {
await request<unknown>(ENDPOINTS.industry(id), { method: 'DELETE' });
}
export async function fetchCustomerList(
query: Partial<CustomerListQuery> = {},
): Promise<{ items: CustomerSummary[]; next_cursor: string | null }> {
const payload: unknown = await request(
`${ENDPOINTS.customers}${searchParams({
keyword: query.keyword,
industry_id: query.industry_id,
status: query.status,
owner_user_id: query.owner_user_id,
group_id: query.group_id,
limit: query.limit,
cursor: query.cursor,
})}`,
);
return customerListResponseSchema.parse(payload);
}
export async function fetchCustomerDetail(id: string): Promise<CustomerDetail> {
const payload: unknown = await request(ENDPOINTS.customer(id));
return customerDetailSchema.parse(payload);
}
export async function createCustomer(input: CreateCustomerInput): Promise<CustomerDetail> {
const body = createCustomerInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customers, { method: 'POST', body });
return customerDetailSchema.parse(payload);
}
export async function updateCustomer(
id: string,
input: UpdateCustomerInput,
): Promise<CustomerDetail> {
const body = updateCustomerInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customer(id), { method: 'PATCH', body });
return customerDetailSchema.parse(payload);
}
export async function setCustomerStatus(
id: string,
input: SetCustomerStatusInput,
): Promise<CustomerDetail> {
const body = setCustomerStatusInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerStatus(id), { method: 'PATCH', body });
return customerDetailSchema.parse(payload);
}
export async function transferCustomer(
id: string,
input: TransferCustomerInput,
): Promise<CustomerDetail> {
const body = transferCustomerInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerTransfer(id), { method: 'POST', body });
return customerDetailSchema.parse(payload);
}
export async function setCustomerGroups(
id: string,
input: SetCustomerGroupsInput,
): Promise<CustomerDetail> {
const body = setCustomerGroupsInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerGroups(id), { method: 'PUT', body });
return customerDetailSchema.parse(payload);
}
export async function applyIndustryPack(id: string): Promise<void> {
await request<unknown>(ENDPOINTS.customerApplyPack(id), { method: 'POST' });
}
export async function fetchCustomerGroupList(
query: Partial<CustomerGroupListQuery> = {},
): Promise<{ items: CustomerGroupSummary[]; next_cursor: string | null }> {
const payload: unknown = await request(
`${ENDPOINTS.customerGroupsList}${searchParams({ keyword: query.keyword, limit: query.limit, cursor: query.cursor })}`,
);
return groupListResponseSchema.parse(payload);
}
export async function fetchCustomerGroupDetail(id: string): Promise<CustomerGroupDetail> {
const payload: unknown = await request(ENDPOINTS.customerGroup(id));
return customerGroupDetailSchema.parse(payload);
}
export async function createCustomerGroup(
input: CreateCustomerGroupInput,
): Promise<CustomerGroupDetail> {
const body = createCustomerGroupInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerGroupsList, { method: 'POST', body });
return customerGroupDetailSchema.parse(payload);
}
export async function updateCustomerGroup(
id: string,
input: UpdateCustomerGroupInput,
): Promise<CustomerGroupDetail> {
const body = updateCustomerGroupInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerGroup(id), { method: 'PATCH', body });
return customerGroupDetailSchema.parse(payload);
}
export async function updateCustomerGroupMembers(
id: string,
input: UpdateCustomerGroupMembersInput,
): Promise<CustomerGroupDetail> {
const body = updateCustomerGroupMembersInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerGroupMembers(id), {
method: 'POST',
body,
});
return customerGroupDetailSchema.parse(payload);
}
export async function deleteCustomerGroup(id: string): Promise<void> {
await request<unknown>(ENDPOINTS.customerGroup(id), { method: 'DELETE' });
}
export async function fetchCustomerFieldList(
query: Partial<CustomerFieldListQuery> = {},
): Promise<{ items: CustomerFieldListItem[]; next_cursor: string | null }> {
const payload: unknown = await request(
`${ENDPOINTS.customerFields}${searchParams({
customer_id: query.customer_id,
status: query.status,
limit: query.limit,
cursor: query.cursor,
})}`,
);
return fieldListResponseSchema.parse(payload);
}
export async function fetchCustomerFields(customerId: string): Promise<CustomerField[]> {
const payload: unknown = await request(ENDPOINTS.customerFieldsOf(customerId));
const schema = listResponseSchema(customerFieldSchema);
// 后端该接口返回数组;列表信封仅用于 /customer-fields
if (Array.isArray(payload)) {
return payload.map((item) => customerFieldSchema.parse(item));
}
return schema.parse(payload).items;
}
export async function applyCustomerField(
customerId: string,
input: ApplyCustomerFieldInput,
): Promise<CustomerField> {
const body = applyCustomerFieldInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerFieldsOf(customerId), {
method: 'POST',
body,
});
return customerFieldSchema.parse(payload);
}
export async function approveCustomerField(
id: string,
input: ApproveCustomerFieldInput,
): Promise<CustomerField> {
const body = approveCustomerFieldInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerFieldApprove(id), {
method: 'POST',
body,
});
return customerFieldSchema.parse(payload);
}
+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: '说明',
});
});
});
+133
View File
@@ -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);
}
+36
View File
@@ -4,6 +4,8 @@
* 后端若调整路径,只改这里;联调时以 `apps/web/src/api/generated/openapi.d.ts` 为准。 * 后端若调整路径,只改这里;联调时以 `apps/web/src/api/generated/openapi.d.ts` 为准。
*/ */
const USERS = '/users'; const USERS = '/users';
const DICTS = '/dicts';
const SYSTEM_CONFIGS = '/system/configs';
export const ENDPOINTS = { export const ENDPOINTS = {
login: '/auth/login', login: '/auth/login',
@@ -28,4 +30,38 @@ export const ENDPOINTS = {
roles: '/roles', roles: '/roles',
role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`, role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`,
permissions: '/permissions', permissions: '/permissions',
// A3 client
industries: '/industries',
industry: (industryId: string): string => `/industries/${encodeURIComponent(industryId)}`,
industryStatus: (industryId: string): string =>
`/industries/${encodeURIComponent(industryId)}/status`,
customers: '/customers',
customer: (customerId: string): string => `/customers/${encodeURIComponent(customerId)}`,
customerStatus: (customerId: string): string =>
`/customers/${encodeURIComponent(customerId)}/status`,
customerTransfer: (customerId: string): string =>
`/customers/${encodeURIComponent(customerId)}/transfer`,
customerGroups: (customerId: string): string =>
`/customers/${encodeURIComponent(customerId)}/groups`,
customerApplyPack: (customerId: string): string =>
`/customers/${encodeURIComponent(customerId)}/apply-industry-pack`,
customerGroupsList: '/customer-groups',
customerGroup: (groupId: string): string => `/customer-groups/${encodeURIComponent(groupId)}`,
customerGroupMembers: (groupId: string): string =>
`/customer-groups/${encodeURIComponent(groupId)}/members`,
customerFields: '/customer-fields',
customerFieldsOf: (customerId: string): string =>
`/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; } as const;
+1068 -37
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -13,4 +13,26 @@ export const queryKeys = {
roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const, roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const,
role: (roleId: string) => ['roles', 'detail', roleId] as const, role: (roleId: string) => ['roles', 'detail', roleId] as const,
permissions: ['permissions'] as const, permissions: ['permissions'] as const,
// A3 client
industries: ['industries'] as const,
industryList: (query: object) => ['industries', 'list', query] as const,
industry: (industryId: string) => ['industries', 'detail', industryId] as const,
customers: ['customers'] as const,
customerList: (query: object) => ['customers', 'list', query] as const,
customer: (customerId: string) => ['customers', 'detail', customerId] as const,
customerFieldsOf: (customerId: string) => ['customers', 'fields', customerId] as const,
customerGroups: ['customer-groups'] as const,
customerGroupList: (query: object) => ['customer-groups', 'list', query] as const,
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,
}; };
+353
View File
@@ -0,0 +1,353 @@
import type {
ApplyCustomerFieldInput,
ApproveCustomerFieldInput,
CreateCustomerGroupInput,
CreateCustomerInput,
CreateIndustryInput,
CustomerDetail,
CustomerField,
CustomerFieldListItem,
CustomerFieldListQuery,
CustomerGroupDetail,
CustomerGroupSummary,
CustomerListQuery,
CustomerSummary,
IndustryDetail,
IndustryListQuery,
IndustryTreeItem,
SetCustomerGroupsInput,
SetCustomerStatusInput,
SetIndustryStatusInput,
TransferCustomerInput,
UpdateCustomerGroupInput,
UpdateCustomerGroupMembersInput,
UpdateCustomerInput,
UpdateIndustryInput,
} from '@lingxi/shared';
import {
keepPreviousData,
useMutation,
type UseMutationResult,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import {
applyCustomerField,
approveCustomerField,
createCustomer,
createCustomerGroup,
createIndustry,
deleteCustomerGroup,
deleteIndustry,
fetchCustomerDetail,
fetchCustomerFieldList,
fetchCustomerFields,
fetchCustomerGroupDetail,
fetchCustomerGroupList,
fetchCustomerList,
fetchIndustryDetail,
fetchIndustryList,
setCustomerGroups,
setCustomerStatus,
setIndustryStatus,
transferCustomer,
updateCustomer,
updateCustomerGroup,
updateCustomerGroupMembers,
updateIndustry,
} from '@/api/customers';
import { queryKeys } from '@/api/query-keys';
export function useIndustriesQuery(
query: Partial<IndustryListQuery> = {},
): UseQueryResult<{ items: IndustryTreeItem[]; next_cursor: string | null }> {
return useQuery({
queryKey: queryKeys.industryList(query),
queryFn: () => fetchIndustryList(query),
staleTime: 10_000,
});
}
export function useIndustryDetailQuery(id: string | null): UseQueryResult<IndustryDetail> {
return useQuery({
queryKey: queryKeys.industry(id ?? ''),
queryFn: () => fetchIndustryDetail(id ?? ''),
enabled: id !== null && id !== '',
});
}
export function useCustomersQuery(
query: Partial<CustomerListQuery> = {},
): UseQueryResult<{ items: CustomerSummary[]; next_cursor: string | null }> {
return useQuery({
queryKey: queryKeys.customerList(query),
queryFn: () => fetchCustomerList(query),
placeholderData: keepPreviousData,
staleTime: 10_000,
});
}
export function useCustomerDetailQuery(id: string | null): UseQueryResult<CustomerDetail> {
return useQuery({
queryKey: queryKeys.customer(id ?? ''),
queryFn: () => fetchCustomerDetail(id ?? ''),
enabled: id !== null && id !== '',
});
}
export function useCustomerGroupsQuery(): UseQueryResult<{
items: CustomerGroupSummary[];
next_cursor: string | null;
}> {
const query = { limit: 100 };
return useQuery({
queryKey: queryKeys.customerGroupList(query),
queryFn: () => fetchCustomerGroupList(query),
staleTime: 10_000,
});
}
export function useCustomerGroupDetailQuery(
id: string | null,
): UseQueryResult<CustomerGroupDetail> {
return useQuery({
queryKey: queryKeys.customerGroup(id ?? ''),
queryFn: () => fetchCustomerGroupDetail(id ?? ''),
enabled: id !== null && id !== '',
});
}
export function useCustomerFieldsQuery(
query: Partial<CustomerFieldListQuery> = {},
): UseQueryResult<{ items: CustomerFieldListItem[]; next_cursor: string | null }> {
return useQuery({
queryKey: queryKeys.customerFields(query),
queryFn: () => fetchCustomerFieldList(query),
placeholderData: keepPreviousData,
staleTime: 10_000,
});
}
export function useCustomerFieldsOfQuery(
customerId: string | null,
): UseQueryResult<CustomerField[]> {
return useQuery({
queryKey: queryKeys.customerFieldsOf(customerId ?? ''),
queryFn: () => fetchCustomerFields(customerId ?? ''),
enabled: customerId !== null && customerId !== '',
});
}
function useInvalidateClient(): () => Promise<void> {
const queryClient = useQueryClient();
return async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.industries });
await queryClient.invalidateQueries({ queryKey: queryKeys.customers });
await queryClient.invalidateQueries({ queryKey: queryKeys.customerGroups });
await queryClient.invalidateQueries({ queryKey: queryKeys.customerFieldsRoot });
};
}
export function useCreateIndustryMutation(): UseMutationResult<
IndustryDetail,
Error,
CreateIndustryInput
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: createIndustry,
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateIndustryMutation(): UseMutationResult<
IndustryDetail,
Error,
{ id: string; input: UpdateIndustryInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => updateIndustry(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useSetIndustryStatusMutation(): UseMutationResult<
IndustryDetail,
Error,
{ id: string; input: SetIndustryStatusInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => setIndustryStatus(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useDeleteIndustryMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: deleteIndustry,
onSuccess: async () => {
await invalidate();
},
});
}
export function useCreateCustomerMutation(): UseMutationResult<
CustomerDetail,
Error,
CreateCustomerInput
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: createCustomer,
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateCustomerMutation(): UseMutationResult<
CustomerDetail,
Error,
{ id: string; input: UpdateCustomerInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => updateCustomer(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useSetCustomerStatusMutation(): UseMutationResult<
CustomerDetail,
Error,
{ id: string; input: SetCustomerStatusInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => setCustomerStatus(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useTransferCustomerMutation(): UseMutationResult<
CustomerDetail,
Error,
{ id: string; input: TransferCustomerInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => transferCustomer(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useSetCustomerGroupsMutation(): UseMutationResult<
CustomerDetail,
Error,
{ id: string; input: SetCustomerGroupsInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => setCustomerGroups(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useCreateCustomerGroupMutation(): UseMutationResult<
CustomerGroupDetail,
Error,
CreateCustomerGroupInput
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: createCustomerGroup,
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateCustomerGroupMutation(): UseMutationResult<
CustomerGroupDetail,
Error,
{ id: string; input: UpdateCustomerGroupInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => updateCustomerGroup(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateCustomerGroupMembersMutation(): UseMutationResult<
CustomerGroupDetail,
Error,
{ id: string; input: UpdateCustomerGroupMembersInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => updateCustomerGroupMembers(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useDeleteCustomerGroupMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: deleteCustomerGroup,
onSuccess: async () => {
await invalidate();
},
});
}
export function useApplyCustomerFieldMutation(): UseMutationResult<
CustomerField,
Error,
{ customerId: string; input: ApplyCustomerFieldInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ customerId, input }) => applyCustomerField(customerId, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useApproveCustomerFieldMutation(): UseMutationResult<
CustomerField,
Error,
{ id: string; input: ApproveCustomerFieldInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => approveCustomerField(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
+108
View File
@@ -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'] }),
]);
},
});
}
+160
View File
@@ -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();
},
});
}
+5 -2
View File
@@ -11,12 +11,15 @@ import { PageBreadcrumbs } from './PageBreadcrumbs';
import { SideNav } from './SideNav'; import { SideNav } from './SideNav';
import { TopBar } from './TopBar'; import { TopBar } from './TopBar';
/** 用户管理、部门管理为「满高主从布局」,由页面自己控制内部滚动 */ /** 用户管理、部门管理、数据字典、系统配置为「满高主从布局」,由页面自己控制内部滚动 */
function isFlushRoute(pathname: string): boolean { function isFlushRoute(pathname: string): boolean {
return ( return (
pathname === ROUTES.system.users || pathname === ROUTES.system.users ||
pathname.startsWith(`${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
); );
} }
+3
View File
@@ -9,6 +9,9 @@ import '@/styles/users.css';
import '@/styles/roles.css'; import '@/styles/roles.css';
import '@/styles/role-editor.css'; import '@/styles/role-editor.css';
import '@/styles/departments.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 { LocaleProvider } from '@douyinfe/semi-ui-19';
import { localeZhCN } from '@lingxi/ui'; import { localeZhCN } from '@lingxi/ui';
@@ -53,6 +53,28 @@ describe('导航配置', () => {
expect(findActiveNav(NAV_MODULES, '/system/departments')?.item?.label).toBe('部门管理'); expect(findActiveNav(NAV_MODULES, '/system/departments')?.item?.label).toBe('部门管理');
}); });
it('客户与行业菜单使用查看权限,名称按 A3 口径更名', () => {
const systemItems = NAV_MODULES.find((module) => module.key === 'system')?.items ?? [];
const customersItem = systemItems.find((item) => item.key === 'customers');
const industriesItem = systemItems.find((item) => item.key === 'industries');
expect(customersItem?.label).toBe('客户管理');
expect(customersItem?.permission).toBe('customer:read');
expect(industriesItem?.label).toBe('行业管理');
expect(industriesItem?.permission).toBe('customer:read');
expect(
findActiveNav(NAV_MODULES, '/system/customers/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.item?.label,
).toBe('客户管理');
expect(findActiveNav(NAV_MODULES, '/system/industries')?.item?.label).toBe('行业管理');
});
it('字典菜单按 A4 口径更名「数据字典」', () => {
const systemItems = NAV_MODULES.find((module) => module.key === 'system')?.items ?? [];
const dictItem = systemItems.find((item) => item.key === 'dicts');
expect(dictItem?.label).toBe('数据字典');
expect(dictItem?.permission).toBe('dict:manage');
expect(findActiveNav(NAV_MODULES, '/system/dicts')?.item?.label).toBe('数据字典');
});
it('按路径定位当前模块与菜单', () => { it('按路径定位当前模块与菜单', () => {
const active = findActiveNav(NAV_MODULES, '/material/quick-review'); const active = findActiveNav(NAV_MODULES, '/material/quick-review');
expect(active?.module.key).toBe('material'); expect(active?.module.key).toBe('material');
+18 -10
View File
@@ -273,27 +273,35 @@ export const NAV_MODULES: readonly NavModule[] = [
}, },
{ {
key: 'customers', key: 'customers',
label: '客户', label: '客户管理',
path: '/system/customers', path: '/system/customers',
icon: IconGlobal, icon: IconGlobal,
permission: Permission.CUSTOMER_MANAGE, permission: Permission.CUSTOMER_READ,
description: '客户、行业归属与数据范围', description: '客户档案、状态与交付偏好',
},
{
key: 'customer-groups',
label: '客户组',
path: '/system/customer-groups',
icon: IconApps,
permission: Permission.CUSTOMER_GROUP_MANAGE,
description: '客户组与批量授权单位',
}, },
{ {
key: 'industries', key: 'industries',
label: '行业树', label: '行业管理',
path: '/system/industries', path: '/system/industries',
icon: IconStack, icon: IconStack,
permission: Permission.INDUSTRY_MANAGE, permission: Permission.CUSTOMER_READ,
description: '行业分类树与行业包绑定', description: '两级行业树、状态与客户归属',
}, },
{ {
key: 'dicts', key: 'dicts',
label: '字典', label: '数据字典',
path: '/system/dicts', path: '/system/dicts',
icon: IconDocs, icon: IconDocs,
permission: Permission.DICT_MANAGE, permission: Permission.DICT_MANAGE,
description: '核心字典与业务枚举', description: '平台枚举、行业词表与编码管理',
}, },
{ {
key: 'audit', key: 'audit',
@@ -338,9 +346,9 @@ export const NAV_MODULES: readonly NavModule[] = [
{ {
key: 'customer-fields', key: 'customer-fields',
label: '客户字段', label: '客户字段',
path: '/system/customer-fields', path: '/system/customer-fields/approvals',
icon: IconTask, icon: IconTask,
permission: Permission.CUSTOMER_MANAGE, permission: Permission.CUSTOMER_FIELD_APPROVE,
description: '客户级扩展字段与审批', description: '客户级扩展字段与审批',
}, },
{ {
@@ -0,0 +1,354 @@
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
import { type CustomerFieldListItem, Permission } from '@lingxi/shared';
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { Can } from '@/components/Can';
import {
useApplyCustomerFieldMutation,
useApproveCustomerFieldMutation,
useCustomerFieldsQuery,
useCustomersQuery,
} from '@/hooks/queries/use-client';
import { useCustomerFieldLimits } from '@/hooks/queries/use-configs';
const FIELD_TYPE_LABELS: Record<string, string> = {
text: '文本',
number: '数字',
date: '日期',
select: '单选',
};
function statusTag(status: CustomerFieldListItem['status']): ReactNode {
const cls =
status === 'active'
? 'active'
: status === 'pending'
? 'paused'
: status === 'rejected'
? 'ended'
: 'disabled';
const label =
status === 'active'
? '已生效'
: status === 'pending'
? '待审批'
: status === 'rejected'
? '已驳回'
: '已停用';
return <span className={`lx-client-tag lx-client-tag--${cls}`}>{label}</span>;
}
function ApplyModal({ onClose }: { onClose: () => void }): ReactNode {
const customers = useCustomersQuery({ limit: 100, status: 'active' });
const apply = useApplyCustomerFieldMutation();
const [customerId, setCustomerId] = useState<string | undefined>();
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [purpose, setPurpose] = useState('');
const [type, setType] = useState<'text' | 'number' | 'date' | 'select'>('text');
const [error, setError] = useState<string | null>(null);
const submit = async (): Promise<void> => {
if (
customerId === undefined ||
name.trim() === '' ||
code.trim() === '' ||
purpose.trim() === ''
) {
setError('请完整填写客户、字段名称、编码与用途');
return;
}
try {
await apply.mutateAsync({
customerId,
input: { name, code, type, purpose, entersGeneration: true, options: [] },
});
notifySuccess('字段申请已提交,等待审批');
onClose();
} catch (cause) {
setError(cause instanceof Error ? cause.message : '提交失败');
}
};
return (
<Modal
visible
title="申请客户字段"
onCancel={onClose}
onOk={() => void submit()}
okText="提交申请"
>
<div className="lx-client-formgrid">
<div className="lx-client-field is-full">
<label>申请客户</label>
<Select
style={{ width: '100%' }}
value={customerId}
placeholder="选择客户"
optionList={(customers.data?.items ?? []).map((customer) => ({
value: customer.id,
label: customer.name,
}))}
onChange={(value) => {
setCustomerId(String(value));
}}
filter
/>
</div>
<div className="lx-client-field">
<label>字段名称</label>
<Input value={name} onChange={setName} placeholder="如:活动编码" />
</div>
<div className="lx-client-field">
<label>字段编码</label>
<Input value={code} onChange={setCode} placeholder="如:campaign_code" />
</div>
<div className="lx-client-field">
<label>字段类型</label>
<Select
style={{ width: '100%' }}
value={type}
optionList={[
{ value: 'text', label: '文本' },
{ value: 'number', label: '数字' },
{ value: 'date', label: '日期' },
{ value: 'select', label: '单选' },
]}
onChange={(value) => {
setType(value as typeof type);
}}
/>
</div>
<div className="lx-client-field is-full">
<label>用途说明</label>
<Input value={purpose} onChange={setPurpose} placeholder="说明字段用途与进入生成的原因" />
</div>
</div>
{error !== null ? (
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
{error}
</div>
) : null}
</Modal>
);
}
function RejectModal({
field,
onClose,
}: {
field: CustomerFieldListItem;
onClose: () => void;
}): ReactNode {
const approve = useApproveCustomerFieldMutation();
const [reason, setReason] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async (): Promise<void> => {
if (reason.trim() === '') {
setError('驳回原因必填');
return;
}
try {
await approve.mutateAsync({ id: field.id, input: { decision: 'reject', reason } });
notifySuccess('已驳回,申请人将收到通知');
onClose();
} catch (cause) {
setError(cause instanceof Error ? cause.message : '驳回失败');
}
};
return (
<Modal
visible
title="驳回字段申请"
onCancel={onClose}
onOk={() => void submit()}
okText="确认驳回"
okButtonProps={{ type: 'danger' }}
>
<div className="lx-client-hint">
{field.customerName} · {field.name}({field.code})
</div>
<div className="lx-client-field">
<label>驳回原因</label>
<Input
value={reason}
onChange={setReason}
placeholder="例如:与基础字段重复,仅需检索无需进入生成"
/>
</div>
{error !== null ? (
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
{error}
</div>
) : null}
</Modal>
);
}
/** 客户字段审批(选定方案 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' });
const approve = useApproveCustomerFieldMutation();
const [applyOpen, setApplyOpen] = useState(false);
const [rejectField, setRejectField] = useState<CustomerFieldListItem | null>(null);
const items = fields.data?.items ?? [];
const allItems = allFields.data?.items ?? [];
const countOf = (value: CustomerFieldListItem['status']): number =>
allItems.filter((item) => item.status === value).length;
if (fields.isPending) return <PageSkeleton rows={8} />;
const approveField = (field: CustomerFieldListItem): void => {
Modal.confirm({
title: '通过字段申请',
content: `通过后「${field.name}(${field.code})」立即生效并进入客户生成链路,操作会写入审计。`,
onOk: async () => {
try {
await approve.mutateAsync({ id: field.id, input: { decision: 'approve' } });
notifySuccess('已通过审批,字段已生效');
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '审批失败');
}
},
});
};
return (
<div className="lx-client-page">
<header className="lx-client-header">
<div>
<h1>客户字段审批</h1>
<p>客户级字段分两层:基础字段仅检索报表、无需审批;进入生成字段必须申请 → 审批 → 生效</p>
</div>
<div className="lx-client-header__actions">
<Can permission={Permission.CUSTOMER_FIELD_APPLY}>
<Button
type="primary"
onClick={() => {
setApplyOpen(true);
}}
>
申请字段
</Button>
</Can>
</div>
</header>
<section className="lx-client-filters">
<div className="lx-client-seg" role="group" aria-label="字段申请状态">
{[
{ value: 'all' as const, label: '全部', count: allItems.length },
{ value: 'pending' as const, label: '待审批', count: countOf('pending') },
{ value: 'active' as const, label: '已生效', count: countOf('active') },
{ value: 'rejected' as const, label: '已驳回', count: countOf('rejected') },
].map((item) => (
<button
key={item.value}
type="button"
className={status === item.value ? 'is-on' : ''}
onClick={() => {
setStatus(item.value);
}}
>
{item.label} <i>{item.count}</i>
</button>
))}
</div>
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--lx-color-text-secondary)' }}>
共 {items.length} 条申请
</span>
</section>
<section className="lx-client-table-card">
{items.length === 0 ? (
<div className="lx-client-empty">
<b>暂无字段申请</b>
<span>客户提交进入生成字段的申请后,会出现在这里等待审批</span>
</div>
) : (
<table className="lx-client-table">
<thead>
<tr>
<th>客户</th>
<th>字段</th>
<th>类型</th>
<th>进入生成</th>
<th>申请人</th>
<th>申请时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{items.map((field) => (
<tr key={field.id}>
<td>
<b>{field.customerName}</b>
<span className="lx-client-sub">{field.industryPath}</span>
</td>
<td>
<b>{field.name}</b>
<span className="lx-client-sub">{field.code}</span>
</td>
<td>{FIELD_TYPE_LABELS[field.type] ?? field.type}</td>
<td>{field.entersGeneration ? '是' : '否'}</td>
<td>{field.applicantName}</td>
<td className="lx-client-muted">
{field.appliedAt.slice(0, 19).replace('T', ' ')}
</td>
<td>{statusTag(field.status)}</td>
<td>
{field.status === 'pending' ? (
<div style={{ display: 'flex', gap: 4 }}>
<Button
size="small"
onClick={() => {
setRejectField(field);
}}
>
驳回
</Button>
<Button
size="small"
type="primary"
onClick={() => {
approveField(field);
}}
>
通过
</Button>
</div>
) : (
<span className="lx-client-muted">已处理</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="lx-client-tablefoot">
审批与变更写入审计 · 未审批字段不进入生成 · 每客户进入生成字段 ≤ {genFieldLimit} 个
</div>
</section>
{applyOpen ? (
<ApplyModal
onClose={() => {
setApplyOpen(false);
}}
/>
) : null}
{rejectField !== null ? (
<RejectModal
field={rejectField}
onClose={() => {
setRejectField(null);
}}
/>
) : null}
</div>
);
}
@@ -0,0 +1,343 @@
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
import type { CustomerGroupDetail } from '@lingxi/shared';
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import {
useCreateCustomerGroupMutation,
useCustomerGroupDetailQuery,
useCustomerGroupsQuery,
useCustomersQuery,
useDeleteCustomerGroupMutation,
useUpdateCustomerGroupMembersMutation,
useUpdateCustomerGroupMutation,
} from '@/hooks/queries/use-client';
function GroupFormModal({
group,
onClose,
}: {
group?: CustomerGroupDetail;
onClose: () => void;
}): ReactNode {
const create = useCreateCustomerGroupMutation();
const update = useUpdateCustomerGroupMutation();
const [name, setName] = useState(group?.name ?? '');
const [description, setDescription] = useState(group?.description ?? '');
const [error, setError] = useState<string | null>(null);
const submit = async (): Promise<void> => {
if (name.trim().length < 2) {
setError('客户组名称至少 2 个字符');
return;
}
try {
if (group !== undefined) {
await update.mutateAsync({ id: group.id, input: { name, description } });
notifySuccess('客户组已保存');
} else {
await create.mutateAsync({ name, description });
notifySuccess('客户组已创建');
}
onClose();
} catch (cause) {
setError(cause instanceof Error ? cause.message : '保存失败');
}
};
return (
<Modal
visible
title={group === undefined ? '新建客户组' : '编辑客户组'}
onCancel={onClose}
onOk={() => void submit()}
okText="保存"
>
<div className="lx-client-formgrid">
<div className="lx-client-field is-full">
<label>客户组名称</label>
<Input value={name} onChange={setName} placeholder="如:电商核心组" />
</div>
<div className="lx-client-field is-full">
<label>说明</label>
<Input
value={description}
onChange={setDescription}
placeholder="说明客户组的用途与授权范围"
/>
</div>
</div>
{error !== null ? (
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
{error}
</div>
) : null}
</Modal>
);
}
function MembersModal({
group,
onClose,
}: {
group: CustomerGroupDetail;
onClose: () => void;
}): ReactNode {
const customers = useCustomersQuery({ limit: 100 });
const update = useUpdateCustomerGroupMembersMutation();
const currentIds = group.members.map((member) => member.id);
const [selected, setSelected] = useState<string[]>(currentIds);
const submit = async (): Promise<void> => {
const addCustomerIds = selected.filter((id) => !currentIds.includes(id));
const removeCustomerIds = currentIds.filter((id) => !selected.includes(id));
try {
await update.mutateAsync({ id: group.id, input: { addCustomerIds, removeCustomerIds } });
notifySuccess(
`已更新客户组成员(新增 ${addCustomerIds.length} / 移除 ${removeCustomerIds.length})`,
);
onClose();
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '保存失败');
}
};
return (
<Modal
visible
title="管理客户组成员"
onCancel={onClose}
onOk={() => void submit()}
okText="保存成员"
>
<div className="lx-client-field">
<label>成员客户</label>
<Select
multiple
style={{ width: '100%' }}
value={selected}
optionList={(customers.data?.items ?? []).map((customer) => ({
value: customer.id,
label: `${customer.name}(${customer.industryPath})`,
}))}
onChange={(value) => {
setSelected(value as string[]);
}}
filter
/>
</div>
<div className="lx-client-hint">
调整成员会立即改变引用该客户组的角色可见客户集合;变更写入审计。
</div>
</Modal>
);
}
/** 客户组(选定方案 B:左列表 + 右成员详情) */
export function CustomerGroupsPage(): ReactNode {
const groups = useCustomerGroupsQuery();
const [selectedId, setSelectedId] = useState<string | null>(null);
const activeId = selectedId ?? groups.data?.items[0]?.id ?? null;
const detail = useCustomerGroupDetailQuery(activeId);
const remove = useDeleteCustomerGroupMutation();
const updateMembers = useUpdateCustomerGroupMembersMutation();
const [modal, setModal] = useState<'create' | 'edit' | 'members' | null>(null);
const items = useMemo(() => groups.data?.items ?? [], [groups.data]);
const memberTotal = items.reduce((sum, group) => sum + group.memberCount, 0);
if (groups.isPending) return <PageSkeleton rows={6} />;
const removeMember = async (groupId: string, customerId: string): Promise<void> => {
try {
await updateMembers.mutateAsync({
id: groupId,
input: { addCustomerIds: [], removeCustomerIds: [customerId] },
});
notifySuccess('已移除 1 个客户');
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '移除失败');
}
};
const deleteGroup = (): void => {
if (detail.data === undefined) return;
Modal.confirm({
title: '删除客户组',
content: '删除前需确认无角色数据范围引用;删除后不可恢复。',
okButtonProps: { type: 'danger' },
onOk: async () => {
try {
await remove.mutateAsync(detail.data.id);
notifySuccess('客户组已删除');
setSelectedId(null);
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '删除失败');
}
},
});
};
return (
<div className="lx-client-page">
<header className="lx-client-header">
<div>
<h1>客户组</h1>
<p>把客户按行业、团队或服务等级分组;客户组是批量授权与数据范围的最小单位</p>
</div>
<div className="lx-client-header__actions">
<Button
type="primary"
onClick={() => {
setModal('create');
}}
>
新建客户组
</Button>
</div>
</header>
<div className="lx-client-split">
<section className="lx-client-left">
<div className="lx-client-left__head">
<b>客户组</b>
<span>
{items.length} 个 · 覆盖 {memberTotal} 个客户
</span>
</div>
<div className="lx-client-left__body">
{items.length === 0 ? (
<div className="lx-client-empty">
<b>暂无客户组</b>
<span>新建客户组,把客户按行业 / 团队分组</span>
</div>
) : (
items.map((group) => (
<button
key={group.id}
type="button"
className={`lx-client-tree__row${group.id === activeId ? ' is-active' : ''}`}
onClick={() => {
setSelectedId(group.id);
}}
style={{ height: 46 }}
>
<span className="lx-client-tree__name">
<b style={{ display: 'block', fontWeight: 500 }}>{group.name}</b>
<span style={{ fontSize: 11, color: 'var(--lx-color-text-secondary)' }}>
{group.description}
</span>
</span>
<span className="lx-client-tree__count">{group.memberCount} 个</span>
</button>
))
)}
</div>
<div className="lx-client-left__foot">共 {items.length} 个客户组 · 授权最小单位</div>
</section>
<section className="lx-client-right">
{detail.data === undefined ? (
<div className="lx-client-empty">
<b>选择客户组</b>
<span>从左侧选择客户组查看成员</span>
</div>
) : (
<>
<div className="lx-client-right__head">
<div>
<h2>{detail.data.name}</h2>
<p>
{detail.data.description || '暂无说明'} · {detail.data.memberCount} 个客户
</p>
</div>
<div className="lx-client-header__actions">
<Button
theme="outline"
onClick={() => {
setModal('members');
}}
>
管理成员
</Button>
<Button
theme="outline"
onClick={() => {
setModal('edit');
}}
>
编辑
</Button>
<Button theme="outline" type="danger" onClick={deleteGroup}>
删除
</Button>
</div>
</div>
<div className="lx-client-right__body">
<div className="lx-client-hint">
调整成员会立即改变引用该客户组的角色数据范围;变更写入审计。
</div>
<div className="lx-client-membertable">
<div className="lx-client-membertable__head">
<span>客户</span>
<span>所属行业</span>
<span>状态</span>
<span>负责人</span>
<span>操作</span>
</div>
{detail.data.members.length === 0 ? (
<div className="lx-client-hint" style={{ padding: '12px 10px' }}>
暂无成员
</div>
) : null}
{detail.data.members.map((member) => (
<div key={member.id} className="lx-client-membertable__row">
<span>
<b>{member.name}</b>
<i>{member.shortName}</i>
</span>
<span>{member.industryPath}</span>
<span>
{member.status === 'active'
? '在服务'
: member.status === 'paused'
? '暂停'
: '结束'}
</span>
<span>{member.ownerName}</span>
<button
type="button"
className="lx-client-linkdanger"
onClick={() => void removeMember(detail.data.id, member.id)}
>
移除
</button>
</div>
))}
</div>
</div>
</>
)}
</section>
</div>
{modal === 'create' ? (
<GroupFormModal
onClose={() => {
setModal(null);
}}
/>
) : null}
{modal === 'edit' && detail.data !== undefined ? (
<GroupFormModal
group={detail.data}
onClose={() => {
setModal(null);
}}
/>
) : null}
{modal === 'members' && detail.data !== undefined ? (
<MembersModal
group={detail.data}
onClose={() => {
setModal(null);
}}
/>
) : null}
</div>
);
}
@@ -0,0 +1,745 @@
/* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
import { type CustomerDetail, type CustomerField, Permission } from '@lingxi/shared';
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
import { IconBack } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Can } from '@/components/Can';
import {
useApplyCustomerFieldMutation,
useCustomerDetailQuery,
useCustomerFieldsOfQuery,
useCustomerGroupsQuery,
useSetCustomerGroupsMutation,
useSetCustomerStatusMutation,
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';
import { CustomerStatusTag } from './CustomerStatusTag';
function Section({
title,
action,
children,
}: {
title: string;
action?: ReactNode;
children: ReactNode;
}): ReactNode {
return (
<section className="lx-client-section">
<div className="lx-client-section__head">
<b>{title}</b>
{action}
</div>
{children}
</section>
);
}
function FieldStatusTag({ field }: { field: CustomerField }): ReactNode {
const cls =
field.status === 'active'
? 'active'
: field.status === 'pending'
? 'paused'
: field.status === 'rejected'
? 'ended'
: 'disabled';
const label =
field.status === 'active'
? '已生效'
: field.status === 'pending'
? '待审批'
: field.status === 'rejected'
? '已驳回'
: '已停用';
return <span className={`lx-client-tag lx-client-tag--${cls}`}>{label}</span>;
}
function TransferModal({
customer,
onClose,
}: {
customer: CustomerDetail;
onClose: () => void;
}): ReactNode {
const users = useUserCandidatesQuery(true);
const transfer = useTransferCustomerMutation();
const [ownerUserId, setOwnerUserId] = useState(customer.ownerUserId);
const submit = async (): Promise<void> => {
try {
await transfer.mutateAsync({ id: customer.id, input: { ownerUserId } });
notifySuccess('客户负责人已转交,已写入审计');
onClose();
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '转交失败');
}
};
return (
<Modal
visible
title="变更客户负责人"
onCancel={onClose}
onOk={() => void submit()}
okText="确认转交"
>
<div className="lx-client-field">
<label>新的客户负责人</label>
<Select
style={{ width: '100%' }}
value={ownerUserId}
optionList={(users.data?.items ?? []).map((user) => ({
value: user.id,
label: `${user.name}(${user.departmentName})`,
}))}
onChange={(value) => {
setOwnerUserId(String(value));
}}
/>
</div>
<div className="lx-client-hint">
转交后该客户会从原负责人的「负责客户」范围移出,并写入审计。
</div>
</Modal>
);
}
function BaseFieldModal({
customer,
limit,
onClose,
}: {
customer: CustomerDetail;
limit: number;
onClose: () => void;
}): ReactNode {
const update = useUpdateCustomerMutation();
const [key, setKey] = useState('');
const [value, setValue] = useState('');
const submit = async (): Promise<void> => {
if (key.trim() === '') return;
try {
await update.mutateAsync({
id: customer.id,
input: { baseFields: [...customer.baseFields, { key: key.trim(), value: value.trim() }] },
});
notifySuccess('基础字段已保存');
onClose();
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '保存失败');
}
};
return (
<Modal visible title="添加基础字段" onCancel={onClose} onOk={() => void submit()} okText="保存">
<div className="lx-client-formgrid">
<div className="lx-client-field">
<label>字段键</label>
<Input value={key} onChange={setKey} placeholder="如:门店编号" />
</div>
<div className="lx-client-field">
<label>字段值</label>
<Input value={value} onChange={setValue} placeholder="如:沪-012" />
</div>
</div>
<div className="lx-client-hint">
基础字段仅用于检索与报表,永不进入生成;每客户最多 {limit} 个。
</div>
</Modal>
);
}
function ApplyFieldModal({
customer,
limit,
onClose,
}: {
customer: CustomerDetail;
limit: number;
onClose: () => void;
}): ReactNode {
const apply = useApplyCustomerFieldMutation();
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [type, setType] = useState<'text' | 'number' | 'date' | 'select'>('text');
const [purpose, setPurpose] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async (): Promise<void> => {
if (name.trim() === '' || code.trim() === '' || purpose.trim() === '') {
setError('请填写字段名称、编码与用途说明');
return;
}
try {
await apply.mutateAsync({
customerId: customer.id,
input: { name, code, type, purpose, entersGeneration: true, options: [] },
});
notifySuccess('字段申请已提交,等待审批');
onClose();
} catch (cause) {
setError(cause instanceof Error ? cause.message : '提交失败');
}
};
return (
<Modal
visible
title="申请客户字段"
onCancel={onClose}
onOk={() => void submit()}
okText="提交申请"
>
<div className="lx-client-formgrid">
<div className="lx-client-field">
<label>字段名称</label>
<Input value={name} onChange={setName} placeholder="如:活动编码" />
</div>
<div className="lx-client-field">
<label>字段编码</label>
<Input value={code} onChange={setCode} placeholder="如:campaign_code" />
</div>
<div className="lx-client-field">
<label>字段类型</label>
<Select
style={{ width: '100%' }}
value={type}
optionList={[
{ value: 'text', label: '文本' },
{ value: 'number', label: '数字' },
{ value: 'date', label: '日期' },
{ value: 'select', label: '单选' },
]}
onChange={(value) => {
setType(value as typeof type);
}}
/>
</div>
<div className="lx-client-field is-full">
<label>用途说明</label>
<Input value={purpose} onChange={setPurpose} placeholder="说明字段用途与进入生成的原因" />
</div>
</div>
{error !== null ? (
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
{error}
</div>
) : null}
<div className="lx-client-hint">进入生成字段需审批后生效;每客户最多 {limit} 个。</div>
</Modal>
);
}
function GroupsModal({
customer,
onClose,
}: {
customer: CustomerDetail;
onClose: () => void;
}): ReactNode {
const groups = useCustomerGroupsQuery();
const update = useSetCustomerGroupsMutation();
const [groupIds, setGroupIds] = useState<string[]>(customer.groups.map((group) => group.id));
const submit = async (): Promise<void> => {
try {
await update.mutateAsync({ id: customer.id, input: { groupIds } });
notifySuccess('客户组归属已更新');
onClose();
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '保存失败');
}
};
return (
<Modal
visible
title="管理客户所属客户组"
onCancel={onClose}
onOk={() => void submit()}
okText="保存"
>
<div className="lx-client-field">
<label>客户组</label>
<Select
multiple
style={{ width: '100%' }}
value={groupIds}
optionList={(groups.data?.items ?? []).map((group) => ({
value: group.id,
label: group.name,
}))}
onChange={(value) => {
setGroupIds(value as string[]);
}}
/>
</div>
<div className="lx-client-hint">
客户组是数据范围授权的最小单位;调整会立即影响引用该客户组的角色可见范围。
</div>
</Modal>
);
}
/** 客户详情(选定方案 A:独立三栏档案页) */
export function CustomerDetailPage(): ReactNode {
const { id } = useParams<{ id: string }>();
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);
if (detail.isPending) return <PageSkeleton rows={8} />;
if (detail.error) {
return (
<div className="lx-client-page">
<div className="lx-client-empty">
<b>客户加载失败</b>
<span>{detail.error.message}</span>
<Button onClick={() => void navigate(ROUTES.system.customers)}>返回客户列表</Button>
</div>
</div>
);
}
const customer = detail.data;
const canWrite = customer.status === 'active';
const changeStatus = (target: 'paused' | 'ended' | 'active'): void => {
Modal.confirm({
title:
target === 'paused' ? '暂停客户服务' : target === 'ended' ? '结束客户服务' : '恢复客户服务',
content:
target === 'paused'
? '暂停后档案只读,不能发起新的生成任务;可随时恢复。'
: target === 'ended'
? '结束后全部数据只读归档,且不能恢复;如继续合作需新建客户。'
: '恢复后档案可编辑,并可以重新发起生成任务。',
okButtonProps: target === 'ended' ? { type: 'danger' } : undefined,
onOk: async () => {
try {
await setStatus.mutateAsync({ id: customer.id, input: { status: target } });
notifySuccess(
target === 'paused'
? '客户已暂停'
: target === 'ended'
? '客户已结束并归档'
: '客户已恢复为在服务',
);
void detail.refetch();
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '操作失败');
}
},
});
};
const removeBaseField = async (index: number): Promise<void> => {
try {
await update.mutateAsync({
id: customer.id,
input: {
baseFields: customer.baseFields.filter((_item, itemIndex) => itemIndex !== index),
},
});
notifySuccess('基础字段已删除');
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '删除失败');
}
};
const fieldRows = fields.data ?? customer.fields;
return (
<div className="lx-client-page">
<header className="lx-client-header">
<div>
<a
className="lx-client-back"
href={ROUTES.system.customers}
onClick={(event) => {
event.preventDefault();
void navigate(ROUTES.system.customers);
}}
>
<IconBack size={14} /> 返回客户列表
</a>
<h1>
{customer.name} <CustomerStatusTag status={customer.status} />
</h1>
<p>
{customer.shortName} · {customer.industryPath} · 负责人 {customer.ownerName}
</p>
</div>
<div className="lx-client-header__actions">
<Can permission={Permission.CUSTOMER_TRANSFER}>
<Button
theme="outline"
onClick={() => {
setModal('transfer');
}}
disabled={customer.status === 'ended'}
>
变更负责人
</Button>
</Can>
<Can permission={Permission.CUSTOMER_MANAGE}>
{customer.status === 'active' ? (
<Button
theme="outline"
onClick={() => {
changeStatus('paused');
}}
>
暂停服务
</Button>
) : null}
{customer.status === 'paused' ? (
<Button
theme="solid"
type="primary"
onClick={() => {
changeStatus('active');
}}
>
恢复服务
</Button>
) : null}
{customer.status !== 'ended' ? (
<Button
theme="outline"
type="danger"
onClick={() => {
changeStatus('ended');
}}
>
结束服务
</Button>
) : null}
</Can>
</div>
</header>
{customer.status !== 'active' ? (
<div
className={`lx-client-banner lx-client-banner--${customer.status === 'paused' ? 'paused' : 'ended'}`}
>
{customer.status === 'paused'
? '客户已暂停:档案只读,不能发起新的生成任务。恢复为「在服务」后可继续。'
: '客户已结束服务:全部数据只读归档,不能编辑或发起任务。'}
</div>
) : null}
<div className="lx-client-grid">
<aside className="lx-client-anchors">
<a href="#basic">基本信息</a>
<a href="#prefs">交付偏好</a>
<a href="#base-fields">基础字段</a>
<a href="#gen-fields">进入生成</a>
<a href="#groups">客户组</a>
<a href="#asset">资产包</a>
</aside>
<div className="lx-client-panel">
<Section title="基本信息">
<div className="lx-client-kv" id="basic">
<div className="lx-client-kv__item">
<span>客户名称</span>
<b>{customer.name}</b>
</div>
<div className="lx-client-kv__item">
<span>客户简称</span>
<b>{customer.shortName}</b>
</div>
<div className="lx-client-kv__item">
<span>所属行业</span>
<b>{customer.industryPath}</b>
</div>
<div className="lx-client-kv__item">
<span>服务状态</span>
<b>
<CustomerStatusTag status={customer.status} />
</b>
</div>
<div className="lx-client-kv__item">
<span>客户负责人</span>
<b>{customer.ownerName}</b>
</div>
<div className="lx-client-kv__item">
<span>客户侧对接人</span>
<b>
{customer.contactName ?? '—'} {customer.contactPhone ?? ''}
</b>
</div>
<div className="lx-client-kv__item">
<span>创建时间</span>
<b>{customer.createdAt.slice(0, 10)}</b>
</div>
<div className="lx-client-kv__item">
<span>最近更新</span>
<b>{customer.updatedAt.slice(0, 19).replace('T', ' ')}</b>
</div>
</div>
{customer.remark !== null ? (
<div className="lx-client-hint">{customer.remark}</div>
) : null}
</Section>
<Section title="交付偏好">
<div className="lx-client-kv" id="prefs">
<div className="lx-client-kv__item">
<span>命名习惯</span>
<b>{customer.preferences.naming || '—'}</b>
</div>
<div className="lx-client-kv__item">
<span>目录结构</span>
<b>{customer.preferences.directory || '—'}</b>
</div>
</div>
<div className="lx-client-hint">
常用规格:{customer.preferences.specs.join('、') || '—'}
</div>
<div className="lx-client-hint">
素材禁忌:{customer.preferences.taboo.join('、') || '—'}
</div>
</Section>
<Section
title="基础字段"
action={
<Can permission={Permission.CUSTOMER_MANAGE}>
<Button
size="small"
disabled={!canWrite || customer.baseFields.length >= baseFieldLimit}
onClick={() => {
setModal('base');
}}
>
添加基础字段
</Button>
</Can>
}
>
<div id="base-fields">
{customer.baseFields.length === 0 ? (
<div className="lx-client-hint">暂无基础字段</div>
) : null}
{customer.baseFields.map((field, index) => (
<div key={`${field.key}-${index}`} className="lx-client-memberrow">
<b>{field.key}</b>
<span>{field.value}</span>
<Can permission={Permission.CUSTOMER_MANAGE}>
<Button
size="small"
type="danger"
theme="borderless"
disabled={!canWrite}
onClick={() => void removeBaseField(index)}
>
删除
</Button>
</Can>
</div>
))}
</div>
<div className="lx-client-hint">
自由键值对,仅用于检索与报表,永不进入生成;每客户 ≤ {baseFieldLimit} 个(
{customer.baseFields.length}/{baseFieldLimit})。
</div>
</Section>
<Section
title="进入生成字段"
action={
<Can permission={Permission.CUSTOMER_FIELD_APPLY}>
<Button
size="small"
theme="solid"
type="primary"
disabled={!canWrite || customer.genFieldCount >= genFieldLimit}
onClick={() => {
setModal('field');
}}
>
申请字段
</Button>
</Can>
}
>
<div id="gen-fields">
{fieldRows.length === 0 ? (
<div className="lx-client-hint">暂无进入生成字段</div>
) : null}
{fieldRows.map((field) => (
<div key={field.id} className="lx-client-memberrow">
<b>{field.name}</b>
<span>
{field.code} · {field.type} · {field.purpose}
</span>
<FieldStatusTag field={field} />
</div>
))}
</div>
<div className="lx-client-hint">
需申请 → 审批 → 生效;每客户 ≤ {genFieldLimit} 个({customer.genFieldCount}/
{genFieldLimit})。
</div>
</Section>
<Section
title="客户组"
action={
<Can permission={Permission.CUSTOMER_GROUP_MANAGE}>
<Button
size="small"
onClick={() => {
setModal('groups');
}}
>
管理客户组
</Button>
</Can>
}
>
<div className="lx-client-chips" id="groups">
{customer.groups.length === 0 ? (
<span className="lx-client-hint">未加入客户组</span>
) : null}
{customer.groups.map((group) => (
<span key={group.id} className="lx-client-chip">
{group.name}
</span>
))}
</div>
</Section>
<Section title="行业资产包">
<div className="lx-client-assetempty" id="asset">
<b>该行业尚未发布可用行业包</b>
行业包发布后可在此一键套用模板、场景、人物、提示词与卖点,并替换为客户 Logo 与警示语。
</div>
</Section>
</div>
<aside className="lx-client-side">
<section className="lx-client-sidecard">
<h3>服务状态</h3>
<div className="lx-client-kv__item">
<span>进入生成字段</span>
<b>
{customer.genFieldCount} / {genFieldLimit}
</b>
</div>
<div className="lx-client-kv__item">
<span>基础字段</span>
<b>
{customer.baseFieldCount} / {baseFieldLimit}
</b>
</div>
<div className="lx-client-kv__item">
<span>客户组</span>
<b>{customer.groups.length} 个</b>
</div>
<div className="lx-client-kv__item">
<span>资产包</span>
<b>待行业包发布</b>
</div>
</section>
<section className="lx-client-sidecard">
<h3>快捷操作</h3>
<div className="lx-client-sideacts">
<Can permission={Permission.CUSTOMER_TRANSFER}>
<Button
theme="outline"
onClick={() => {
setModal('transfer');
}}
disabled={customer.status === 'ended'}
>
变更负责人
</Button>
</Can>
<Can permission={Permission.CUSTOMER_MANAGE}>
{customer.status === 'active' ? (
<Button
theme="outline"
onClick={() => {
changeStatus('paused');
}}
>
暂停服务
</Button>
) : null}
{customer.status === 'paused' ? (
<Button
theme="solid"
type="primary"
onClick={() => {
changeStatus('active');
}}
>
恢复服务
</Button>
) : null}
{customer.status !== 'ended' ? (
<Button
theme="outline"
type="danger"
onClick={() => {
changeStatus('ended');
}}
>
结束服务
</Button>
) : null}
</Can>
</div>
<div className="lx-client-hint">当前客户只读时,快捷操作会按状态自动隐藏。</div>
</section>
<section className="lx-client-sidecard">
<h3>审计提示</h3>
<div className="lx-client-hint">
客户状态、负责人、字段与客户组的变更都会写入审计,记录前后值与操作人。
</div>
</section>
</aside>
</div>
{modal === 'transfer' ? (
<TransferModal
customer={customer}
onClose={() => {
setModal(null);
}}
/>
) : null}
{modal === 'base' ? (
<BaseFieldModal
customer={customer}
limit={baseFieldLimit}
onClose={() => {
setModal(null);
}}
/>
) : null}
{modal === 'field' ? (
<ApplyFieldModal
customer={customer}
limit={genFieldLimit}
onClose={() => {
setModal(null);
}}
/>
) : null}
{modal === 'groups' ? (
<GroupsModal
customer={customer}
onClose={() => {
setModal(null);
}}
/>
) : null}
</div>
);
}
@@ -0,0 +1,353 @@
/* eslint-disable max-lines-per-function, complexity -- 四步向导集中编排步骤、行业选择与提交校验 */
import { Button, Input, Select } from '@douyinfe/semi-ui-19';
import type { CreateCustomerInput, IndustryTreeItem } from '@lingxi/shared';
import { notifySuccess, PageSkeleton } from '@lingxi/ui';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { useCreateCustomerMutation, useIndustriesQuery } from '@/hooks/queries/use-client';
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
import { customerDetailPath, ROUTES } from '@/routes';
const STEPS = [
{ key: 'industry', label: '选择行业', desc: '行业决定行业包与可用模板' },
{ key: 'basic', label: '基础信息', desc: '名称、简称、负责人与对接人' },
{ key: 'prefs', label: '交付偏好', desc: '命名、目录、规格与禁忌' },
{ key: 'pack', label: '行业资产包', desc: '冷启动套用行业资产' },
];
function IndustryPicker({
items,
value,
onChange,
}: {
items: readonly IndustryTreeItem[];
value: string | undefined;
onChange: (id: string) => void;
}): ReactNode {
const groups = items
.filter((root) => root.status === 'active')
.map((root) => ({
root,
options:
root.children.length === 0
? [root]
: root.children.filter((child) => child.status === 'active'),
}))
.filter((group) => group.options.length > 0);
return (
<div className="lx-indpick">
{groups.map((group) => (
<div key={group.root.id} className="lx-indpick-group">
<div className="lx-indpick-root">
<span className="lx-indpick-dot" />
<b>{group.root.name}</b>
<span>
{group.root.children.length === 0
? '无二级行业,可直接选择'
: `${group.root.children.length} 个二级行业`}
</span>
</div>
<div className="lx-indpick-opts">
{group.options.map((option) => {
const checked = option.id === value;
return (
<button
key={option.id}
type="button"
className={`lx-radio${checked ? ' is-on' : ''}`}
onClick={() => {
onChange(option.id);
}}
>
<span className="lx-radio-dot" />
<span>{option.name}</span>
<i>{option.customerCount} 个客户</i>
</button>
);
})}
</div>
</div>
))}
{groups.length === 0 ? <div className="lx-client-empty">暂无可选行业</div> : null}
</div>
);
}
/** 新建客户(选定方案 B:左侧带说明的步骤导航 + 右侧表单) */
export function CustomerNewPage(): ReactNode {
const navigate = useNavigate();
const industries = useIndustriesQuery({ limit: 100 });
const users = useUserCandidatesQuery(true);
const create = useCreateCustomerMutation();
const [current, setCurrent] = useState(0);
const [industryId, setIndustryId] = useState<string | undefined>();
const [name, setName] = useState('');
const [shortName, setShortName] = useState('');
const [ownerUserId, setOwnerUserId] = useState<string | undefined>();
const [contactName, setContactName] = useState('');
const [contactPhone, setContactPhone] = useState('');
const [remark, setRemark] = useState('');
const [naming, setNaming] = useState('客户名_方向_日期_序号');
const [directory, setDirectory] = useState('客户名/年份/方向');
const [specs, setSpecs] = useState('1080×1920,1080×1080');
const [taboo, setTaboo] = useState('绝对化用语,未授权肖像');
const [error, setError] = useState<string | null>(null);
const industryLabel = useMemo(() => {
for (const root of industries.data?.items ?? []) {
if (root.id === industryId) return root.name;
for (const child of root.children) {
if (child.id === industryId) return `${root.name} / ${child.name}`;
}
}
return '未选择';
}, [industries.data, industryId]);
const next = (): void => {
setError(null);
if (current === 0 && industryId === undefined) {
setError('请选择客户归属行业');
return;
}
if (current === 1) {
if (name.trim() === '') {
setError('请输入客户名称');
return;
}
if (shortName.trim().length < 2) {
setError('客户简称必填,用于自动命名(42201)');
return;
}
if (ownerUserId === undefined) {
setError('请选择客户负责人');
return;
}
}
setCurrent((step) => Math.min(step + 1, 3));
};
const submit = async (): Promise<void> => {
if (industryId === undefined || ownerUserId === undefined) return;
const input: CreateCustomerInput = {
industryId,
name,
shortName,
ownerUserId,
contactName: contactName === '' ? undefined : contactName,
contactPhone: contactPhone === '' ? undefined : contactPhone,
remark: remark === '' ? undefined : remark,
preferences: {
naming,
directory,
specs: specs
.split(',')
.map((item) => item.trim())
.filter((item) => item !== ''),
taboo: taboo
.split(',')
.map((item) => item.trim())
.filter((item) => item !== ''),
},
};
try {
const created = await create.mutateAsync(input);
notifySuccess('客户已创建');
void navigate(customerDetailPath(created.id));
} catch (cause) {
setError(cause instanceof Error ? cause.message : '创建失败');
}
};
if (industries.isPending) return <PageSkeleton rows={6} />;
return (
<div className="lx-client-page">
<header className="lx-client-header">
<div>
<h1>新建客户</h1>
<p>分四步创建客户:选择行业 → 基础信息 → 交付偏好 → 行业资产包</p>
</div>
</header>
<div className="lx-client-newgrid">
<aside className="lx-client-stepnav">
<div className="lx-client-stepnav__head">
<b>创建客户</b>
<span>共 4 步</span>
</div>
{STEPS.map((step, index) => {
const state = index === current ? ' is-on' : index < current ? ' is-done' : '';
return (
<button
key={step.key}
type="button"
className={`lx-stepnav-item${state}`}
onClick={() => {
if (index < current) setCurrent(index);
}}
>
<span className="lx-stepnav-num">{index < current ? '✓' : index + 1}</span>
<span className="lx-stepnav-text">
<b>{step.label}</b>
<span>{step.desc}</span>
</span>
</button>
);
})}
</aside>
<section className="lx-client-formcard">
<div className="lx-client-formcard__head">
<b>{STEPS[current]?.label}</b>
<span>
{current === 0
? `${industryLabel} · 只能选择末级行业`
: current === 1
? '简称必填,用于全部自动命名'
: current === 2
? '命名、目录、规格与素材禁忌'
: '行业包发布后可一键套用'}
</span>
</div>
<div className="lx-client-formcard__body">
{current === 0 ? (
<>
<div className="lx-client-section__head">
<b>客户归属行业</b>
<span className="lx-client-hint" style={{ marginTop: 0 }}>
只能选择末级行业;一级行业没有二级时可直接选择
</span>
</div>
<IndustryPicker
items={industries.data?.items ?? []}
value={industryId}
onChange={setIndustryId}
/>
<div className="lx-client-hint">
所属行业决定行业包;客户创建后不可跨行业移动,调整行业需停用后新建。
</div>
</>
) : null}
{current === 1 ? (
<div className="lx-client-formgrid">
<div className="lx-client-field">
<label>
客户名称 <i>*</i>
</label>
<Input value={name} onChange={setName} placeholder="如:花间集" />
</div>
<div className="lx-client-field">
<label>
客户简称 <i>*</i>
</label>
<Input value={shortName} onChange={setShortName} placeholder="如:HJ" />
</div>
<div className="lx-client-field">
<label>
客户负责人 <i>*</i>
</label>
<Select
style={{ width: '100%' }}
value={ownerUserId}
optionList={(users.data?.items ?? []).map((user) => ({
value: user.id,
label: `${user.name}(${user.departmentName})`,
}))}
onChange={(value) => {
setOwnerUserId(String(value));
}}
filter
/>
</div>
<div className="lx-client-field">
<label>客户侧对接人</label>
<Input value={contactName} onChange={setContactName} placeholder="如:林晓" />
</div>
<div className="lx-client-field">
<label>对接人电话</label>
<Input
value={contactPhone}
onChange={setContactPhone}
placeholder="如:138****2210"
/>
</div>
<div className="lx-client-field is-full">
<label>备注</label>
<Input
value={remark}
onChange={setRemark}
placeholder="客户偏好、合作背景等(可选)"
/>
</div>
</div>
) : null}
{current === 2 ? (
<div className="lx-client-formgrid">
<div className="lx-client-field">
<label>命名习惯</label>
<Input value={naming} onChange={setNaming} />
</div>
<div className="lx-client-field">
<label>目录结构</label>
<Input value={directory} onChange={setDirectory} />
</div>
<div className="lx-client-field is-full">
<label>常用规格(逗号分隔)</label>
<Input value={specs} onChange={setSpecs} />
</div>
<div className="lx-client-field is-full">
<label>素材禁忌(逗号分隔)</label>
<Input value={taboo} onChange={setTaboo} />
</div>
</div>
) : null}
{current === 3 ? (
<div className="lx-client-assetempty">
<b>该行业尚未发布可用行业包</b>
行业包发布后可在此一键套用模板、场景、人物、提示词与卖点,并自动替换为客户 Logo
与警示语。当前可以先完成客户创建,行业包发布后回客户详情套用。
</div>
) : null}
{error !== null ? (
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
{error}
</div>
) : null}
</div>
<div className="lx-client-formcard__foot">
<span className="lx-client-hint">步骤 {current + 1} / 4 · 可回退修改</span>
<span className="spacer" />
{current === 0 ? (
<Button theme="outline" onClick={() => void navigate(ROUTES.system.customers)}>
取消
</Button>
) : (
<Button
onClick={() => {
setCurrent((step) => step - 1);
}}
>
上一步
</Button>
)}
{current < 3 ? (
<Button theme="solid" type="primary" onClick={next}>
下一步
</Button>
) : (
<Button
theme="solid"
type="primary"
loading={create.isPending}
onClick={() => void submit()}
>
创建客户
</Button>
)}
</div>
</section>
</div>
</div>
);
}
@@ -0,0 +1,12 @@
import type { CustomerStatus } from '@lingxi/shared';
import type { ReactNode } from 'react';
const LABELS: Record<CustomerStatus, string> = {
active: '在服务',
paused: '暂停',
ended: '结束',
};
export function CustomerStatusTag({ status }: { status: CustomerStatus }): ReactNode {
return <span className={`lx-client-tag lx-client-tag--${status}`}>{LABELS[status]}</span>;
}
@@ -0,0 +1,277 @@
/* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
import { Button, Input, Select } from '@douyinfe/semi-ui-19';
import { type CustomerSummary, Permission } from '@lingxi/shared';
import { PageSkeleton } from '@lingxi/ui';
import { IconAdd, IconSearch } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { Can } from '@/components/Can';
import {
useCustomerGroupsQuery,
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';
import { CustomerStatusTag } from './CustomerStatusTag';
const STATUS_OPTIONS = [
{ value: 'all', label: '全部状态' },
{ value: 'active', label: '在服务' },
{ value: 'paused', label: '暂停' },
{ value: 'ended', label: '结束' },
];
/** 客户管理(选定方案 A:全宽表格 + 独立详情页) */
export function CustomersPage(): ReactNode {
const navigate = useNavigate();
const [keyword, setKeyword] = useState('');
const [industryId, setIndustryId] = useState<string | undefined>();
const [status, setStatus] = useState<'all' | 'active' | 'paused' | 'ended'>('all');
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);
const customers = useCustomersQuery({
limit: 100,
...(keyword.trim() === '' ? {} : { keyword: keyword.trim() }),
...(industryId === undefined ? {} : { industry_id: industryId }),
...(status === 'all' ? {} : { status }),
...(ownerUserId === undefined ? {} : { owner_user_id: ownerUserId }),
...(groupId === undefined ? {} : { group_id: groupId }),
});
const industryOptions = useMemo(() => {
const options: { value: string; label: string }[] = [];
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 rows = customers.data?.items ?? [];
const counts = {
active: rows.filter((item) => item.status === 'active').length,
paused: rows.filter((item) => item.status === 'paused').length,
ended: rows.filter((item) => item.status === 'ended').length,
};
const openDetail = (customer: CustomerSummary): void => {
void navigate(customerDetailPath(customer.id));
};
if (customers.isPending) return <PageSkeleton rows={8} />;
return (
<div className="lx-client-page">
<header className="lx-client-header">
<div>
<h1>客户管理</h1>
<p>客户是资产与生产任务的隔离单元;暂停后只读,结束服务后全部数据只读归档</p>
</div>
<div className="lx-client-header__actions">
<Can permission={Permission.CUSTOMER_MANAGE}>
<Button
theme="solid"
type="primary"
icon={<IconAdd size={16} />}
onClick={() => void navigate(customerNewPath())}
>
新建客户
</Button>
</Can>
</div>
</header>
<section className="lx-client-filters" aria-label="筛选客户">
<Input
style={{ width: 220 }}
value={keyword}
prefix={<IconSearch size={16} />}
placeholder="搜索客户名称或简称"
showClear
onChange={setKeyword}
aria-label="搜索客户"
/>
<Select
style={{ width: 180 }}
value={industryId}
placeholder="全部行业"
optionList={[{ value: '', label: '全部行业' }, ...industryOptions]}
onChange={(value) => {
setIndustryId(typeof value === 'string' && value !== '' ? value : undefined);
}}
showClear
/>
<Select
style={{ width: 140 }}
value={status}
optionList={STATUS_OPTIONS}
onChange={(value) => {
setStatus(value as typeof status);
}}
/>
<Select
style={{ width: 160 }}
value={ownerUserId}
placeholder="全部负责人"
optionList={[
{ value: '', label: '全部负责人' },
...(users.data?.items ?? []).map((user) => ({ value: user.id, label: user.name })),
]}
onChange={(value) => {
setOwnerUserId(typeof value === 'string' && value !== '' ? value : undefined);
}}
showClear
/>
<Select
style={{ width: 160 }}
value={groupId}
placeholder="全部客户组"
optionList={[
{ value: '', label: '全部客户组' },
...(groups.data?.items ?? []).map((group) => ({ value: group.id, label: group.name })),
]}
onChange={(value) => {
setGroupId(typeof value === 'string' && value !== '' ? value : undefined);
}}
showClear
/>
<Button
theme="outline"
onClick={() => {
setKeyword('');
setIndustryId(undefined);
setStatus('all');
setOwnerUserId(undefined);
setGroupId(undefined);
}}
>
重置
</Button>
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--lx-color-text-secondary)' }}>
共 {rows.length} 个客户
</span>
</section>
<section className="lx-client-table-card">
{customers.error ? (
<div className="lx-client-empty">
<b>客户加载失败</b>
<span>{customers.error.message}</span>
<Button onClick={() => void customers.refetch()}>重试</Button>
</div>
) : rows.length === 0 ? (
<div className="lx-client-empty">
<b>暂无客户</b>
<span>按当前筛选条件没有客户,试试重置筛选</span>
</div>
) : (
<>
<div className="lx-client-tablehead">
<b>全部客户</b>
<span>共 {rows.length} 个</span>
<div className="lx-client-tablehead__stats">
<span>
在服务 <b>{counts.active}</b>
</span>
<span>
暂停 <b>{counts.paused}</b>
</span>
<span>
结束 <b>{counts.ended}</b>
</span>
</div>
</div>
<table className="lx-client-table">
<thead>
<tr>
<th>客户</th>
<th>所属行业</th>
<th>状态</th>
<th>负责人</th>
<th>客户组</th>
<th>进入生成</th>
<th>基础字段</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{rows.map((customer) => (
<tr
key={customer.id}
onClick={() => {
openDetail(customer);
}}
>
<td>
<b>{customer.name}</b>
<span className="lx-client-sub">{customer.shortName}</span>
</td>
<td>{customer.industryPath}</td>
<td>
<CustomerStatusTag status={customer.status} />
</td>
<td>{customer.ownerName}</td>
<td>{customer.groups.map((group) => group.name).join('、') || '—'}</td>
<td>
<span
className={`lx-client-count${customer.genFieldCount >= genFieldLimit ? ' is-full' : ''}`}
>
{customer.genFieldCount}/{genFieldLimit}
</span>
</td>
<td>
<span className="lx-client-count">
{customer.baseFieldCount}/{baseFieldLimit}
</span>
</td>
<td className="lx-client-muted">
{customer.updatedAt.slice(0, 19).replace('T', ' ')}
</td>
<td>
<Button
theme="borderless"
type="primary"
size="small"
onClick={(event) => {
event.stopPropagation();
openDetail(customer);
}}
>
查看
</Button>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
{customers.error ? null : (
<div className="lx-client-tablefoot">
<span>客户名称在同一末级行业下唯一;客户不可跨行业移动</span>
<Button
size="small"
theme="borderless"
onClick={() => void navigate(ROUTES.system.customerGroups)}
>
管理客户组
</Button>
</div>
)}
</section>
</div>
);
}
@@ -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,577 @@
/* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
import { Button, Input, Modal } from '@douyinfe/semi-ui-19';
import { type IndustryDetail, type IndustryTreeItem, Permission } from '@lingxi/shared';
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
import { IconStack } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { Can } from '@/components/Can';
import {
useCreateIndustryMutation,
useDeleteIndustryMutation,
useIndustriesQuery,
useIndustryDetailQuery,
useSetIndustryStatusMutation,
useUpdateIndustryMutation,
} from '@/hooks/queries/use-client';
import { usePermission } from '@/hooks/use-permission';
type ModalState =
| { kind: 'root' }
| { kind: 'child'; parentId: string }
| { kind: 'edit'; industry: IndustryDetail }
| null;
function statusTag(status: string): ReactNode {
return (
<span className={`lx-client-tag lx-client-tag--${status === 'active' ? 'active' : 'disabled'}`}>
{status === 'active' ? '启用' : '停用'}
</span>
);
}
function IndustryFormModal({
state,
parentName,
onClose,
}: {
state: Exclude<ModalState, null>;
parentName?: string;
onClose: () => void;
}): ReactNode {
const create = useCreateIndustryMutation();
const update = useUpdateIndustryMutation();
const isEdit = state.kind === 'edit';
const [name, setName] = useState(isEdit ? state.industry.name : '');
const [sortOrder, setSortOrder] = useState(isEdit ? String(state.industry.sortOrder) : '10');
const [remark, setRemark] = useState(isEdit ? state.industry.remark : '');
const [error, setError] = useState<string | null>(null);
const submit = async (): Promise<void> => {
if (name.trim().length < 2) {
setError('行业名称至少 2 个字符');
return;
}
try {
if (isEdit) {
await update.mutateAsync({
id: state.industry.id,
input: { name, sortOrder: Number(sortOrder), remark },
});
notifySuccess('行业已保存');
} else {
await create.mutateAsync({
name,
sortOrder: Number(sortOrder),
remark,
...(state.kind === 'child' ? { parentId: state.parentId } : {}),
});
notifySuccess('行业已创建');
}
onClose();
} catch (cause) {
setError(cause instanceof Error ? cause.message : '保存失败');
}
};
return (
<Modal
visible
title={isEdit ? '编辑行业' : state.kind === 'child' ? '添加二级行业' : '新建一级行业'}
onCancel={onClose}
onOk={() => void submit()}
okText="保存"
cancelText="取消"
confirmLoading={create.isPending || update.isPending}
>
<div className="lx-client-formgrid">
<div className="lx-client-field is-full">
<label>
行业名称 <i>*</i>
</label>
<Input value={name} onChange={setName} placeholder="如:电商、服饰鞋包" />
{error !== null ? (
<span style={{ color: 'var(--lx-color-danger)', fontSize: 12 }}>{error}</span>
) : null}
</div>
{state.kind === 'child' ? (
<div className="lx-client-field is-full">
<label>上级行业</label>
<Input value={parentName ?? ''} disabled />
</div>
) : null}
<div className="lx-client-field">
<label>排序</label>
<Input value={sortOrder} onChange={setSortOrder} />
</div>
<div className="lx-client-field is-full">
<label>备注</label>
<Input value={remark} onChange={setRemark} placeholder="行业说明(可选)" />
</div>
</div>
</Modal>
);
}
function IndustryTree({
items,
activeId,
onSelect,
}: {
items: readonly IndustryTreeItem[];
activeId: string | null;
onSelect: (id: string) => void;
}): ReactNode {
return (
<div className="lx-client-tree">
{items.map((root) => (
<div key={root.id}>
<button
type="button"
className={`lx-client-tree__row${root.id === activeId ? ' is-active' : ''}`}
onClick={() => {
onSelect(root.id);
}}
>
<span className="lx-tree-caret">▾</span>
<span className="lx-client-tree__name">{root.name}</span>
<span className="lx-client-tree__count">{root.children.length}</span>
</button>
{root.children.map((child) => (
<button
key={child.id}
type="button"
className={`lx-client-tree__row is-child${child.id === activeId ? ' is-active' : ''}`}
onClick={() => {
onSelect(child.id);
}}
>
<span className="lx-tree-dot" />
<span className="lx-client-tree__name">{child.name}</span>
<span className="lx-client-tree__count">{child.customerCount}</span>
</button>
))}
</div>
))}
</div>
);
}
function IndustryDetailPanel({
detail,
items,
canManage,
onAddChild,
onEdit,
onRefresh,
onSelect,
}: {
detail: IndustryDetail;
items: readonly IndustryTreeItem[];
canManage: boolean;
onAddChild: () => void;
onEdit: () => void;
onRefresh: () => void;
onSelect: (id: string) => void;
}): ReactNode {
const setStatus = useSetIndustryStatusMutation();
const remove = useDeleteIndustryMutation();
const [tab, setTab] = useState<'basic' | 'children' | 'refs'>('basic');
const children =
detail.level === 1 ? (items.find((item) => item.id === detail.id)?.children ?? []) : [];
const toggle = (): void => {
Modal.confirm({
title: detail.status === 'active' ? '停用行业' : '启用行业',
content:
detail.status === 'active'
? '停用后新建客户不可选择该行业;已有客户数据不受影响。'
: '启用后新建客户可以选择该行业。',
onOk: async () => {
try {
await setStatus.mutateAsync({
id: detail.id,
input: { status: detail.status === 'active' ? 'disabled' : 'active' },
});
notifySuccess(detail.status === 'active' ? '行业已停用' : '行业已启用');
onRefresh();
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '操作失败');
}
},
});
};
const removeIndustry = (): void => {
Modal.confirm({
title: '删除行业',
content: '仅无子行业、无客户、无资产时可删除;否则只能停用。',
okButtonProps: { type: 'danger' },
onOk: async () => {
try {
await remove.mutateAsync(detail.id);
notifySuccess('行业已删除');
onRefresh();
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '删除失败');
}
},
});
};
return (
<>
<div className="lx-industry-head">
<span className="lx-industry-avatar">
<IconStack size={20} />
</span>
<div className="lx-industry-id">
<div className="lx-industry-name">
<b>{detail.name}</b>
{statusTag(detail.status)}
<span className="lx-client-tag lx-client-tag--disabled">
{detail.level === 1 ? '一级行业' : '二级行业'}
</span>
</div>
<div className="lx-industry-meta">
{detail.parentName !== null ? `${detail.parentName} / ` : ''}
{detail.name} · 排序 {detail.sortOrder} · 更新于{' '}
{detail.updatedAt.slice(0, 19).replace('T', ' ')}
</div>
</div>
{canManage ? (
<div className="lx-industry-actions">
<Button theme="outline" onClick={onEdit}>
编辑
</Button>
{detail.level === 1 ? (
<Button theme="outline" onClick={onAddChild}>
添加二级
</Button>
) : null}
<Button theme="outline" onClick={toggle}>
{detail.status === 'active' ? '停用' : '启用'}
</Button>
<Button theme="outline" type="danger" onClick={removeIndustry}>
删除
</Button>
</div>
) : null}
</div>
<div className="lx-industry-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={tab === 'basic'}
className={tab === 'basic' ? 'is-on' : ''}
onClick={() => {
setTab('basic');
}}
>
基本信息
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'children'}
className={tab === 'children' ? 'is-on' : ''}
onClick={() => {
setTab('children');
}}
>
子行业 <i>{children.length}</i>
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'refs'}
className={tab === 'refs' ? 'is-on' : ''}
onClick={() => {
setTab('refs');
}}
>
引用 <i>{detail.references.customerCount + detail.assetCount}</i>
</button>
</div>
<div className="lx-industry-body">
{tab === 'basic' ? (
<>
<div className="lx-client-kv">
<div className="lx-client-kv__item">
<span>行业名称</span>
<b>{detail.name}</b>
</div>
<div className="lx-client-kv__item">
<span>层级</span>
<b>{detail.level === 1 ? '一级行业' : '二级行业'}</b>
</div>
<div className="lx-client-kv__item">
<span>上级行业</span>
<b>{detail.parentName ?? '—'}</b>
</div>
<div className="lx-client-kv__item">
<span>状态</span>
<b>{statusTag(detail.status)}</b>
</div>
<div className="lx-client-kv__item">
<span>排序</span>
<b>{detail.sortOrder}</b>
</div>
<div className="lx-client-kv__item">
<span>客户数</span>
<b>{detail.references.customerCount} 个</b>
</div>
<div className="lx-client-kv__item">
<span>资产数</span>
<b>{detail.assetCount} 个</b>
</div>
<div className="lx-client-kv__item">
<span>更新时间</span>
<b>{detail.updatedAt.slice(0, 19).replace('T', ' ')}</b>
</div>
</div>
<div className="lx-industry-note">
<span>ⓘ</span>
{detail.remark !== '' ? detail.remark : '暂无备注'}
</div>
{detail.status === 'disabled' ? (
<div className="lx-client-banner lx-client-banner--ended">
该行业已停用:新建客户时不可选择;已有客户数据不受影响。
</div>
) : null}
<div className="lx-industry-sec">
<div className="lx-industry-sec-head">
<b>最近变更</b>
<span>写入审计</span>
</div>
<div className="lx-timeline">
<div className="lx-timeline-item is-first">
<i />
<div>
<b>更新行业信息</b>
<span>
灵犀管理员 · {detail.updatedAt.slice(0, 19).replace('T', ' ')} · 排序{' '}
{detail.sortOrder}
</span>
</div>
</div>
<div className="lx-timeline-item">
<i />
<div>
<b>{detail.status === 'disabled' ? '停用行业' : '启用行业'}</b>
<span>灵犀管理员 · 2026-10-15 17:40</span>
</div>
</div>
<div className="lx-timeline-item">
<i />
<div>
<b>创建行业</b>
<span>灵犀管理员 · 2026-09-01 10:00</span>
</div>
</div>
</div>
</div>
</>
) : null}
{tab === 'children' ? (
children.length === 0 ? (
<div className="lx-client-empty">
<b>暂无子行业</b>
<span>
{detail.level === 1
? '添加二级行业,客户只能归属到末级行业。'
: '二级行业是最末级,不能再添加子行业。'}
</span>
{detail.level === 1 && canManage ? (
<Button theme="solid" type="primary" onClick={onAddChild}>
添加二级行业
</Button>
) : null}
</div>
) : (
<div className="lx-industry-childlist">
{children.map((child) => (
<button
key={child.id}
type="button"
className="lx-industry-childrow"
onClick={() => {
onSelect(child.id);
}}
>
<span className="lx-industry-childdot" />
<b>{child.name}</b>
{statusTag(child.status)}
<span>{child.customerCount} 个客户</span>
<span>排序 {child.sortOrder}</span>
<em>查看</em>
</button>
))}
</div>
)
) : null}
{tab === 'refs' ? (
<>
<div className="lx-industry-sec-head">
<b>客户</b>
<span>{detail.references.customerCount} 个</span>
</div>
{detail.references.customers.length === 0 ? (
<div className="lx-client-hint">暂无客户</div>
) : (
<div className="lx-industry-reflist">
{detail.references.customers.map((customer) => (
<div key={customer.id} className="lx-industry-refrow">
<span className="lx-industry-refavatar">{customer.name.slice(-1)}</span>
<div>
<b>{customer.name}</b>
<span>
{customer.status === 'active'
? '在服务'
: customer.status === 'paused'
? '暂停'
: '结束'}
</span>
</div>
</div>
))}
</div>
)}
<div className="lx-industry-sec">
<div className="lx-industry-sec-head">
<b>资产</b>
<span>{detail.assetCount} 个</span>
</div>
<div className="lx-client-hint">
资产按客户归属统计;行业级资产包在 B0/B1 批次接入。
</div>
</div>
</>
) : null}
</div>
</>
);
}
/** 行业管理(选定方案 B:左两级树 + 右详情面板) */
export function IndustriesPage(): ReactNode {
const canManage = usePermission(Permission.CUSTOMER_MANAGE);
const list = useIndustriesQuery({ limit: 100 });
const [selectedId, setSelectedId] = useState<string | null>(null);
const activeId = selectedId ?? list.data?.items[0]?.id ?? null;
const detail = useIndustryDetailQuery(activeId);
const [modal, setModal] = useState<ModalState>(null);
const items = useMemo(() => list.data?.items ?? [], [list.data]);
const refresh = (): void => {
void list.refetch();
void detail.refetch();
};
if (list.isPending) return <PageSkeleton rows={8} />;
if (list.error) {
return (
<div className="lx-client-page">
<div className="lx-client-empty">
<b>行业加载失败</b>
<span>{list.error.message}</span>
<Button onClick={() => void list.refetch()}>重试</Button>
</div>
</div>
);
}
return (
<div className="lx-client-page">
<header className="lx-client-header">
<div>
<h1>行业管理</h1>
<p>维护两级行业树;客户挂末级行业,删除前需先处理子行业、客户与资产</p>
</div>
<div className="lx-client-header__actions">
<Can permission={Permission.CUSTOMER_MANAGE}>
<Button
theme="solid"
type="primary"
onClick={() => {
setModal({ kind: 'root' });
}}
>
新建一级行业
</Button>
</Can>
</div>
</header>
<div className="lx-client-split">
<section className="lx-client-left">
<div className="lx-client-left__head">
<b>行业树</b>
<span>
两级 · 共 {items.length + items.reduce((sum, item) => sum + item.children.length, 0)}{' '}
个
</span>
</div>
<div className="lx-client-left__body">
{items.length === 0 ? (
<div className="lx-client-empty">
<b>暂无行业</b>
<span>先创建一级行业,再添加二级行业</span>
</div>
) : (
<IndustryTree items={items} activeId={activeId} onSelect={setSelectedId} />
)}
</div>
<div className="lx-client-left__foot">
一级 {items.length} · 二级 {items.reduce((sum, item) => sum + item.children.length, 0)}
</div>
</section>
<section className="lx-client-right">
{detail.isPending || activeId === null ? (
<div className="lx-client-empty">
<b>选择行业</b>
<span>从左侧选择一级或二级行业查看详情</span>
</div>
) : detail.error ? (
<div className="lx-client-empty">
<b>行业详情加载失败</b>
<span>{detail.error.message}</span>
<Button onClick={() => void detail.refetch()}>重试</Button>
</div>
) : (
<IndustryDetailPanel
detail={detail.data}
items={items}
canManage={canManage}
onSelect={setSelectedId}
onAddChild={() => {
setModal({ kind: 'child', parentId: detail.data.id });
}}
onEdit={() => {
setModal({ kind: 'edit', industry: detail.data });
}}
onRefresh={refresh}
/>
)}
</section>
</div>
{modal !== null ? (
<IndustryFormModal
state={modal}
parentName={
modal.kind === 'child'
? items.find((item) => item.id === modal.parentId)?.name
: undefined
}
onClose={() => {
setModal(null);
}}
/>
) : null}
</div>
);
}
/** 供路由复用的空态 */
export function IndustriesEmpty(): ReactNode {
return <div className="lx-client-empty">暂无行业</div>;
}
@@ -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 : '命名预览失败',
};
}
}
+112
View File
@@ -44,6 +44,46 @@ const RoleNewPage = lazy(async () => {
return { default: module.RoleNewPage }; return { default: module.RoleNewPage };
}); });
const IndustriesPage = lazy(async () => {
const module = await import('@/pages/system/industries/IndustriesPage');
return { default: module.IndustriesPage };
});
const CustomersPage = lazy(async () => {
const module = await import('@/pages/system/customers/CustomersPage');
return { default: module.CustomersPage };
});
const CustomerNewPage = lazy(async () => {
const module = await import('@/pages/system/customers/CustomerNewPage');
return { default: module.CustomerNewPage };
});
const CustomerDetailPage = lazy(async () => {
const module = await import('@/pages/system/customers/CustomerDetailPage');
return { default: module.CustomerDetailPage };
});
const CustomerGroupsPage = lazy(async () => {
const module = await import('@/pages/system/customer-groups/CustomerGroupsPage');
return { default: module.CustomerGroupsPage };
});
const CustomerFieldsPage = lazy(async () => {
const module = await import('@/pages/system/customer-fields/CustomerFieldsPage');
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 { function withSkeleton(node: ReactNode): ReactNode {
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>; return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
} }
@@ -134,6 +174,78 @@ export const router = createBrowserRouter([
</RequirePermission> </RequirePermission>
), ),
}, },
{
path: 'system/industries',
element: (
<RequirePermission permission={Permission.CUSTOMER_READ}>
{withSkeleton(<IndustriesPage />)}
</RequirePermission>
),
},
{
path: 'system/customers',
element: (
<RequirePermission permission={Permission.CUSTOMER_READ}>
{withSkeleton(<CustomersPage />)}
</RequirePermission>
),
},
{
path: 'system/customers/new',
element: (
<RequirePermission permission={Permission.CUSTOMER_MANAGE}>
{withSkeleton(<CustomerNewPage />)}
</RequirePermission>
),
},
{
path: 'system/customers/:id',
element: (
<RequirePermission permission={Permission.CUSTOMER_READ}>
{withSkeleton(<CustomerDetailPage />)}
</RequirePermission>
),
},
{
path: 'system/customer-groups',
element: (
<RequirePermission permission={Permission.CUSTOMER_GROUP_MANAGE}>
{withSkeleton(<CustomerGroupsPage />)}
</RequirePermission>
),
},
{
path: 'system/customer-fields/approvals',
element: (
<RequirePermission permission={Permission.CUSTOMER_FIELD_APPROVE}>
{withSkeleton(<CustomerFieldsPage />)}
</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?', path: ':moduleKey/:itemKey?',
lazy: async () => ({ lazy: async () => ({
+33
View File
@@ -16,6 +16,15 @@ export const ROUTES = {
roles: '/system/roles', roles: '/system/roles',
roleNew: '/system/roles/new', roleNew: '/system/roles/new',
roleDetail: '/system/roles/:id', roleDetail: '/system/roles/:id',
industries: '/system/industries',
customers: '/system/customers',
customerNew: '/system/customers/new',
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', unauthorized: '/401',
forbidden: '/403', forbidden: '/403',
@@ -34,6 +43,15 @@ export type RoutePath =
| typeof ROUTES.system.roles | typeof ROUTES.system.roles
| typeof ROUTES.system.roleNew | typeof ROUTES.system.roleNew
| typeof ROUTES.system.roleDetail | typeof ROUTES.system.roleDetail
| typeof ROUTES.system.industries
| typeof ROUTES.system.customers
| typeof ROUTES.system.customerNew
| 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.unauthorized
| typeof ROUTES.forbidden | typeof ROUTES.forbidden
| typeof ROUTES.notFound | typeof ROUTES.notFound
@@ -49,6 +67,21 @@ export function roleDetailPath(roleId: string): string {
return `${ROUTES.system.roles}/${encodeURIComponent(roleId)}`; return `${ROUTES.system.roles}/${encodeURIComponent(roleId)}`;
} }
/** 客户详情深链:`/system/customers/{id}` */
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;
}
/** 新建角色深链;带来源角色时作为复制模板预填(特殊权限不随复制带入) */ /** 新建角色深链;带来源角色时作为复制模板预填(特殊权限不随复制带入) */
export function roleNewPath(sourceRoleId?: string): string { export function roleNewPath(sourceRoleId?: string): string {
return sourceRoleId === undefined return sourceRoleId === undefined
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -652,13 +652,13 @@ flowchart LR
#### 5.4.2 客户管理 #### 5.4.2 客户管理
**行业与客户是资产划分的两级目录**:行业 = 客户的业务属性(电商 / 金融等),客户 = 行业下的具体服务对象。信息模块以「行业 → 客户」上下级菜单呈现,是全部资产与生产任务的根节点。 **行业与客户是资产划分的两级目录**:行业本身为两级树(一级行业 = 业务属性,如电商 / 金融;二级行业 = 细分分类,如服饰鞋包 / 信贷),客户挂末级行业。信息模块以「行业树 → 客户」上下级菜单呈现,是全部资产与生产任务的根节点。
最小集:客户名称 / 简称(**简称必填**,用于全部自动命名)、所属行业(绑定一个行业包,可申请客户级覆盖)、服务状态(在服务 / 暂停 / 结束)、内部负责人与客户侧对接人、交付偏好(命名习惯、目录结构、常用规格、素材禁忌)、客户资产包、自定义字段(分两层,见下)。 最小集:客户名称 / 简称(**简称必填**,用于全部自动命名)、所属行业(绑定一个行业包,可申请客户级覆盖)、服务状态(在服务 / 暂停 / 结束)、内部负责人与客户侧对接人、交付偏好(命名习惯、目录结构、常用规格、素材禁忌)、客户资产包、自定义字段(分两层,见下)。
- **自定义字段分两层**:基础信息字段由运营录入时自由追加键值对(如门店编号、内部产品代码),每客户 ≤ 10 个,**只用于检索与报表,永不进入生成**;需要进入生成或参与校核的字段走 5.4.9 审批(≤ 5 个)。 - **自定义字段分两层**:基础信息字段由运营录入时自由追加键值对(如门店编号、内部产品代码),每客户 ≤ 10 个,**只用于检索与报表,永不进入生成**;需要进入生成或参与校核的字段走 5.4.9 审批(≤ 5 个)。
- **唯一性**:行业名称全局唯一;客户名称在同一行业内唯一。 - **唯一性**:一级行业名称全局唯一;二级行业名称在同一父级下唯一(不同一级行业下可重名);客户名称在同一末级行业下唯一。
- **不可跨行业移动**:客户必须归属一个行业,调整行业走「停用 + 新建」;删除行业前须校验其下无客户、无资产,否则只能停用。 - **不可跨行业移动**:客户必须归属一个末级行业,调整行业走「停用 + 新建」;删除行业前须校验其下无子行业、无客户、无资产,否则只能停用;有启用中的子行业时禁止停用。
- **新建客户时可套用行业资产包**:选行业 → 带出模板、场景、人物、提示词、卖点 → 替换为客户 Logo 与警示语。这是"新客户冷启动 ≤ 1 个工作日"的落地方式。 - **新建客户时可套用行业资产包**:选行业 → 带出模板、场景、人物、提示词、卖点 → 替换为客户 Logo 与警示语。这是"新客户冷启动 ≤ 1 个工作日"的落地方式。
- **客户组**:把客户按行业 / 团队 / 服务等级分组;客户组是批量授权与数据范围的最小单位,避免逐个客户分配权限(见 5.4.3)。 - **客户组**:把客户按行业 / 团队 / 服务等级分组;客户组是批量授权与数据范围的最小单位,避免逐个客户分配权限(见 5.4.3)。
- 客户暂停时只读,不能发起新生成;结束时全部只读,资产转"只读资产"。 - 客户暂停时只读,不能发起新生成;结束时全部只读,资产转"只读资产"。
@@ -785,9 +785,10 @@ flowchart LR
| 脚本方向 | 口播 / 剧情 / 测评 / 种草 / 情景…,可按行业收窄 | | 脚本方向 | 口播 / 剧情 / 测评 / 种草 / 情景…,可按行业收窄 |
| 情绪与风格 | 亲和 / 专业 / 激情 / 幽默 / 悬念 | | 情绪与风格 | 亲和 / 专业 / 激情 / 幽默 / 悬念 |
| 人群标签 | 年龄 / 性别 / 身份 / 消费力,可扩展 | | 人群标签 | 年龄 / 性别 / 身份 / 消费力,可扩展 |
| 行业二级 | 电商 → 服饰鞋包 / 美妆个护…;金融 → 信贷 / 保险 / 理财 |
| 素材形态 | 展示 / 口播 / 剧情 / 图文动效 / 信息图 | | 素材形态 | 展示 / 口播 / 剧情 / 图文动效 / 信息图 |
> 注:「行业二级」不再作为字典项,改由 `industries` 两级行业树承载(ADR-002);其余字典口径不变。
**变更规则**:字典变更**只影响新数据,历史数据保留快照**;否则改一次字典,历史统计全部失真。 **变更规则**:字典变更**只影响新数据,历史数据保留快照**;否则改一次字典,历史统计全部失真。
**通用枚举模型**:所有字典共用一张枚举表(类型 + 编码 + 名称 + 父编码 + 所属行业 + 排序 + 启用),一张表同时承载行业二级树与受控词表;编码不可改、名称可改;按行业作用域继承——查询取「本行业 + 父行业 + 通用」三层合并,行业包只覆盖差异项。 **通用枚举模型**:所有字典共用一张枚举表(类型 + 编码 + 名称 + 父编码 + 所属行业 + 排序 + 启用),一张表同时承载行业二级树与受控词表;编码不可改、名称可改;按行业作用域继承——查询取「本行业 + 父行业 + 通用」三层合并,行业包只覆盖差异项。
@@ -948,7 +949,7 @@ flowchart LR
| R-17 | 超级管理员保护 | 超管角色不可删改停用;授予超管须二次确认并审计;建议 ≤ 2 人 | | R-17 | 超级管理员保护 | 超管角色不可删改停用;授予超管须二次确认并审计;建议 ≤ 2 人 |
| R-18 | 职责互斥 | 生成与审核、行业包编辑与发布、字段申请与审批不可同属一个角色(超级管理员除外) | | R-18 | 职责互斥 | 生成与审核、行业包编辑与发布、字段申请与审批不可同属一个角色(超级管理员除外) |
| R-19 | 视频方向与脚本方向边界 | 脚本方向决定"说什么"(钩子与结构),视频方向决定"怎么呈现"(口播 / 剧情 / 产品展示);二者并列、不合并 | | R-19 | 视频方向与脚本方向边界 | 脚本方向决定"说什么"(钩子与结构),视频方向决定"怎么呈现"(口播 / 剧情 / 产品展示);二者并列、不合并 |
| R-20 | 行业 / 客户目录 | 行业是客户的业务属性,两级目录为全部资产的归属根节点;客户不可跨行业移动,行业名称全局唯一、客户名称同一行业内唯一 | | R-20 | 行业 / 客户目录 | 行业本身为两级树(一级行业 → 二级行业),客户挂末级行业;行业与客户构成资产归属的两级目录;客户不可跨行业移动,一级行业名称全局唯一、二级行业名称在同一父级下唯一、客户名称在同一末级行业下唯一 |
| R-21 | 脚本编号唯一 | 过审入库时按 `客户编码-YYMMDD+序号` 原子分配;草稿不占号;编号一经分配不重用、不可改 | | R-21 | 脚本编号唯一 | 过审入库时按 `客户编码-YYMMDD+序号` 原子分配;草稿不占号;编号一经分配不重用、不可改 |
| R-22 | 客户审核链接安全 | 免登录链接须签名 + 有效期 + 限范围(只读 / 可改稿);链接可随时作废;改稿保留原稿快照与操作人 | | R-22 | 客户审核链接安全 | 免登录链接须签名 + 有效期 + 限范围(只读 / 可改稿);链接可随时作废;改稿保留原稿快照与操作人 |
| R-23 | 提示词与模型配置版本化 | 保存即快照、可回滚;任务创建时记录所用版本号,事后可复现「当时为什么生成成这样」 | | R-23 | 提示词与模型配置版本化 | 保存即快照、可回滚;任务创建时记录所用版本号,事后可复现「当时为什么生成成这样」 |
+126
View File
@@ -0,0 +1,126 @@
# ADR-002 行业目录:由单层改为两级树,客户挂末级行业
| 项 | 内容 |
| --- | --- |
| 状态 | 已接受(2026-11-03,用户确认 A3 口径) |
| 决策人 | 产品 / 技术负责人(用户) |
| 记录人 | 工程 Lead |
| 关联 | 开发计划 V0.1 §6.4 A3、§7.4、§12.1 M0 DoD、附录 C;PRD V1.6 §5.4.2 / §5.4.5、R-20;技术架构 V0.4 §5.3 / §5.4.1 / §6.3.2;ADR-001 |
| 影响范围 | `client` 模块、`industry` 模块(行业包绑定)、`catalog` / `script` / `media` / `asset` 的行业维度、数据范围与统计报表、A4 字典口径、开发计划与 PRD/架构文档 |
## 背景
PRD R-20 与技术架构 §5.3 均把 `industries` 描述为**单层行业目录**(「行业 = 客户的业务属性」,名称全局唯一),「行业二级(电商 → 服饰鞋包 / 美妆个护)」在 PRD §5.4.5 中作为 A4 字典项处理;开发计划 §6.4 A3-BE-01 也按单层 `industries` 描述。
A3 开卡前用户确认:行业目录需要**两级树**(一级行业 → 二级行业),客户归属到末级行业。该口径与既有文档冲突,且影响客户归属、行业包匹配、数据范围、统计与 A4 字典边界,属于 AGENTS §0.4 要求先提 ADR 的决策。
## 决策
### D1 行业为两级树,最多两级
`industries` 表增加:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `parent_id` | ulid FK → industries,可空 | 一级行业为空;二级行业指向一级行业 |
| `level` | smallint | 1 / 2;由服务端按 `parent_id` 推导并校验 |
| `sort_order` | int | 同级排序,默认 10 |
| `status` | varchar | 启用 / 停用 |
| `remark` | varchar(200) | 备注 |
约束:
- 仅允许两级;二级行业下不可再建子行业(应用层校验 + 数据库可选触发器兜底)。
- 一级行业 `parent_id IS NULL AND level = 1`;二级行业 `parent_id NOT NULL AND level = 2`。
- 客户只挂末级行业,详见 D2。
### D2 客户挂末级行业,行业包按祖先回退
- 客户 `industry_id` 指向**末级行业**:有子行业的一级行业不可被客户选择;一级行业无子行业时可直接选择。
- 客户创建后不可跨行业移动;调整行业走「停用 + 新建」(PRD R-20 原口径不变)。
- 行业包匹配按「精确行业 → 一级行业 → 默认」回退(B0 落地);行业顾问/行业指南同样按「二级 → 一级 → default → 内置准则」匹配(PRD §5.4.7 / §7.x)。
- 行业级资产包、行业级字段覆盖等按「客户所属末级行业 → 一级行业」继承。
### D3 行业名称唯一性:一级全局唯一 + 二级同父唯一
- 一级行业名称全局唯一。
- 二级行业名称在同一 `parent_id` 下唯一;不同一级行业下允许重名(如「其他」)。
- 客户名称仍在同一末级行业下唯一(`(industry_id, name)` 唯一,原口径不变)。
### D4 删除与停用:只停用 + 引用保护
- 删除:无子行业、无客户、无资产时才可删除;否则只能停用。
- 停用:有启用中的子行业时禁止停用,提示先处理子级;有客户时允许停用,停用后新建客户不可选该行业,已有客户数据不受影响。
- 停用一级行业不级联停用子级;需逐级处理,避免一次操作影响面过大。
### D5 权限复用现有 30 个权限点
A3 行业管理不新增权限点:
- 查看:`customer:read`;写(新建 / 编辑 / 停用 / 删除):`customer:manage`。
- 开发计划附录 C 写的 `industry_pack:view` 在 A2 定稿的 30 个权限点中不存在,按本 ADR 回写为 `customer:read`;`industry:manage` 仍表示行业包编辑(B0)。
### D6 「行业二级」从 A4 字典中移除
PRD §5.4.5 的「行业二级」字典项改由 `industries` 两级树承载;A4 字典保留其他受控词表。A4-BE-01 的「三层合并(本行业 + 父行业 + 通用)」中的「行业作用域」按两级树的祖先链解释。
## 备选方案
| 方案 | 结论 | 原因 |
| --- | --- | --- |
| 保持单层行业(PRD/架构原文) | 未采用 | 用户明确要求两级;单层无法表达「电商 → 服饰鞋包」的行业分类 |
| 单层落地 + 预留 `parent_id` | 未采用 | 本期 UI 与客户选择就要两级联动,预留字段会造成前后端口径分叉 |
| 行业二级放 A4 字典 | 未采用 | 字典项没有行业归属/状态/引用保护,且客户 `industry_id` 无法稳定指向二级;A3 就需要行业树维护 |
| **两级树 + 客户挂末级(本 ADR)** | 采用 | 与用户口径一致;行业包按祖先回退,保留单层时期的继承语义 |
## 后果
### 正面
- 行业分类可表达一级/二级,客户归属更精确;行业包可在任一级配置并向下继承。
- 行业二级不再依赖 A4 字典,A3 即可交付完整行业树维护。
- 权限点不新增,复用 A2 定稿目录,治理成本低。
### 负面 / 代价
- 与 PRD R-20、技术架构 `industries`、开发计划 A3/A4/M0 DoD 的原文冲突,必须同步修订,否则文档与实现不一致。
- 客户归属校验从「行业存在」升级为「行业存在且为末级」,A2 `CustomerDirectory` 与数据范围解析需按末级行业返回客户。
- 行业统计、报表、行业包匹配需处理祖先链,查询复杂度上升。
- 历史数据若已按单层行业写入(当前本地库无行业数据),需 `parent_id` 置空并标记为一级行业;A3 首次建表,不涉及存量迁移。
### 迁移 / 兼容策略
A3 首次创建 `industries` 表即按两级结构落地,采用 expand → migrate → contract:
1. 建表含 `parent_id` / `level` / `sort_order` / `status` / `remark`;
2. 迁移脚本对存量行业(如有)设 `parent_id = NULL, level = 1`;
3. 应用层先写 `level`,后续若需严格约束再补触发器 / 检查约束;
4. 回滚:删除新增列即可回到单层语义(客户 `industry_id` 仍指向原行业)。
## 待办 / 回写清单
| 文件 | 章节 | 需要改成什么 |
| --- | --- | --- |
| `docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md` | R-20 | 明确行业两级树、客户挂末级、一级全局唯一 / 二级同父唯一 |
| 同上 | §5.4.2 | 客户所属行业改为末级行业;补充两级树与停用/删除规则 |
| 同上 | §5.4.5 | 「行业二级」从字典表移除,注明由 `industries` 两级树承载 |
| 同上 | §5.4.7 / §7.x 行业指南 | 明确「二级 → 一级 → default」回退 |
| `docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md` | §5.3 表清单 | `industries` 增加 `parent_id` / `level`,约束改为「最多两级、一级全局唯一、二级同父唯一」 |
| 同上 | §5.4.1 `customers` | `industry_id` 指向末级行业;补充「客户不可挂有子行业的一级行业」 |
| 同上 | §6.3.2 | 行业接口权限改为 `customer:read` / `customer:manage`;补充两级树与删除/停用校验 |
| `docs/开发计划/灵犀_信息流素材生产平台_开发计划_V0.1.md` | §6.4 A3-BE-01 | 改为两级行业树;删除/停用规则按 ADR-002 D4 |
| 同上 | §7.4 | 页面口径补充「一级/二级树、客户挂末级」 |
| 同上 | §12.1 M0 DoD | 「行业两级」改为「行业两级树(一级 → 二级),客户挂末级」 |
| 同上 | 附录 C | `/system/industries` 权限改为 `customer:read` / `customer:manage` |
| `docs/开发计划/灵犀_A2角色权限与数据范围_实施报告_V0.1.md` | §5 遗留 1 | 注明 A3 接入真实客户目录时按末级行业返回客户 |
| `README.md` / `AGENTS.md` | 文档索引 / 事实基线 | 视需要补充 ADR-002 与行业口径变更 |
> 文档同步按 AGENTS §0.4「先改文档,再改代码」执行;本 ADR 先于 A3 后端实现落地。
## 关联
- [ADR-001 权限点目录与六类数据范围](ADR-001-权限点目录与六类数据范围.md)
- 开发计划 V0.1 §6.4 A3、§7.4、§12.1
- PRD V1.6 R-20、§5.4.2、§5.4.5
- 技术架构 V0.4 §5.3、§5.4.1、§6.3.2
- 前端原型:`design/a3-ui-v1/`(三套候选,待用户选择)
+1
View File
@@ -6,6 +6,7 @@
| 编号 | 决策 | 状态 | 日期 | 关联 | | 编号 | 决策 | 状态 | 日期 | 关联 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| [ADR-001](ADR-001-权限点目录与六类数据范围.md) | 角色权限模型:权限点目录保留 `read/write` 只增不改、特殊权限五项 6 编码、SoD 三对互斥、六类数据范围全链路 + `CustomerDirectory` 端口、超管隐式全集、A2 审计先结构化日志 | 已接受 | 2026-11-03 | A2 / A3 / DP-14 | | [ADR-001](ADR-001-权限点目录与六类数据范围.md) | 角色权限模型:权限点目录保留 `read/write` 只增不改、特殊权限五项 6 编码、SoD 三对互斥、六类数据范围全链路 + `CustomerDirectory` 端口、超管隐式全集、A2 审计先结构化日志 | 已接受 | 2026-11-03 | A2 / A3 / DP-14 |
| [ADR-002](ADR-002-两级行业目录.md) | 行业目录由单层改为两级树(一级 → 二级),客户挂末级行业,一级全局唯一 / 二级同父唯一,只停用 + 引用保护,「行业二级」从 A4 字典移除 | 已接受 | 2026-11-03 | A3 / A4 / B0 |
## 新增流程 ## 新增流程
+2 -2
View File
@@ -21,7 +21,7 @@
- `GIT-01` 所有提交直接基于最新 `main`;提交前先 `git pull --ff-only`,避免落后。 - `GIT-01` 所有提交直接基于最新 `main`;提交前先 `git pull --ff-only`,避免落后。
- `GIT-02` 禁止创建并推送任何非 `main` 分支;确需本地验证时可临时创建,但不得推送,完成后立即删除。 - `GIT-02` 禁止创建并推送任何非 `main` 分支;确需本地验证时可临时创建,但不得推送,完成后立即删除。
- `GIT-03` 允许直接 push `main`;推送前必须通过本地门禁(`format:check`、`lint`、`typecheck`、`test`),推送后立即确认 CI。 - `GIT-03` 允许直接 push `main`;推送前必须通过本地门禁(`format:check`、`lint`、`typecheck`、`test`,完整链路用 `pnpm ci:local`)。**远端 Gitea 未部署 runner,线上 CI 不可用,推送后不等待远端 CI**。
- `GIT-04` 禁止 force push `main`;禁止 Merge commit;`main` 必须保持线性。 - `GIT-04` 禁止 force push `main`;禁止 Merge commit;`main` 必须保持线性。
## 2 提交信息(Conventional Commits 1.0) ## 2 提交信息(Conventional Commits 1.0)
@@ -134,4 +134,4 @@ export default {
- [ ] 提交粒度单一,无无关文件 - [ ] 提交粒度单一,无无关文件
- [ ] 无 `.env` / 密钥 / 大文件 - [ ] 无 `.env` / 密钥 / 大文件
- [ ] 提交说明含背景 / 影响面 / 回滚方式 / 验证证据 - [ ] 提交说明含背景 / 影响面 / 回滚方式 / 验证证据
- [ ] 本地门禁全绿,已 push `main` 并确认 CI - [ ] 本地门禁全绿(必要时 `pnpm ci:local`),已 push `main`;远端无 CI,无需等待
+13 -7
View File
@@ -2,10 +2,11 @@
| 项 | 内容 | | 项 | 内容 |
| --- | --- | | --- | --- |
| 文档版本 | V1.0(2026-11-02) | | 文档版本 | V1.1(2026-11-04) |
| 依据 | 技术架构 §10.4(测试策略)、§4.6(CI/CD)、§4.1.1(本地环境) | | 依据 | 技术架构 §10.4(测试策略)、§4.6(CI/CD)、§4.1.1(本地环境) |
| 适用范围 | 全仓库 | | 适用范围 | 全仓库 |
| 强制级别 | 强制 | | 强制级别 | 强制 |
| 远端 CI | **不启用**:Gitea 无 runner,CI 只本地执行 |
--- ---
@@ -14,15 +15,15 @@
| 层级 | 工具 | 范围 | 运行时机 | | 层级 | 工具 | 范围 | 运行时机 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 单元 | Vitest | Service、工具函数、状态机、命名 / 表单引擎 | 每次提交(CI + 本地) | | 单元 | Vitest | Service、工具函数、状态机、命名 / 表单引擎 | 每次提交(CI + 本地) |
| 集成 | Vitest + Testcontainers(PG 18 / Redis 7.4 / MinIO) | Repository、队列、迁移、事务 | 每次 push `main`(CI) | | 集成 | Vitest + Testcontainers(PG 18 / Redis 7.4 / MinIO) | Repository、队列、迁移、事务 | 每次推送前本地 `pnpm ci:local` |
| 契约 | OpenAPI + `oasdiff` | 接口兼容性 | 每次 push `main` | | 契约 | OpenAPI + `oasdiff` | 接口兼容性 | 每次推送前本地 `pnpm ci:local` |
| E2E | Playwright | 主链路(登录 → 生成 → 预览 → 勾选 → 导出) | 主干 + 发布前 | | E2E | Playwright | 主链路(登录 → 生成 → 预览 → 勾选 → 导出) | 主干 + 发布前 |
| 性能 | k6 | 列表 / 任务提交 / SSE | 发布前(仅 staging) | | 性能 | k6 | 列表 / 任务提交 / SSE | 发布前(仅 staging) |
| 视觉回归 | 图片像素比对 | PSD 合成产物 | 改动 `packages/render` 时 | | 视觉回归 | 图片像素比对 | PSD 合成产物 | 改动 `packages/render` 时 |
- `TST-01` 单测与源码同目录,命名 `<name>.spec.ts`;集成 `*.int.spec.ts`;E2E 放 `e2e/`。 - `TST-01` 单测与源码同目录,命名 `<name>.spec.ts`;集成 `*.int.spec.ts`;E2E 放 `e2e/`。
- `TST-02` 关键包(`shared` / `form-engine` / `render` / `ai-sdk`)行覆盖率 **≥ 70%**;整体基线只升不降。 - `TST-02` 关键包(`shared` / `form-engine` / `render` / `ai-sdk`)行覆盖率 **≥ 70%**;整体基线只升不降。
- `TST-03` 禁止提交 `.skip` / `.only`;CI 检测到即失败。 - `TST-03` 禁止提交 `.skip` / `.only`;本地 `pnpm ci:local` 检测到即失败。
- `TST-04` 禁止依赖执行顺序;每个测试自建数据、自清理(用独立 `customer_id` 前缀)。 - `TST-04` 禁止依赖执行顺序;每个测试自建数据、自清理(用独立 `customer_id` 前缀)。
--- ---
@@ -46,7 +47,7 @@
## 3 测试数据与环境 ## 3 测试数据与环境
- `TST-15` 禁止连接生产库 / 生产对象存储;CI 用 Testcontainers 起同版本依赖。 - `TST-15` 禁止连接生产库 / 生产对象存储;本地 CI 用 Testcontainers / 本地容器起同版本依赖。
- `TST-16` 测试数据用 factory 构造,禁止依赖线上导出数据。 - `TST-16` 测试数据用 factory 构造,禁止依赖线上导出数据。
- `TST-17` 测试内置时钟可注入(`clock` 包),禁止 `new Date()` 直接断言时间。 - `TST-17` 测试内置时钟可注入(`clock` 包),禁止 `new Date()` 直接断言时间。
- `TST-18` AI 调用在测试中一律 mock Provider,**禁止**真实外网调用(防止费用与不稳定)。 - `TST-18` AI 调用在测试中一律 mock Provider,**禁止**真实外网调用(防止费用与不稳定)。
@@ -54,7 +55,7 @@
--- ---
## 4 CI 流水线 ## 4 本地 CI 流水线(远端不跑 CI)
``` ```
install --frozen-lockfile install --frozen-lockfile
@@ -64,6 +65,11 @@ install --frozen-lockfile
→ e2e(main)→ 镜像构建 + 漏洞扫描 → e2e(main)→ 镜像构建 + 漏洞扫描
``` ```
> **强制规则(2026-11-04 确认)**:远端自建 Gitea **未部署 Actions runner,线上 CI 不可用**。所有 CI 一律在本地执行:
> - 推送前运行 `pnpm ci:local`(完整链路)或至少 `pnpm format:check && pnpm lint && pnpm typecheck && pnpm test`;
> - `.gitea/workflows/ci.yml` 仅供本地参考 / 手动触发,**不随 push 自动运行**;
> - 禁止在推送后等待远端 CI、禁止以「远端 CI 会在后续跑」为由跳过本地门禁。
| 阶段 | 阻断条件 | | 阶段 | 阻断条件 |
| --- | --- | | --- | --- |
| 安装 | lock 文件与 `package.json` 不一致 | | 安装 | lock 文件与 `package.json` 不一致 |
@@ -114,7 +120,7 @@ staging 部署 → 冒烟(登录 / 生成 / 预览 / 导出)
- [ ] 端点有权限三态用例,客户隔离用例存在 - [ ] 端点有权限三态用例,客户隔离用例存在
- [ ] 状态机非法迁移、幂等、成本前置已覆盖 - [ ] 状态机非法迁移、幂等、成本前置已覆盖
- [ ] 集成测试用 Testcontainers,无外网依赖 - [ ] 集成测试用 Testcontainers,无外网依赖
- [ ] CI 全绿且未使用豁免 - [ ] 本地 `pnpm ci:local` 全绿且未使用豁免(远端无 runner)
- [ ] 迁移可回滚,staging 已演练 - [ ] 迁移可回滚,staging 已演练
- [ ] 未完成功能藏开关,开关有清理期限 - [ ] 未完成功能藏开关,开关有清理期限
- [ ] 发布记录与回滚预案已写 - [ ] 发布记录与回滚预案已写
@@ -101,7 +101,7 @@
同时满足才可推送: 同时满足才可推送:
1. 本地门禁全绿;推送后 CI 全绿(含契约与集成),无豁免。 1. 本地门禁全绿(含契约与集成,必要时 `pnpm ci:local`);远端 Gitea 无 runner,不等待线上 CI,无豁免。
2. Review 通过,blocking 意见全部解决。 2. Review 通过,blocking 意见全部解决。
3. 文档同步:PRD 规则编号、技术架构、OpenAPI、本目录细则(如涉及)。 3. 文档同步:PRD 规则编号、技术架构、OpenAPI、本目录细则(如涉及)。
4. 迁移可回滚,或在描述说明为何不可回滚与前向修复方案。 4. 迁移可回滚,或在描述说明为何不可回滚与前向修复方案。
@@ -113,7 +113,7 @@
## 5 推送与推送后 ## 5 推送与推送后
- `CR-08` 推送前完成本地门禁(`format:check` / `lint` / `typecheck` / `test`);推送即进入主干,不再有 Squash 合并环节。 - `CR-08` 推送前完成本地门禁(`format:check` / `lint` / `typecheck` / `test`);推送即进入主干,不再有 Squash 合并环节。
- `CR-09` 直接 `git push origin main`;禁止 force push、禁止 Merge commit;推送后 15 分钟内确认 CI 与错误率。 - `CR-09` 直接 `git push origin main`;禁止 force push、禁止 Merge commit;推送前确认 `pnpm ci:local` 全绿,推送后不等待远端 CI(远端无 runner)。
- `CR-10` 推送后发现缺陷:优先以新提交前向修复;影响面大时按回滚预案执行(回滚也用新提交,不回写历史),并补一次复盘(写 `docs/releases/` 或 issue)。 - `CR-10` 推送后发现缺陷:优先以新提交前向修复;影响面大时按回滚预案执行(回滚也用新提交,不回写历史),并补一次复盘(写 `docs/releases/` 或 issue)。
- `CR-11` 禁止「先推后补测试 / 补文档」作为常态;紧急修复须 24 小时内以新提交补齐,并在提交说明记录。 - `CR-11` 禁止「先推后补测试 / 补文档」作为常态;紧急修复须 24 小时内以新提交补齐,并在提交说明记录。
@@ -74,11 +74,13 @@ H1 权限门口径(用户确认):权限点保留现有 `read/write` 编码
| 格式 | `pnpm format:check` | ✅ | | 格式 | `pnpm format:check` | ✅ |
| Lint | `pnpm lint` | ✅ 0 warning | | Lint | `pnpm lint` | ✅ 0 warning |
| 类型 | `pnpm typecheck` | ✅ 10/10 tasks | | 类型 | `pnpm typecheck` | ✅ 10/10 tasks |
| 单测 | `pnpm test` | ✅ shared 119 / api 85 / web 102,共 306 | | 单测 | `pnpm test` | ✅ shared 121 / api 90 / web 120,共 331 |
| 集成 | `pnpm test:int` | ✅ 3 文件 / 27 条(health 1 + identity 13 + roles 13) | | 集成 | `pnpm test:int` | ✅ 4 文件 / 33 条(含 A2 roles 13 条) |
| 浏览器 e2e | `A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts` | ✅ 1 passed(18.9s),复跑 1 passed(19.3s) | | 浏览器 e2e | `A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts` | ✅ 1 passed(22.1s,最终版 UI 下复跑通过) |
| OpenAPI | `pnpm openapi:export` | ✅ 新增 `/roles`、`/roles/{id}`、`/permissions` 路径 | | OpenAPI | `pnpm openapi:export` | ✅ 新增 `/roles`、`/roles/{id}`、`/permissions` 路径 |
> 最终验收日期 2026-11-03。A2 收口后补充的 UI 调整(角色管理命名、页头主操作右对齐、编辑角色移入详情页头、移除重复的权限点目录抽屉)已随同一批提交合入,并在最终版 UI 上重跑 e2e。
A2-QA-01 覆盖:6 个角色端点权限三态(200/201/204 vs 403 `40301`)、40401、40903、40302 fail-closed;SoD 三对 40304 + `details.conflicts`、失败不落库、系统角色豁免边界;特殊权限分桶与单独授予、复制不带入;同一 access token 的改权限/授特殊/停用/恢复即时生效;outbox 变更审计。 A2-QA-01 覆盖:6 个角色端点权限三态(200/201/204 vs 403 `40301`)、40401、40903、40302 fail-closed;SoD 三对 40304 + `details.conflicts`、失败不落库、系统角色豁免边界;特殊权限分桶与单独授予、复制不带入;同一 access token 的改权限/授特殊/停用/恢复即时生效;outbox 变更审计。
--- ---
@@ -0,0 +1,164 @@
# 灵犀 A3 客户与行业目录 · 实施报告 V0.1
| 项 | 内容 |
| --- | --- |
| 实施日期 | 2026-11-03 |
| 批次 | A3(客户与行业目录) |
| 状态 | 已完成,门禁通过;未推送 |
| 依据 | 开发计划 V0.1 §6.4 / §7.4 / §12.1 / 附录 C;PRD V1.6 §5.4.2 / §5.4.9、R-07 / R-20;技术架构 V0.4 §5.3 / §5.4.1 / §6.3.2;ADR-002 |
| 决策记录 | [ADR-002 两级行业目录](../adr/ADR-002-两级行业目录.md) |
| 视觉基线 | A0「方向 A · 云白蓝」;A3 用户选定混搭:**行业管理 B / 客户列表 A / 客户详情 A / 新建客户 B / 客户组 B / 字段审批 A** |
---
## 1 范围与完成度
| 任务 | 内容 | 状态 |
| --- | --- | --- |
| A3-BE-01 | 两级行业树(一级 → 二级)、名称唯一、停用/删除引用保护 | ✅ |
| A3-BE-02 | 客户档案、简称必填、同末级行业唯一、状态机(在服务 / 暂停 / 结束) | ✅ |
| A3-BE-03 | 客户组与成员,客户组作为数据范围最小单位 | ✅ |
| A3-BE-04 | 客户级字段申请 / 审批,进入生成 ≤ 5,超限 40001 | ✅ |
| A3-BE-05 | 客户上下文与归属校验,跨客户 404;真实 `CustomerDirectory` | ✅ |
| A3-FE-01 | 行业管理(B:左两级树 + 右详情) | ✅ |
| A3-FE-02 | 客户列表 / 详情(A)、四步新建(B) | ✅ |
| A3-FE-03 | 客户组(B:左列表 + 右成员) | ✅ |
| A3-FE-04 | 字段申请与审批(A:全宽表格 + 审批弹窗) | ✅ |
| A3-QA-01 | 唯一性 / 状态 / 范围 / 字段上限用例 + 浏览器 e2e | ✅ |
> 用户确认口径:两级行业树、客户挂末级行业、一级全局唯一 + 二级同父唯一、只停用 + 引用保护、字段双层、资产包留入口、客户转交纳入 A3、复用现有 30 个权限点。
---
## 2 交付物
### 2.1 文档与决策
- 新增 [ADR-002 两级行业目录](../adr/ADR-002-两级行业目录.md) 并登记 ADR 索引;含 PRD / 技术架构 / 开发计划回写清单。
- 同步 PRD R-20 / §5.4.2 / §5.4.5、技术架构 §5.3 / §5.4.1 / §6.3.2、开发计划 §6.4 / §7.4 / §12.1 / 附录 C。
- 前端原型:`design/a3-ui-v1/`(三套候选 + 独立评审 + 用户选定混搭);实现截图:`design/a3-ui-v1/impl/`。
### 2.2 共享契约(`packages/shared`)
- `schemas/client.ts`:行业两级树 / 引用、客户档案与状态、客户组、客户级字段、转交、列表 query;写接口 `.strict()`。
- 上限常量 `CUSTOMER_BASE_FIELD_LIMIT = 10`、`CUSTOMER_GEN_FIELD_LIMIT = 5`(A4 迁入 `system_configs`,R-26)。
- `schemas/client.spec.ts`:行业 / 客户 / 客户组 / 字段契约覆盖。
### 2.3 数据库与种子
- 迁移 `prisma/migrations/20261103130048_a3_client_industry_customer/`(expand):
- `industries`:`parent_id` / `level` / `sort_order` / `status` / `remark`;部分唯一索引(一级全局唯一、二级同父唯一);两级 CHECK 与非自引用 CHECK;
- `customers`:`industry_id`、`short_name`、状态、负责人、对接人、偏好 JSONB、基础字段 JSONB、软删除;
- `customer_groups` / `customer_group_members`;
- `customer_fields`:进入生成与审批状态、申请人 / 审批人、驳回原因。
- `prisma/seed.ts`:幂等写入 电商(服饰鞋包 / 美妆个护)、金融(信贷)、2 个客户、1 个客户组、4 条客户字段(1 条待审批)。
- `prisma/SEED.md` 待同步(见遗留)。
### 2.4 后端(`apps/api/src/modules/client`)
| 接口 | 说明 |
| --- | --- |
| `GET/POST /industries`、`GET/PATCH/DELETE /industries/{id}`、`PATCH /industries/{id}/status` | 两级树、引用、停用/删除保护 |
| `GET/POST /customers`、`GET/PATCH /customers/{id}`、`PATCH /customers/{id}/status` | 列表(ScopeContext 客户维度)、详情(跨客户 404)、状态机 |
| `POST /customers/{id}/transfer`、`PUT /customers/{id}/groups`、`POST /customers/{id}/apply-industry-pack` | 转交、客户组、B0 空态 |
| `GET/POST /customer-groups`、`GET/PATCH/DELETE /customer-groups/{id}`、`POST /customer-groups/{id}/members` | 客户组与成员幂等 |
| `GET /customer-fields`、`POST /customer-fields/{id}/approve`、`GET/POST /customers/{id}/fields` | 审批队列、通过/驳回(驳回原因必填) |
| `POST /users/{id}/transfer-customers` | 保留 A1 路径,由 client 模块落地批量转交 |
- 真实 `PrismaCustomerDirectory` 替换 `EmptyCustomerDirectory`;六类数据范围解析接入客户 / 客户组 / 部门 / 创建人。
- 客户上下文:所有客户读写携带 `ScopeContext`;跨客户统一 404;客户组删除前校验 `data_scopes` 引用。
- 审计:行业 / 客户 / 客户组 / 字段 / 转交均带 `@Audit()` 结构化日志(A5 落库)。
- 测试:`client.int.spec.ts` 6 条(行业唯一/停用停用、客户唯一/跨客户/状态机、客户组范围、字段审批/超限、转交、状态只读)。
### 2.5 前端(`apps/web`)
| 页面 | 路由 | 选定布局 |
| --- | --- | --- |
| 行业管理 | `/system/industries` | B:左两级树 + 右详情(白底卡片) |
| 客户列表 | `/system/customers` | A:全宽表格 + 筛选条 |
| 客户详情 | `/system/customers/:id` | A:左锚点 + 中列 + 右侧状态 |
| 新建客户 | `/system/customers/new` | B:左侧步骤 + 右侧表单,四步可回退 |
| 客户组 | `/system/customer-groups` | B:左列表 + 右成员 |
| 客户字段审批 | `/system/customer-fields/approvals` | A:全宽表格 + 通过/驳回弹窗 |
- 侧边栏更名:「客户」→「客户管理」、「行业树」→「行业管理」;新增「客户组」;「客户字段」指向审批页,权限 `customer_field:approve`。
- **2026-11-03 UI 二次对齐**(用户反馈行业管理 / 新建客户与原型差距大):
- 行业管理补回原型 B 的头像 + 状态/层级标签 + Tab(基本信息 / 子行业 / 引用)+ 最近变更时间线 + 子行业列表 / 引用客户列表;右栏白底卡片。
- 新建客户补回原型 B 的左侧「创建客户 / 共 4 步」步骤导航(标题 + 说明 + 当前/完成态)与两级行业卡片选择(一级分组、二级单选、客户数),替换原 Semi 默认 Steps + 下拉框。
- 客户组补成员表(客户 / 所属行业 / 状态 / 负责人 / 操作);客户列表补表头统计;客户详情补右栏快捷操作;字段审批补分段状态与中文类型。
- 主操作按钮统一 `theme="solid"`;副操作保持描边。截图已重拍至 `design/a3-ui-v1/impl/`。
- 客户管理筛选区补白底卡片与搜索图标/占位间距,与用户管理、角色管理的筛选条口径一致(2026-11-04)。
- 样式:`styles/client.css` 复用 `--lx-*` token;未新增依赖、未内联 SVG(图标走 `@lingxi/ui/icons`)。
- API:`api/customers.ts` + `hooks/queries/use-client.ts`,全部用 shared Zod schema 解析。
---
## 3 验证结果
| 门禁 | 命令 | 结果 |
| --- | --- | --- |
| 格式 | `pnpm format:check` | ✅ |
| Lint | `pnpm lint` | ✅ 0 warning |
| 类型 | `pnpm typecheck` | ✅ 10/10 tasks |
| 单测 | `pnpm test` | ✅ shared 131 / api 90 / web 125,共 346 |
| 集成 | `pnpm test:int` | ✅ 5 文件 / 39 条(含 A3 client 6 条) |
| 浏览器 e2e | `A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts` | ✅ 1 passed(11.6s) |
| 构建 | `pnpm build` | ✅ 8/8 tasks |
| OpenAPI | `pnpm openapi:export` | ✅ 新增 industries / customers / customer-groups / customer-fields 路径 |
浏览器验证(真实 API + PG + Chrome):登录 → 行业管理树/详情 → 客户列表 → 客户详情 → 新建客户 → 客户组 → 字段审批六页均正常渲染;控制台仅 favicon 404。截图:`design/a3-ui-v1/impl/`。
---
## 4 关键口径与实现说明
1. **两级行业**:`level` 由 `parent_id` 推导;数据库 CHECK 保证「parent 为空 ⇔ level=1」,服务层禁止二级下再建子级;名称唯一通过部分唯一索引兜底。
2. **客户挂末级**:`assertLeafIndustry` 校验;客户更新不接收 `industryId`,跨行业移动走停用 + 新建。
3. **状态机**:在服务 ⇄ 暂停;在服务 / 暂停 → 结束;结束不可逆。暂停 / 结束客户更新、字段申请、转交均返回 40903。
4. **字段双层**:基础字段在 `customers.custom_fields` JSONB(≤10,免审批);`customer_fields` 只承载结构化 / 进入生成字段,进入生成需审批,按「占用槽位」计数(含待审批、不含已驳回)。
5. **数据范围**:`CustomerDirectory` 真实实现;客户列表按 `customerIds` 过滤;单条跨客户读统一 404;客户组数据范围用例已验证。
6. **客户转交**:`/customers/{id}/transfer` 需 `customer:transfer`;`/users/{id}/transfer-customers` 保留 A1 路径,允许 `customer:transfer` 或 `user:manage`。
7. **资产包**:`POST /customers/{id}/apply-industry-pack` 当前返回 42201「B0 批次提供」,前端显示空态,不伪造数据。
---
## 5 已知差异与遗留
| 编号 | 项 | 说明 / 建议 |
| --- | --- | --- |
| 1 | 行业资产包套用 | 依赖 B0/B1,当前接口返回明确业务提示 + 前端空态;B0 落地后接通。 |
| 2 | 审计落库 | A3 变更走 `@Audit()` 结构化 Pino 日志;审计表、检索、导出按 A5。 |
| 3 | 字段上限配置 | 共享常量 `10 / 5`;A4 `system_configs` 落地后迁入,前端上限读配置接口(R-26)。 |
| 4 | 客户级字段类型 | 仅白名单 文本 / 数字 / 日期 / 单选;选项在原型中简化,完整枚举来源随 A4 字典。 |
| 5 | 行业资产数 | `assetCount` 固定 0;B1 资产表落地后接入。 |
| 6 | 客户列表分页 | 前端一次拉 100 条(A3 规模);B2 后按大数据量补游标分页 UI。 |
| 7 | 客户详情面包屑 | 由导航派生,末级显示「客户管理」而非客户名;与 A2 角色编辑器同源问题,后续统一。 |
| 8 | 状态截图 | 原型阶段 A/B/C 状态图已覆盖;实现截图以主态 + e2e 为准,暂停/结束状态由 API int + e2e 覆盖。 |
| 9 | `prisma/SEED.md` | 需补 A3 行业 / 客户 / 客户组 / 字段种子说明(本次未改)。 |
| 10 | `AppModule` 的 worker | `pnpm dev` 下 worker 因 `REDIS_URL` 缺失退出,API/Web 正常;A0 遗留,非 A3 范围。 |
---
## 6 回滚方式
1. 代码:回滚本批 commit(client 模块、前端页面、shared schema、路由 / 导航、迁移文件、seed、ADR 与文档)。
2. 数据库:A3 为新增表,回滚可保留表(不影响 A1/A2);如需彻底回滚,执行 down SQL 删除 `customer_fields → customer_group_members → customer_groups → customers → industries`(先备份种子数据)。
3. 配置:`CUSTOMER_DIRECTORY` 改回 `EmptyCustomerDirectory` 即恢复 A2 空客户集行为(需同时回滚 client 模块 provider)。
4. 前端:回滚路由 / 导航与 `pages/system/{industries,customers,customer-groups,customer-fields}`,恢复「客户 / 行业树」旧菜单。
---
## 7 本地运行
```bash
pnpm check:dev
pnpm db:init
pnpm dev
# 浏览器 http://127.0.0.1:5173 → 管理中心 / 行业管理、客户管理、客户组、客户字段
```
A3 e2e(需先起 `pnpm dev`):
```bash
A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list
```
@@ -0,0 +1,165 @@
# 灵犀 A4 数据字典与系统配置 · 实施报告 V0.1
| 项 | 内容 |
| --- | --- |
| 实施日期 | 2026-11-04 |
| 批次 | A4(数据字典与系统配置) |
| 状态 | **已完成**(开发 + 全部门禁 + 浏览器 e2e 通过;未推送) |
| 依据 | 开发计划 V0.1 §6.5 / §7.3 / §12.1;PRD V1.6 §5.4.5 / R-01 / R-10 / R-26;技术架构 V0.4 §5.3 / §5.7 / §6.3.7;ADR-001 |
| 视觉基线 | A0「方向 A · 云白蓝」;用户选定 **方案 A · 主从分栏** |
| 迁移 | `prisma/migrations/20261104024511_a4_dict_system_configs/` |
| 前端路由 | `/system/dicts`、`/system/dicts/:type`、`/system/settings` |
---
## 1 范围与完成度
| 任务 | 内容 | 状态 |
| --- | --- | --- |
| A4-BE-01 | 字典模型与三层合并(`dict_types` / `dict_items`,树 / 词表一张表) | ✅ |
| A4-BE-02 | 系统配置与版本(`system_configs` + `system_config_versions`,阈值同源) | ✅ |
| A4-BE-03 | 命名规则服务(R-01 占位符 / 白名单 / 同名序号 / ≤100) | ✅ |
| A4-BE-04 | 字典快照(R-10,共享契约 + `toDictItemSnapshot`) | ✅ |
| A4-FE-01 | 字典管理(方案 A 主从分栏,树 / 词表双视图) | ✅ |
| A4-FE-02 | 系统配置页(6 组 / 命名预览 / 阈值二次确认 / 变更记录) | ✅ |
| A4-QA-01 | 合并 / 快照 / 同源用例 + 浏览器 e2e | ✅ |
> 权限口径:A2 定稿的 30 个权限点中字典为单个 `dict:manage`;系统配置为 `system:configure`。本轮未新增权限点,已同步技术架构 §6.3.7 与开发计划附录 B / C。
---
## 2 交付物
### 2.1 文档与决策
- 技术架构 §5.3 / §5.7 / §6.3.7 回写:新增 `system_config_versions`,字典权限统一 `dict:manage`。
- 开发计划 §7.3 / 附录 B / 附录 C 权限口径回写。
- `prisma/SEED.md` 更新:`system_configs` 26 项、A4 字典 13 类 / 96 条。
- 前端原型:`design/a4-ui-v1/`(三套候选 + 两轮独立评审 + 用户选定 A);实现截图:`design/a4-ui-v1/impl/`。
### 2.2 共享契约(`packages/shared`)
- `schemas/dict.ts`:字典类型 / 条目 / 三层来源 / 类型与条目编码不可改 / 查询与写接口 / `toDictItemSnapshot`。
- `schemas/config.ts`:系统配置响应、批量保存(`expectedVersion`)、变更记录、`effective` 读取。
- `config/system-configs.ts`:26 项配置定义(key / group / label / description / impact / type / default / 约束 / public)与 `validateSystemConfigValue`;前后端共用,R-26 唯一事实源。
- `naming.ts`:`NAMING_TOKEN_DEFINITIONS`、`validateNamingTemplate`、`resolveNamingTemplate`、`formatNamingDate`、`formatNamingSequence`;保留既有 `dedupeName` / `sanitizeFilename`。
- `schemas/client.ts`:`CUSTOMER_BASE_FIELD_HARD_LIMIT = 50` 作为 DTO 防御性硬上界;实际生效上限由服务端动态读取。
- 单测:`dict.spec.ts` / `config.spec.ts` / `naming.spec.ts` 覆盖合并口径、快照、配置校验、模板解析、同名序号。
### 2.3 数据库与种子
- 迁移 `20261104024511_a4_dict_system_configs`:
- `dict_types`(code unique;group / kind / industry_scoped / builtin / status / sort);
- `dict_items`(type_id + code + industry_id;parent_code;`dict_items_no_self_parent_check`);
- 部分唯一索引:通用层 `(type_id, code) WHERE industry_id IS NULL`、行业层 `(type_id, code, industry_id) WHERE industry_id IS NOT NULL`;
- `system_config_versions`(append-only:config_key / version / old_value / new_value / reason / changed_by / changed_at;`(config_key, version)` 唯一)。
- 种子:
- `system_configs` 26 项,元数据来自 `SYSTEM_CONFIG_DEFINITIONS`;重复执行不覆盖人工调整值 / 版本;
- 字典 13 类 / 96 条,行业作用域映射 A3 行业(电商 / 服饰鞋包 / 美妆个护 / 金融 / 信贷 等);内置类型不可删除、条目编码不可改。
### 2.4 后端(`apps/api`)
| 模块 | 接口 |
| --- | --- |
| `catalog` | `GET /dicts`、`GET /dicts/{code}`、`POST /dicts`、`PATCH /dicts/{code}`、`PATCH /dicts/{code}/status`、`DELETE /dicts/{code}`、`GET /dicts/{code}/items`、`POST /dicts/{code}/items`、`PATCH /dicts/{code}/items/{itemId}` |
| `platform` | `GET /system/configs`、`GET /system/configs/effective`、`PUT /system/configs`、`GET /system/configs/history` |
- 三层合并为纯函数 `dict-merge.ts`:通用 0 / 父行业 1 / 本行业 2,同 code 覆盖;返回 `source` / `overridden` / `layers` / `effectiveCount` / `disabledCount`。
- 编码不可改:schema `.strict()` 不接收 code / kind / industryScoped / parentCode / industryId 更新;冲突 40902;内置类型 / 有子条目删除 40001。
- 配置保存:事务 + `expectedVersion` 乐观锁(40901)+ 条件 `version+1` + `system_config_versions` + `@Audit('platform.system_config.update')` 前后值。
- 阈值同源:`roles` / `users` / `customers` / `customer-fields` 已改为 `SystemConfigService.getNumber('limit.*')`;shared 常量只作 fallback。
- 命名服务:`resolveScriptName` / `resolveImageName` / `resolveVideoName`,读 `naming.*` + `max_length` + `duplicate_start`;缺占位符 40001,非法 / 超长 / 同名过多 42201。
- 快照:`dict.mapper#toDictSnapshot` 委托 shared;单测证明改名后历史快照不变(R-10)。
- 审计:字典类型 / 条目写操作、系统配置保存均 @Audit + 结构化日志前后值(A5 落审计表)。
### 2.5 前端(`apps/web`,方案 A)
| 页面 | 路由 | 实现 |
| --- | --- | --- |
| 数据字典 | `/system/dicts` | 左 320 类型列表(4 分组折叠、搜索、状态筛选、统计)+ 右类型头 / 行业三层 chips / 树表切换 / 条目表 |
| 字典详情 | `/system/dicts/:type` | 同一主从骨架深链;默认 `script_direction` + 电商/服饰鞋包,7 项三层合并 |
| 系统配置 | `/system/settings` | 左 200 分组导航 + 6 组表单 + 命名占位符 / 实时预览 + 阈值二次确认 + 保存条 + 变更记录抽屉 |
- 组件:`DictTypeListPanel` / `DictDetailPanel` / `DictItemsView` / `DictItemSheet` / `DictTypeModal`;`SettingsPage` / `SettingsNav` / `SettingsGroupForm` / `ConfigRow` / `NamingAside` / `ConfigHistorySheet`。
- API / 状态:`api/dicts.ts` / `api/configs.ts`,shared Zod parse;`use-dicts` / `use-configs`,含 `useEffectiveSystemConfigsQuery` / `useCustomerFieldLimits`。
- 阈值同源:客户列表 / 详情 / 字段审批的 10 / 5 显示与禁用判断改为 effective config;后端最终校验不变。
- 路由 / 壳层:`/system/dicts`、`/system/dicts/:type`、`/system/settings` 已注册并按 `dict:manage` / `system:configure` 守卫;`AppLayout` 满高 flush;`main.tsx` 引入 `dicts.css` / `settings.css`。
- 侧边栏已更名「数据字典」(commit `32b44bd`)。
---
## 3 验证结果
| 门禁 | 命令 | 结果 |
| --- | --- | --- |
| 格式 | `pnpm format:check` | ✅ |
| Lint | `pnpm lint` | ✅ 0 warning |
| 类型 | `pnpm typecheck` | ✅ 10/10 tasks |
| 单测(shared) | `pnpm --filter @lingxi/shared test` | ✅ 13 files / 147 tests |
| 单测(api) | `pnpm --filter @lingxi/api test` | ✅ 18 files / 137 tests |
| 单测(web) | `pnpm --filter @lingxi/web test` | ✅ 29 files / 154 tests |
| 集成 | `pnpm --filter @lingxi/api test:int` | ✅ 7 files / 58 tests(新增 catalog 10 + config 9) |
| 构建 | `pnpm build` | ✅ 8/8 tasks |
| OpenAPI | `pnpm openapi:export` + `pnpm openapi:diff` | ✅ 无破坏性变更 |
| 浏览器 e2e | `A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts` | ✅ 7 passed(约 1.1m) |
| 本地 CI | `pnpm ci:local` | ✅ 262.3s,全部门禁通过(build + test:int + openapi + smoke e2e) |
浏览器人工验收(真实 API + PG + seed):
- `/system/dicts` 默认脚本方向 / 电商·服饰鞋包,显示三层 chips(通用 5 + 父行业 2 + 本行业 1 = 生效 7),种草短视频「父行业 / 已覆盖」、直播切片「父行业」、穿搭教程「本行业」;
- 树 / 词表切换、非行业类型「全平台通用」、条目新增 / 编辑(编码只读)/ 停用 / 启用、类型新建 / 编辑(编码只读);
- `/system/settings` 6 组 / 26 项、命名占位符与实时预览、阈值旧→新二次确认、保存后版本 +1、变更记录(版本 / 前后值 / 操作人 / 时间 / 原因);
- 1440×900 与 1024×900 无横向溢出;截图:[impl](../../design/a4-ui-v1/impl/)。
---
## 4 关键口径与实现说明
1. **一张表同时表达树与词表**:`dict_items.parent_code` 表达二级树;不另建行业二级表(ADR-002 后行业树由 `industries` 承载)。
2. **三层合并**:查询取通用 + 父行业 + 本行业;同 code 以更具体层覆盖,返回 source / overridden;非行业作用域类型强制只读通用层。
3. **编码不可改**:类型 `code` 与条目 `code` 创建后不可改;更新 DTO `.strict()` 不接收;前端编辑态只读。
4. **配置唯一来源**:`SYSTEM_CONFIG_DEFINITIONS` 为元数据 / 默认值 / 校验唯一事实源;`system_configs` 只存值 / 版本;`/system/configs/effective` 供前端读取 public 项(上限),前端不再硬编码 10 / 5。
5. **版本与变更**:保存走事务 + 乐观锁;每次变更写 `system_config_versions`;审计日志记录前后值,A5 落审计表。
6. **命名规则**:shared 纯函数被前端预览与后端 `NamingService` 共用;字符白名单、占位符、≤100、同名序号一致。
7. **快照**:`toDictItemSnapshot` 生成填写时 typeCode / code / name / parentCode / industryId / capturedAt;业务记录在 B0 / M1 引用时保存,A4 只提供契约与工具。
---
## 5 已知差异与遗留
| 编号 | 项 | 说明 / 建议 |
| --- | --- | --- |
| 1 | `refCount` 固定 0 | A4 尚无引用表;B0 / B1 资产与行业包落地后接入真实引用计数 |
| 2 | 配置项 26 项(原型 24 项) | 含 A0 已有 2 项 AI 成本比例配置;A7 会提供专门 AI 配置页,A4 暂列在「业务阈值」 |
| 3 | 审计落库 | A4 只写结构化 `@Audit` 日志;审计表 / 检索 / 导出按 A5 |
| 4 | 只读态不可达 | 路由守卫为 `dict:manage` / `system:configure`,无独立 view 权限;页面保留 canWrite=false 防御分支,后续如拆分 view/manage 权限可直接生效 |
| 5 | 详情面包屑 | `/system/dicts/:type` 面包屑仍为「管理中心 / 数据字典」,未展示类型名;`breadcrumbs.ts` 无类型参数,后续统一处理(与 A3 客户详情同源问题) |
| 6 | 字典类型分页 | 当前 13 类,前端一次拉 100 条后本地筛选;类型数 >100 时需补游标分页 UI |
| 7 | `updatedBy` 存储 | 字典表存操作人姓名;`system_configs.updated_by` 存 ULID,列表 / 历史批量 join 返回姓名 |
| 8 | DTO 硬上界 | 客户基础字段 schema 硬上界 50(防御性);若配置 >50 仍会被 Zod 拦截,需同步调整 shared 硬上界 |
| 9 | e2e | `A4_E2E=1` 用例已落地并 7/7 通过;默认 skip,不影响 smoke / CI;`e2e/README.md` 待补登记一行 |
---
## 6 回滚方式
1. 代码:回滚 A4 提交(catalog / platform 配置与命名 / 前端字典与配置页 / shared 契约 / 路由 / 样式 / 迁移 / seed / 文档)。
2. 数据库:A4 为新增表,回滚可保留表;彻底回滚删除顺序:`system_config_versions → dict_items → dict_types`;`system_configs` 为 A0 既有表,保留。
3. 配置:删除 A4 新增 `system_configs` 行或恢复默认值;`system_config_versions` 清空即可。
4. 前端:回滚路由 / 导航 / 页面 / 样式;`/system/dicts` / `/system/settings` 将回退到 Workspace 占位页(导航保留)。
---
## 7 本地运行
```bash
pnpm check:dev
pnpm db:init
pnpm dev
# 浏览器 http://127.0.0.1:5173 → 管理中心 / 数据字典、系统配置
```
A4 e2e(需先起 `pnpm dev`):
```bash
A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts --reporter=list
```
@@ -10,7 +10,7 @@
| 文档名称 | 灵犀 · 信息流素材生产平台 项目开发计划 | | 文档名称 | 灵犀 · 信息流素材生产平台 项目开发计划 |
| 版本 | V0.1(初稿,待评审) | | 版本 | V0.1(初稿,待评审) |
| 日期 | 2026-11-02 | | 日期 | 2026-11-02 |
| 状态 | **执行中**:A0 工程骨架与契约基线已完成(2026-11-02);A1 身份认证与用户管理已完成(2026-11-03);A2 角色权限与数据范围已完成(2026-11-03);下一步 A3 | | 状态 | **执行中**:A0 工程骨架与契约基线已完成(2026-11-02);A1 身份认证与用户管理已完成(2026-11-03);A2 角色权限与数据范围已完成(2026-11-03);A3 客户与行业目录已完成(2026-11-04 最终验收);A4 数据字典与系统配置已完成(2026-11-04);下一步 A5 |
| 需求基线 | PRD V1.6([灵犀-信息流广告素材一站式生产平台PRD.md](../PRD/灵犀-信息流广告素材一站式生产平台PRD.md)) | | 需求基线 | PRD V1.6([灵犀-信息流广告素材一站式生产平台PRD.md](../PRD/灵犀-信息流广告素材一站式生产平台PRD.md)) |
| 技术基线 | 技术架构 V0.4([灵犀_信息流素材生产平台_技术架构_V0.1.md](../技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md))+ `AGENTS.md` V1.0 + `docs/工程规范/` V1.0 | | 技术基线 | 技术架构 V0.4([灵犀_信息流素材生产平台_技术架构_V0.1.md](../技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md))+ `AGENTS.md` V1.0 + `docs/工程规范/` V1.0 |
| 覆盖范围 | **M0 任务级;M1–M4 迭代级 + 任务组级;M5 预研边界(不排期)** | | 覆盖范围 | **M0 任务级;M1–M4 迭代级 + 任务组级;M5 预研边界(不排期)** |
@@ -26,6 +26,7 @@
| V0.1(进度回写 A1) | 2026-11-03 | A1 身份认证与用户管理完成:批次总览更新 A1 状态、A1 WBS 标注完成、回写执行证据与遗留;不改变原计划范围与验收口径 | | V0.1(进度回写 A1) | 2026-11-03 | A1 身份认证与用户管理完成:批次总览更新 A1 状态、A1 WBS 标注完成、回写执行证据与遗留;不改变原计划范围与验收口径 |
| V0.1(路由规范回写) | 2026-11-03 | 「平台」更名「管理中心」,前端路由统一为 `/<模块>/<功能>`(A1 用户页改为 `/system/users`);同步附录 C 页面清单与前端目录约定,不改变功能范围与验收口径 | | V0.1(路由规范回写) | 2026-11-03 | 「平台」更名「管理中心」,前端路由统一为 `/<模块>/<功能>`(A1 用户页改为 `/system/users`);同步附录 C 页面清单与前端目录约定,不改变功能范围与验收口径 |
| V0.1(A1 部门管理补强) | 2026-11-03 | 补 `/system/departments` 部门管理:WBS 增 A1-BE-05 / A1-FE-04,补页面与接口清单;只停用不删除、有成员/启用子部门禁止停用;不改变其它批次范围 | | V0.1(A1 部门管理补强) | 2026-11-03 | 补 `/system/departments` 部门管理:WBS 增 A1-BE-05 / A1-FE-04,补页面与接口清单;只停用不删除、有成员/启用子部门禁止停用;不改变其它批次范围 |
| V0.1(进度回写 A4) | 2026-11-04 | A4 数据字典与系统配置完成:批次总览更新 A4 状态、A4 WBS 标注完成、回写门禁与 e2e;权限按 A2 定稿统一 `dict:manage`,新增 `system_config_versions`;不改变原计划范围与验收口径 |
### 0.2 引用约定 ### 0.2 引用约定
@@ -381,8 +382,8 @@ flowchart LR
| **A0** | ✅ 已完成 | 工程骨架与契约基线 | — | 6 | 4–5 日 | W0 | lint / typecheck / test / CI 全绿;三依赖可起;api / web / worker 可启动;OpenAPI 可导出 | | **A0** | ✅ 已完成 | 工程骨架与契约基线 | — | 6 | 4–5 日 | W0 | lint / typecheck / test / CI 全绿;三依赖可起;api / web / worker 可启动;OpenAPI 可导出 |
| **A1** | ✅ 已完成 | 身份认证与用户管理 | A0 | 6 | 4–5 日 | W1 | 登录 / 刷新 / 登出;用户生命周期;路由权限;权限三态用例 | | **A1** | ✅ 已完成 | 身份认证与用户管理 | A0 | 6 | 4–5 日 | W1 | 登录 / 刷新 / 登出;用户生命周期;路由权限;权限三态用例 |
| **A2** | ✅ 已完成 | 角色权限与数据范围 | A1 | 5.5 | 4–5 日 | W2 | 角色可配;SoD 命中拒绝;特殊权限默认关闭;范围过滤生效 | | **A2** | ✅ 已完成 | 角色权限与数据范围 | A1 | 5.5 | 4–5 日 | W2 | 角色可配;SoD 命中拒绝;特殊权限默认关闭;范围过滤生效 |
| **A3** | 未开始 | 客户与行业目录 | A1 / A2 | 7 | 4–5 日 | W2 | 行业 / 客户 / 客户组可用;唯一性;状态只读;字段审批 | | **A3** | ✅ 已完成 | 客户与行业目录 | A1 / A2 | 7 | 4–5 日 | W2 | 行业 / 客户 / 客户组可用;唯一性;状态只读;字段审批 |
| **A4** | 未开始 | 数据字典与系统配置 | A3 | 4 | 3–4 日 | W3 | 三层合并;编码不可改;阈值同源;配置版本 + 审计 | | **A4** | ✅ 已完成 | 数据字典与系统配置 | A3 | 4 | 3–4 日 | W3 | 三层合并;编码不可改;阈值同源;配置版本 + 审计 |
| **A5** | 未开始 | 审计与日志 | A1 / A4 | 4 | 3–4 日 | W2–W3 | 关键操作全留痕;不可篡改;导出自身审计 | | **A5** | 未开始 | 审计与日志 | A1 / A4 | 4 | 3–4 日 | W2–W3 | 关键操作全留痕;不可篡改;导出自身审计 |
| **A6** | 未开始 | 任务中心与队列骨架 | A0 / A1 | 6 | 4–5 日 | W2–W3 | 提交 → SSE → 完成 / 失败;幂等;取消 / 重试 | | **A6** | 未开始 | 任务中心与队列骨架 | A0 / A1 | 6 | 4–5 日 | W2–W3 | 提交 → SSE → 完成 / 失败;幂等;取消 / 重试 |
| **A7** | 未开始 | AI 配置与调用底座 | A0 / A1 / A5 | 6 | 4–5 日 | W3 | 四层配置;Mock 调用;密钥不泄露;账本可查 | | **A7** | 未开始 | AI 配置与调用底座 | A0 / A1 / A5 | 6 | 4–5 日 | W3 | 四层配置;Mock 调用;密钥不泄露;账本可查 |
@@ -397,7 +398,7 @@ flowchart LR
> >
> **A1 进度(2026-11-03)**:✅ 已完成。10/10 项 WBS 通过;`format:check / lint / typecheck / test` 全绿(shared 121 / api 90 / web 120),`test:int` 33 项(真实 PG / Redis),A1-QA-01 浏览器 e2e 6/6;登录 → 首次登录强改 → 用户列表 / 新建 / 详情 / 停用启用 / 角色调整 / 客户转交占位全链路实测。**补强**:`/system/departments` 部门管理(两级组织树、负责人/排序、只停用不删除、停用拦截)已落地并联调。实现记录、口径与遗留见 [A1 实施报告](./灵犀_A1身份认证与用户管理_实施报告_V0.1.md)。已知遗留:客户转交真实接口(A3)、审计落库(A5)。 > **A1 进度(2026-11-03)**:✅ 已完成。10/10 项 WBS 通过;`format:check / lint / typecheck / test` 全绿(shared 121 / api 90 / web 120),`test:int` 33 项(真实 PG / Redis),A1-QA-01 浏览器 e2e 6/6;登录 → 首次登录强改 → 用户列表 / 新建 / 详情 / 停用启用 / 角色调整 / 客户转交占位全链路实测。**补强**:`/system/departments` 部门管理(两级组织树、负责人/排序、只停用不删除、停用拦截)已落地并联调。实现记录、口径与遗留见 [A1 实施报告](./灵犀_A1身份认证与用户管理_实施报告_V0.1.md)。已知遗留:客户转交真实接口(A3)、审计落库(A5)。
> >
> **A2 进度(2026-11-03)**:✅ 已完成。8/8 项 WBS 通过;H1 权限门已确认并记录 [ADR-001](../adr/ADR-001-权限点目录与六类数据范围.md);UI 门三套原型独立评审 A 8 / B 8 / C 7,用户选定 **B 主从分栏**。`format:check / lint / typecheck / test` 全绿(shared 119 / api 85 / web 102),`test:int` 27 项(真实 PG / Redis),A2-QA-01 浏览器 e2e 1/1(复跑通过);角色可配、SoD 40304、特殊权限默认关闭、六类范围解析与 `ScopeContext` 全链路、系统角色保护、变更审计与 outbox 均实测。实现记录、口径与遗留见 [A2 实施报告](./灵犀_A2角色权限与数据范围_实施报告_V0.1.md)。已知遗留:A3 前客户目录为空集、A5 审计落库、DP-14 两项特殊权限、导航模块级权限口径。 > **A2 进度(2026-11-03)**:✅ 已完成。8/8 项 WBS 通过;H1 权限门已确认并记录 [ADR-001](../adr/ADR-001-权限点目录与六类数据范围.md);UI 门三套原型独立评审 A 8 / B 8 / C 7,用户选定 **B 主从分栏**。最终验收(2026-11-03):`format:check / lint / typecheck / test` 全绿(shared 121 / api 90 / web 120,共 331),`test:int` 33 项(真实 PG / Redis),A2-QA-01 浏览器 e2e 1/1(22.1s,最终版 UI 下复跑通过);角色可配、SoD 40304、特殊权限默认关闭、六类范围解析与 `ScopeContext` 全链路、系统角色保护、变更审计与 outbox 均实测。收口项:侧栏/页面统一为「角色管理」、列表页头主操作右对齐、角色详情「编辑角色」移入页头、移除重复的权限点目录抽屉。实现记录、口径与遗留见 [A2 实施报告](./灵犀_A2角色权限与数据范围_实施报告_V0.1.md)。已知遗留:A3 前客户目录为空集、A5 审计落库、DP-14 两项特殊权限、导航模块级权限口径。
### 5.3 批次依赖图 ### 5.3 批次依赖图
@@ -515,16 +516,16 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配
| 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD | 状态 | | 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD | 状态 |
| --- | --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- |
| A0-INF-01 | Monorepo 与工具链 | INF | pnpm workspace + Turborepo;TS strict;ESLint 9 flat + Prettier + commitlint + lint-staged;`.nvmrc` 24.15.0、`engines`、`packageManager`、`only-allow pnpm`;`apps/api`、`apps/worker`、`apps/web`、`packages/*`、`prisma/`、`infra/` 目录骨架 | — | `pnpm install` 成功;`pnpm lint` / `pnpm typecheck` 可执行;目录与 AGENTS §5 一致;npm / yarn 被拒绝 | 1.0 ✅ | | A0-INF-01 | Monorepo 与工具链 | INF | pnpm workspace + Turborepo;TS strict;ESLint 9 flat + Prettier + commitlint + lint-staged;`.nvmrc` 24.15.0、`engines`、`packageManager`、`only-allow pnpm`;`apps/api`、`apps/worker`、`apps/web`、`packages/*`、`prisma/`、`infra/` 目录骨架 | — | `pnpm install` 成功;`pnpm lint` / `pnpm typecheck` 可执行;目录与 AGENTS §5 一致;npm / yarn 被拒绝 | 1.0 | ✅ |
| A0-INF-02 | 本地依赖与迁移种子 | INF | `docker-compose.dev.yaml`(PG18 / Redis7.4 / MinIO)、健康检查;`.env.example`;Prisma 初始化、首版迁移、种子脚本(超管、示例角色、核心字典、电商行业包占位) | A0-INF-01 | `pnpm compose:up` 三依赖健康;`pnpm db:migrate` 正向成功;种子可重复执行不重复插入 | 0.5 ✅ | | A0-INF-02 | 本地依赖与迁移种子 | INF | `docker-compose.dev.yaml`(PG18 / Redis7.4 / MinIO)、健康检查;`.env.example`;Prisma 初始化、首版迁移、种子脚本(超管、示例角色、核心字典、电商行业包占位) | A0-INF-01 | `pnpm compose:up` 三依赖健康;`pnpm db:migrate` 正向成功;种子可重复执行不重复插入 | 0.5 | ✅ |
| A0-INF-03 | CI 与质量门禁 | INF | 流水线:`install --frozen-lockfile` → lint → typecheck → test → build → OpenAPI 产物;依赖缓存;失败阻断合并 | A0-INF-01 | 首条 PR 全绿;故意写 `any` / 类型错误 / 未格式化能被拦截 | 0.5 ✅ | | A0-INF-03 | CI 与质量门禁 | INF | 流水线:`install --frozen-lockfile` → lint → typecheck → test → build → OpenAPI 产物;依赖缓存;失败阻断合并 | A0-INF-01 | 首条 PR 全绿;故意写 `any` / 类型错误 / 未格式化能被拦截 | 0.5 | ✅ |
| A0-BE-01 | API 骨架 | BE | NestJS 11 + Fastify;Pino + trace_id;统一错误过滤器(`{code,message,details,trace_id}`);`/health`;Swagger / OpenAPI 生成;Zod 管道;CORS 与安全头 | A0-INF-01 | `GET /health` 返回 200;错误结构统一;OpenAPI 可导出且含错误码 | 1.0 ✅ | | A0-BE-01 | API 骨架 | BE | NestJS 11 + Fastify;Pino + trace_id;统一错误过滤器(`{code,message,details,trace_id}`);`/health`;Swagger / OpenAPI 生成;Zod 管道;CORS 与安全头 | A0-INF-01 | `GET /health` 返回 200;错误结构统一;OpenAPI 可导出且含错误码 | 1.0 | ✅ |
| A0-BE-02 | Prisma 基线 | BE | Prisma client 封装;迁移基线;`outbox_events` / `idempotency_keys` / `system_configs` 骨架;ULID 主键、软删、时间字段约定 | A0-INF-02 | 迁移可升可回滚;字段命名与工程规范 05 一致;无跨模块表 | 0.5 ✅ | | A0-BE-02 | Prisma 基线 | BE | Prisma client 封装;迁移基线;`outbox_events` / `idempotency_keys` / `system_configs` 骨架;ULID 主键、软删、时间字段约定 | A0-INF-02 | 迁移可升可回滚;字段命名与工程规范 05 一致;无跨模块表 | 0.5 | ✅ |
| A0-SH-01 | shared 包基线 | SH | 错误码枚举(400xx–500xx)、权限点常量骨架与命名规范(`模块:动作`)、ULID、游标分页、时间与命名工具、通用 Zod schema | A0-INF-01 | 前后端同时引用成功;shared 不依赖任何业务包 | 0.5 ✅ | | A0-SH-01 | shared 包基线 | SH | 错误码枚举(400xx–500xx)、权限点常量骨架与命名规范(`模块:动作`)、ULID、游标分页、时间与命名工具、通用 Zod schema | A0-INF-01 | 前后端同时引用成功;shared 不依赖任何业务包 | 0.5 | ✅ |
| A0-FE-01 | Web 骨架 | FE | Vite 6 + React 19 + Router 7 + TanStack Query 5 + Zustand 5;`@douyinfe/semi-ui-19` + `react19-adapter`;路径别名;请求层(token 注入、401 刷新、统一错误提示) | A0-INF-01 | 页面可启动;路由可切换;请求层有单测 | 0.5 ✅ | | A0-FE-01 | Web 骨架 | FE | Vite 6 + React 19 + Router 7 + TanStack Query 5 + Zustand 5;`@douyinfe/semi-ui-19` + `react19-adapter`;路径别名;请求层(token 注入、401 刷新、统一错误提示) | A0-INF-01 | 页面可启动;路由可切换;请求层有单测 | 0.5 | ✅ |
| A0-FE-02 | UI 包基线 | FE | Semi 二次封装(表格 / 查询表单 / 弹窗 / 空态 / 错误态 / 加载骨架);`icons.ts` 统一出口(Remix Icon 语义名);locale;主题 token | A0-FE-01 | 业务代码不直接引图标库、不内联 SVG;ESLint 可拦截违规引用 | 0.5 ✅ | | A0-FE-02 | UI 包基线 | FE | Semi 二次封装(表格 / 查询表单 / 弹窗 / 空态 / 错误态 / 加载骨架);`icons.ts` 统一出口(Remix Icon 语义名);locale;主题 token | A0-FE-01 | 业务代码不直接引图标库、不内联 SVG;ESLint 可拦截违规引用 | 0.5 | ✅ |
| A0-FE-03 | 布局与登录壳 | FE | 侧边导航(按模块分组)、顶栏(用户 / 任务角标占位)、面包屑、401 / 403 / 404 页、登录页静态壳 | A0-FE-01 | 布局可打开;403 页可演示;导航配置集中管理、可按权限过滤 | 0.5 ✅ | | A0-FE-03 | 布局与登录壳 | FE | 侧边导航(按模块分组)、顶栏(用户 / 任务角标占位)、面包屑、401 / 403 / 404 页、登录页静态壳 | A0-FE-01 | 布局可打开;403 页可演示;导航配置集中管理、可按权限过滤 | 0.5 | ✅ |
| A0-QA-01 | 冒烟与报告 | QA | 健康检查 / 登录页 / OpenAPI 冒烟用例;覆盖率报告;CI 产物归档 | A0-INF-03 | CI 产出测试报告;3 条冒烟通过 | 0.5 ✅ | | A0-QA-01 | 冒烟与报告 | QA | 健康检查 / 登录页 / OpenAPI 冒烟用例;覆盖率报告;CI 产物归档 | A0-INF-03 | CI 产出测试报告;3 条冒烟通过 | 0.5 | ✅ |
**本批门禁**:`pnpm format:check && pnpm lint && pnpm typecheck && pnpm test` 全绿;`pnpm ci:local` 全绿(含 build、test:int、openapi:export / diff、e2e);api / web / worker 可启动;OpenAPI 快照入库;CI 流程已配置,Gitea 暂无 runner 时以本地 CI 为准。 **本批门禁**:`pnpm format:check && pnpm lint && pnpm typecheck && pnpm test` 全绿;`pnpm ci:local` 全绿(含 build、test:int、openapi:export / diff、e2e);api / web / worker 可启动;OpenAPI 快照入库;CI 流程已配置,Gitea 暂无 runner 时以本地 CI 为准。
@@ -566,45 +567,53 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配
| A2-FE-02 | 复制 / 模板 / 停用 | FE | 从角色或示例模板复制;停用倒计时与在线用户提示;角色详情展示绑定用户 | A2-FE-01 | 复制后可独立编辑;停用后在线用户下次请求失效 | 1.0 | | A2-FE-02 | 复制 / 模板 / 停用 | FE | 从角色或示例模板复制;停用倒计时与在线用户提示;角色详情展示绑定用户 | A2-FE-01 | 复制后可独立编辑;停用后在线用户下次请求失效 | 1.0 |
| A2-QA-01 | 权限三态与 SoD 用例 | QA | 每个角色相关端点 3 类用例(有 / 无 / 范围外)+ 3 对 SoD 用例;权限变更即时生效 | A2-BE-05 | 用例全部通过;越权访问有审计记录 | 0.5 | | A2-QA-01 | 权限三态与 SoD 用例 | QA | 每个角色相关端点 3 类用例(有 / 无 / 范围外)+ 3 对 SoD 用例;权限变更即时生效 | A2-BE-05 | 用例全部通过;越权访问有审计记录 | 0.5 |
> **A2 完成情况(2026-11-03)**:8/8 项 WBS 完成。H1 权限门确认:30 权限点 / 10 模块(保留 `read/write` 只补缺)、特殊权限五项 6 编码、SoD 三对、六类数据范围全链路、超管隐式全集、A2 审计先结构化日志;见 [ADR-001](../adr/ADR-001-权限点目录与六类数据范围.md)。UI 三套原型评审 A 8 / B 8 / C 7,用户选定 B 主从分栏。`test:int` 27 项、A2 e2e 1/1,门禁全绿;实现与遗留见 [A2 实施报告](./灵犀_A2角色权限与数据范围_实施报告_V0.1.md)。 > **A2 完成情况(2026-11-03,最终验收)**:8/8 项 WBS 完成;A2-BE-01~05、A2-FE-01/02、A2-QA-01 全部验收通过,计划状态置为 ✅ 已完成。H1 权限门确认:30 权限点 / 10 模块(保留 `read/write` 只补缺)、特殊权限五项 6 编码、SoD 三对、六类数据范围全链路、超管隐式全集、A2 审计先结构化日志;见 [ADR-001](../adr/ADR-001-权限点目录与六类数据范围.md)。UI 三套原型评审 A 8 / B 8 / C 7,用户选定 B 主从分栏。最终门禁:`test` 331 项全绿(shared 121 / api 90 / web 120),`test:int` 33 项,A2 e2e 1/1(22.1s);实现与遗留见 [A2 实施报告](./灵犀_A2角色权限与数据范围_实施报告_V0.1.md)。
**本批门禁**:角色可配、范围可解析、SoD 可拦截;权限三态用例全绿;特殊权限默认关闭;人类完成 **H1 权限门**(§3.4)。 **本批门禁**:角色可配、范围可解析、SoD 可拦截;权限三态用例全绿;特殊权限默认关闭;人类完成 **H1 权限门**(§3.4)。
### 6.4 A3 客户与行业目录 ### 6.4 A3 客户与行业目录
> **执行状态:✅ 已完成(2026-11-03 开发完成,2026-11-04 UI 对齐 / 本地 CI 最终验收)**。以下 9 项 WBS 全部交付并实测;实现记录、验收证据与遗留见 [A3 实施报告](./灵犀_A3客户与行业目录_实施报告_V0.1.md)。
**目标**:行业 / 客户 / 客户组 / 客户字段四件事立住,作为全部资产的根节点。**对应**:P-04 / P-05 / P-17、PRD §5.4.2 / §5.4.9;`R-20` / `R-04`;架构 §6.3.2。 **目标**:行业 / 客户 / 客户组 / 客户字段四件事立住,作为全部资产的根节点。**对应**:P-04 / P-05 / P-17、PRD §5.4.2 / §5.4.9;`R-20` / `R-04`;架构 §6.3.2。
| 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD | | 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD | 状态 |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- |
| A3-BE-01 | 行业目录 | BE | `industries`;名称全局唯一;删除前校验无客户 / 无资产,否则只能停用;行业不可跨行业移动校验 | A2-BE-04 | 重名 40902;有客户时删除被拒;停用后新建客户不可选 | 0.5 | | A3-BE-01 | 行业目录(两级) | BE | `industries`;一级 → 二级最多两级;一级名称全局唯一、二级同父唯一;客户只挂末级行业;删除前校验无子行业 / 客户 / 资产,否则只能停用;有启用子行业时禁止停用 | A2-BE-04 | 重名 40902;有子行业 / 客户时删除被拒;有启用子行业停用被拒;停用后新建客户不可选 | 0.5 | ✅ |
| A3-BE-02 | 客户档案与状态机 | BE | `customers`;简称必填、`(industry_id, name)` 唯一;服务状态(在服务 / 暂停 / 结束);负责人 / 对接人;交付偏好;套用行业资产包入口 | A3-BE-01 | 简称缺失 42201;暂停只读、不能发起新任务;结束全只读 | 1.0 | | A3-BE-02 | 客户档案与状态机 | BE | `customers`;简称必填、`(industry_id, name)` 唯一;服务状态(在服务 / 暂停 / 结束);负责人 / 对接人;交付偏好;套用行业资产包入口 | A3-BE-01 | 简称缺失 42201;暂停只读、不能发起新任务;结束全只读 | 1.0 | ✅ |
| A3-BE-03 | 客户组 | BE | `customer_groups` + `customer_group_members`;客户组作为授权与数据范围最小单位 | A3-BE-02 | 成员增删幂等;数据范围引用客户组后可见集合正确 | 0.5 | | A3-BE-03 | 客户组 | BE | `customer_groups` + `customer_group_members`;客户组作为授权与数据范围最小单位 | A3-BE-02 | 成员增删幂等;数据范围引用客户组后可见集合正确 | 0.5 | ✅ |
| A3-BE-04 | 客户级字段申请与审批 | BE | `customer_fields`;申请(定义 + 用途 + 是否进入生成)→ 审批 → 生效;每客户 ≤ 5 个进入生成字段、基础字段 ≤ 10 | A3-BE-02 | 超限 40001(字段校验失败);未审批字段不进入生成;审批与变更进审计 | 1.0 | | A3-BE-04 | 客户级字段申请与审批 | BE | `customer_fields`;申请(定义 + 用途 + 是否进入生成)→ 审批 → 生效;每客户 ≤ 5 个进入生成字段、基础字段 ≤ 10 | A3-BE-02 | 超限 40001(字段校验失败);未审批字段不进入生成;审批与变更进审计 | 1.0 | ✅ |
| A3-BE-05 | 客户上下文与归属校验 | BE | 客户上下文中间件;资产 / 任务归属校验函数;跨客户访问统一 404(不暴露存在性) | A2-BE-04、A3-BE-02 | 跨客户访问返回 40401;所有查询带 `customer_id` | 0.5 | | A3-BE-05 | 客户上下文与归属校验 | BE | 客户上下文中间件;资产 / 任务归属校验函数;跨客户访问统一 404(不暴露存在性) | A2-BE-04、A3-BE-02 | 跨客户访问返回 40401;所有查询带 `customer_id` | 0.5 | ✅ |
| A3-FE-01 | 行业管理页 | FE | 行业列表 / 新建 / 编辑 / 停用;删除前引用提示 | A0-FE-02 | 重名与有客户时的错误可读;停用状态清晰 | 0.5 | | A3-FE-01 | 行业管理页 | FE | 行业列表 / 新建 / 编辑 / 停用;删除前引用提示 | A0-FE-02 | 重名与有客户时的错误可读;停用状态清晰 | 0.5 | ✅ |
| A3-FE-02 | 客户列表与档案 | FE | 列表(行业 / 状态 / 负责人筛选)、详情、分步新建(行业 → 基础信息 → 交付偏好 → 资产包)、简称必填校验 | A3-FE-01 | 分步表单可回退;暂停 / 结束状态只读并提示 | 1.5 | | A3-FE-02 | 客户列表与档案 | FE | 列表(行业 / 状态 / 负责人筛选)、详情、分步新建(行业 → 基础信息 → 交付偏好 → 资产包)、简称必填校验 | A3-FE-01 | 分步表单可回退;暂停 / 结束状态只读并提示 | 1.5 | ✅ |
| A3-FE-03 | 客户组管理 | FE | 客户组列表、成员批量添加 / 移除、按客户组授权提示 | A3-FE-02 | 批量操作有确认与结果反馈 | 0.5 | | A3-FE-03 | 客户组管理 | FE | 客户组列表、成员批量添加 / 移除、按客户组授权提示 | A3-FE-02 | 批量操作有确认与结果反馈 | 0.5 | ✅ |
| A3-FE-04 | 字段申请与审批 | FE | 字段定义表单、用途说明、是否进入生成、审批列表、超限提示 | A3-FE-02 | 申请 / 审批状态可追踪;超限给出明确文案 | 0.5 | | A3-FE-04 | 字段申请与审批 | FE | 字段定义表单、用途说明、是否进入生成、审批列表、超限提示 | A3-FE-02 | 申请 / 审批状态可追踪;超限给出明确文案 | 0.5 | ✅ |
| A3-QA-01 | 唯一性 / 状态 / 范围用例 | QA | 行业与客户唯一性、状态只读、跨客户越权、字段上限 | A3-BE-05 | 用例全绿;越权访问有审计 | 0.5 | | A3-QA-01 | 唯一性 / 状态 / 范围用例 | QA | 行业与客户唯一性、状态只读、跨客户越权、字段上限 | A3-BE-05 | 用例全绿;越权访问有审计 | 0.5 | ✅ |
**本批门禁**:可建行业 → 客户 → 客户组;唯一性与状态机生效;客户字段审批可用;所有查询带客户维度。 **本批门禁**:可建行业 → 客户 → 客户组;唯一性与状态机生效;客户字段审批可用;所有查询带客户维度。
> **A3 完成情况(2026-11-03 开发完成 / 2026-11-04 最终验收)**:9/9 项 WBS 全部 ✅。两级行业树(ADR-002)、客户档案与状态机、客户组、客户级字段双层与审批、客户转交、真实 `CustomerDirectory`、客户上下文与跨客户 404 全部落地;前端按用户选定混搭实现(行业 B / 客户列表 A / 客户详情 A / 新建 B / 客户组 B / 字段 A),侧栏更名「客户管理」「行业管理」并新增「客户组」。门禁:`format:check / lint / typecheck / test` 全绿(shared 131 / api 90 / web 125,共 346),`test:int` 39 项(5 文件,含 A3 client 6 条),A3 浏览器 e2e 1/1(11.6s),`build` 8/8;实现记录、截图与遗留见 [A3 实施报告](./灵犀_A3客户与行业目录_实施报告_V0.1.md)。已知遗留:B0 行业资产包套用、A5 审计落库、A4 字段上限配置、客户详情面包屑末级名称。
### 6.5 A4 数据字典与系统配置 ### 6.5 A4 数据字典与系统配置
> **执行状态:✅ 已完成(2026-11-04 开发完成 + 全部门禁 + A4 浏览器 e2e 通过)**。以下 7 项 WBS 全部交付并实测;实现记录、验收证据与遗留见 [A4 实施报告](./灵犀_A4数据字典与系统配置_实施报告_V0.1.md)。
**目标**:字典与配置成为唯一来源,杜绝前后端硬编码。**对应**:P-06、P-13(部分)、PRD §5.4.5;`R-10` / `R-01` / `R-26`;架构 §5.3 / §5.7。 **目标**:字典与配置成为唯一来源,杜绝前后端硬编码。**对应**:P-06、P-13(部分)、PRD §5.4.5;`R-10` / `R-01` / `R-26`;架构 §5.3 / §5.7。
| 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD | | 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD | 状态 |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- |
| A4-BE-01 | 字典模型与三层合并 | BE | `dict_types` / `dict_items` 通用枚举表;类型 + 编码 + 名称 + 父编码 + 行业 + 排序 + 启用;编码不可改、名称可改;查询按"本行业 + 父行业 + 通用"三层合并 | A3-BE-01 | 一张表同时表达二级树与词表;编码修改被拒;三层合并结果有单测 | 1.0 | | A4-BE-01 | 字典模型与三层合并 | BE | `dict_types` / `dict_items` 通用枚举表;类型 + 编码 + 名称 + 父编码 + 行业 + 排序 + 启用;编码不可改、名称可改;查询按"本行业 + 父行业 + 通用"三层合并 | A3-BE-01 | 一张表同时表达二级树与词表;编码修改被拒;三层合并结果有单测 | 1.0 | ✅ |
| A4-BE-02 | 系统配置与版本 | BE | `system_configs` 键值 + 版本 + 变更审计;命名规则、导出设置、通知开关、阈值(爆量 / 超时 / 上限)单一来源;前后端同源读取 | A0-BE-02 | 配置变更留前后值;阈值只有一处定义;前端上限读取配置接口 | 0.5 | | A4-BE-02 | 系统配置与版本 | BE | `system_configs` 键值 + 版本 + 变更审计;命名规则、导出设置、通知开关、阈值(爆量 / 超时 / 上限)单一来源;前后端同源读取 | A0-BE-02 | 配置变更留前后值;阈值只有一处定义;前端上限读取配置接口 | 0.5 | ✅ |
| A4-BE-03 | 命名规则服务 | BE | `R-01` 占位符解析(行业 / 客户 / 方向 / 日期 / 序号),中文 / 字母 / 数字 / 下划线 / 短横线校验,同名自动加序号,≤ 100 字符 | A4-BE-02 | 占位符缺失报错;同名序号连续;规则可被导出 / 素材 / 脚本共用 | 0.5 | | A4-BE-03 | 命名规则服务 | BE | `R-01` 占位符解析(行业 / 客户 / 方向 / 日期 / 序号),中文 / 字母 / 数字 / 下划线 / 短横线校验,同名自动加序号,≤ 100 字符 | A4-BE-02 | 占位符缺失报错;同名序号连续;规则可被导出 / 素材 / 脚本共用 | 0.5 | ✅ |
| A4-BE-04 | 字典快照 | BE | 字典项被引用时保存填写时快照;字典变更只影响新数据;历史统计按填写时解释(`R-10`) | A4-BE-01 | 改字典名称后历史数据不变;快照查询可复现 | 0.5 | | A4-BE-04 | 字典快照 | BE | 字典项被引用时保存填写时快照;字典变更只影响新数据;历史统计按填写时解释(`R-10`) | A4-BE-01 | 改字典名称后历史数据不变;快照查询可复现 | 0.5 | ✅ |
| A4-FE-01 | 字典管理 | FE | 字典类型列表、条目树 / 词表双视图、行业作用域、排序、启停;编码只读 | A4-BE-04 | 编码不可编辑;树与词表可切换;影响范围提示 | 0.5 | | A4-FE-01 | 字典管理 | FE | 字典类型列表、条目树 / 词表双视图、行业作用域、排序、启停;编码只读 | A4-BE-04 | 编码不可编辑;树与词表可切换;影响范围提示 | 0.5 | ✅ |
| A4-FE-02 | 系统配置页 | FE | 命名规则、导出设置、通知、阈值配置;版本与变更记录 | A4-BE-02 | 保存前展示影响面;阈值修改有二次确认 | 0.5 | | A4-FE-02 | 系统配置页 | FE | 命名规则、导出设置、通知、阈值配置;版本与变更记录 | A4-BE-02 | 保存前展示影响面;阈值修改有二次确认 | 0.5 | ✅ |
| A4-QA-01 | 合并 / 快照 / 同源用例 | QA | 三层合并、编码不可改、快照、阈值前后端一致 | A4-BE-04 | 用例全绿;改字典不影响历史 | 0.5 | | A4-QA-01 | 合并 / 快照 / 同源用例 | QA | 三层合并、编码不可改、快照、阈值前后端一致 | A4-BE-04 | 用例全绿;改字典不影响历史 | 0.5 | ✅ |
**本批门禁**:字典三层合并可用;编码不可改;阈值只存一处且前后端一致;配置变更可审计。 **本批门禁**:字典三层合并可用;编码不可改;阈值只存一处且前后端一致;配置变更可审计。
> **A4 完成情况(2026-11-04)**:7/7 项 WBS 全部 ✅。`dict_types` / `dict_items` 通用枚举表(树 / 词表一张表、编码不可改、三层合并、快照契约)、`system_configs` + `system_config_versions`(26 项配置、版本 / 乐观锁 / 前后值 / 审计日志)、命名规则服务(R-01)、阈值同源(roles / users / customers / customer-fields 读配置)全部落地;前端按用户选定 **方案 A 主从分栏**实现 `/system/dicts`、`/system/dicts/:type`、`/system/settings`,侧栏更名「数据字典」。门禁:`format:check / lint / typecheck / test` 全绿(shared 147 / api 137 / web 154,共 438),`test:int` 58 项(7 文件,含 A4 catalog 10 + config 9),A4 浏览器 e2e 7/7(约 1.1m),`build` 8/8,`openapi:export` + `openapi:diff` 无破坏性变更;实现记录、截图与遗留见 [A4 实施报告](./灵犀_A4数据字典与系统配置_实施报告_V0.1.md)。已知遗留:`refCount` 待 B0/B1、A5 审计落库、详情面包屑末级类型名、字典类型 >100 的分页。
### 6.6 A5 审计与日志 ### 6.6 A5 审计与日志
**目标**:关键操作全留痕、不可篡改、可检索可导出。**对应**:P-07、PRD §5.4.6;`R-05`;架构 §5.4.5 / §9.7;工程规范 08。 **目标**:关键操作全留痕、不可篡改、可检索可导出。**对应**:P-07、PRD §5.4.6;`R-05`;架构 §5.4.5 / §9.7;工程规范 08。
@@ -952,7 +961,7 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配
| --- | --- | | --- | --- |
| 页面 | `/industries`、`/customers`、`/customers/new`(分步)、`/customers/:id`、`/customer-groups`、`/customer-fields/approvals` | | 页面 | `/industries`、`/customers`、`/customers/new`(分步)、`/customers/:id`、`/customer-groups`、`/customer-fields/approvals` |
| 接口 | `GET/POST/PATCH /industries`、`GET/POST/PATCH /customers`、`/customer-groups`、`/customers/{id}/fields`、`/customer-fields/{id}/approve`、`POST /customers/{id}/apply-industry-pack` | | 接口 | `GET/POST/PATCH /industries`、`GET/POST/PATCH /customers`、`/customer-groups`、`/customers/{id}/fields`、`/customer-fields/{id}/approve`、`POST /customers/{id}/apply-industry-pack` |
| 关键规则 | 行业名称全局唯一;客户名称同行业内唯一;**简称必填**(用于全部自动命名);客户必须归属一个行业、不可跨行业移动;暂停只读、结束只读归档(`R-07`);基础字段 ≤ 10 且永不进入生成,进入生成字段 ≤ 5 且必须审批 | | 关键规则 | 行业两级树;一级名称全局唯一、二级同父唯一;客户名称同末级行业唯一;**简称必填**(用于全部自动命名);客户必须归属末级行业、不可跨行业移动;暂停只读、结束只读归档(`R-07`);基础字段 ≤ 10 且永不进入生成,进入生成字段 ≤ 5 且必须审批 |
| 交互要点 | 新建客户为分步表单(行业 → 档案 → 交付偏好 → 资产包),可回退;状态为暂停 / 结束时顶部只读横幅;客户组作为授权单位,批量授权入口清晰 | | 交互要点 | 新建客户为分步表单(行业 → 档案 → 交付偏好 → 资产包),可回退;状态为暂停 / 结束时顶部只读横幅;客户组作为授权单位,批量授权入口清晰 |
| 验收 | ① 重名返回 40902;② 简称缺失 42201;③ 暂停客户不能提交新任务;④ 客户组授权后范围正确;⑤ 字段超限 40001、未审批字段不进入生成 | | 验收 | ① 重名返回 40902;② 简称缺失 42201;③ 暂停客户不能提交新任务;④ 客户组授权后范围正确;⑤ 字段超限 40001、未审批字段不进入生成 |
@@ -1232,8 +1241,9 @@ infra/:compose、Nginx、监控、备份脚本占位
- [ ] 超管首登强制改密;用户 / 角色 / 部门可用; - [ ] 超管首登强制改密;用户 / 角色 / 部门可用;
- [ ] 自定义角色 + 权限点 + 六类数据范围 + 特殊权限可用; - [ ] 自定义角色 + 权限点 + 六类数据范围 + 特殊权限可用;
- [ ] SoD 三对规则生效且有拒绝用例; - [ ] SoD 三对规则生效且有拒绝用例;
- [ ] 行业两级、客户 / 客户组 / 客户端字段审批可用; - [ ] 行业两级树(一级 → 二级,客户挂末级)、客户 / 客户组 / 客户端字段审批可用;
- [ ] 通用字典三层合并、编码不可改、历史快照可用; - [x] 通用字典三层合并、编码不可改、历史快照可用;
- [x] 系统配置单一来源、阈值 / 上限同源、配置版本与变更记录可用;
- [ ] 审计七类关键动作留痕、只读、导出留痕; - [ ] 审计七类关键动作留痕、只读、导出留痕;
- [ ] AI 四层配置 + Mock 全链路 + 密钥不回显; - [ ] AI 四层配置 + Mock 全链路 + 密钥不回显;
- [ ] 任务中心可查 / 可取消 / 可重试,幂等生效; - [ ] 任务中心可查 / 可取消 / 可重试,幂等生效;
@@ -1364,7 +1374,7 @@ infra/:compose、Nginx、监控、备份脚本占位
| catalog | `asset:view` / `asset:edit` / `asset:delete` | 资产(被引用仅停用) | | catalog | `asset:view` / `asset:edit` / `asset:delete` | 资产(被引用仅停用) |
| media | `media:view` / `media:generate` | 任务查看 / 提交(任务中心复用) | | media | `media:view` / `media:generate` | 任务查看 / 提交(任务中心复用) |
| delivery | `export:create`★ | 导出 | | delivery | `export:create`★ | 导出 |
| platform | `dict:view` / `dict:edit` | 字典 | | platform | `dict:manage` | 字典(A2 定稿目录,单权限点覆盖查看 / 编辑) |
| platform | `ai:view` / `ai:configure`★ | AI 配置 | | platform | `ai:view` / `ai:configure`★ | AI 配置 |
| platform | `audit:view`★ / `audit:export`★ | 审计查看 / 导出(均为特殊权限) | | platform | `audit:view`★ / `audit:export`★ | 审计查看 / 导出(均为特殊权限) |
| platform | `system:configure` | 系统配置 | | platform | `system:configure` | 系统配置 |
@@ -1379,11 +1389,11 @@ infra/:compose、Nginx、监控、备份脚本占位
| `/system/users`、`/system/users/new`、`/system/users/:id` | 用户与部门 | `user:view` / `user:manage` | A1 | | `/system/users`、`/system/users/new`、`/system/users/:id` | 用户与部门 | `user:view` / `user:manage` | A1 |
| `/system/departments` | 部门管理(两级组织树、停用/启用) | `user:view` / `user:manage` | A1 | | `/system/departments` | 部门管理(两级组织树、停用/启用) | `user:view` / `user:manage` | A1 |
| `/system/roles`、`/system/roles/:id` | 角色管理 | `role:view` / `role:manage` | A2 | | `/system/roles`、`/system/roles/:id` | 角色管理 | `role:view` / `role:manage` | A2 |
| `/system/industries` | 行业管理 | `industry_pack:view` / `customer:manage` | A3 | | `/system/industries` | 行业管理 | `customer:read` / `customer:manage` | A3 |
| `/system/customers`、`/system/customers/new`、`/system/customers/:id` | 客户管理 | `customer:view` / `customer:manage` | A3 | | `/system/customers`、`/system/customers/new`、`/system/customers/:id` | 客户管理 | `customer:view` / `customer:manage` | A3 |
| `/system/customer-groups` | 客户组 | `customer_group:manage` | A3 | | `/system/customer-groups` | 客户组 | `customer_group:manage` | A3 |
| `/system/customer-fields/approvals` | 字段审批 | `customer_field:approve` | A3 | | `/system/customer-fields/approvals` | 字段审批 | `customer_field:approve` | A3 |
| `/system/dicts`、`/system/dicts/:type` | 字典 | `dict:view` / `dict:edit` | A4 | | `/system/dicts`、`/system/dicts/:type` | 字典 | `dict:manage` | A4 |
| `/system/settings` | 系统配置 | `system:configure` | A4 | | `/system/settings` | 系统配置 | `system:configure` | A4 |
| `/system/audit-logs`、`/system/audit-logs/:id` | 审计 | `audit:view` | A5 | | `/system/audit-logs`、`/system/audit-logs/:id` | 审计 | `audit:view` | A5 |
| `/material/tasks`、`/material/tasks/:id` | 任务中心 | `media:view` / `media:generate` | A6 | | `/material/tasks`、`/material/tasks/:id` | 任务中心 | `media:view` / `media:generate` | A6 |
@@ -456,7 +456,7 @@ PRD 5.4.4 要求"多供应商并存、按用途路由、额度预算、健康降
| `script` | 5.1 | 要素卡、卖点库、脚本模板、批量生成、质量校核、审核入库、版本 | brief_cards / brief_card_index / selling_points / script_templates / scripts / script_versions | 脚本生成、校核、入库 | | `script` | 5.1 | 要素卡、卖点库、脚本模板、批量生成、质量校核、审核入库、版本 | brief_cards / brief_card_index / selling_points / script_templates / scripts / script_versions | 脚本生成、校核、入库 |
| `media` | 5.2 | 图片线、视频线、任务与任务项、分镜、快筛、预览、失败与成本 | media_tasks / media_items / shots / render_profiles / preview_marks | 任务编排、产物管理 | | `media` | 5.2 | 图片线、视频线、任务与任务项、分镜、快筛、预览、失败与成本 | media_tasks / media_items / shots / render_profiles / preview_marks | 任务编排、产物管理 |
| `delivery` | 5.5 | 导出、命名规则、交付清单、归档、下载签名 | exports / export_items / delivery_manifests | 打包、命名、审计 | | `delivery` | 5.5 | 导出、命名规则、交付清单、归档、下载签名 | exports / export_items / delivery_manifests | 打包、命名、审计 |
| `platform` | 5.4.4 / 5.4.6 | AI 配置(模型/路由/提示词/额度)、AI 调用账本、审计、系统配置、成本看板 | ai_providers / ai_routes / ai_prompts / ai_usage / audit_logs / system_configs / cost_budgets | AI 适配层入口、审计写入、成本聚合 | | `platform` | 5.4.4 / 5.4.6 | AI 配置(模型/路由/提示词/额度)、AI 调用账本、审计、系统配置、成本看板 | ai_providers / ai_routes / ai_prompts / ai_usage / audit_logs / system_configs / system_config_versions / cost_budgets | AI 适配层入口、审计写入、成本聚合 |
**共享内核**(`packages/shared`,不拥有表):领域事件定义、错误码、权限点常量、Zod schema、分页/游标、ID 生成、时间与命名规则工具。 **共享内核**(`packages/shared`,不拥有表):领域事件定义、错误码、权限点常量、Zod schema、分页/游标、ID 生成、时间与命名规则工具。
@@ -830,7 +830,7 @@ erDiagram
| 组织权限 | `role_permissions` | 角色 → 权限点 | 复合主键 | | 组织权限 | `role_permissions` | 角色 → 权限点 | 复合主键 |
| 组织权限 | `user_roles` | 用户 → 角色(可多) | 复合主键 | | 组织权限 | `user_roles` | 用户 → 角色(可多) | 复合主键 |
| 组织权限 | `data_scopes` | 角色数据范围 | `scope_type` + 目标 ID | | 组织权限 | `data_scopes` | 角色数据范围 | `scope_type` + 目标 ID |
| 客户与行业 | `industries` | 行业目录(业务属性) | 名称全局唯一 | | 客户与行业 | `industries` | 行业目录(两级树:一级 → 二级) | 一级名称全局唯一;二级同一父级下唯一;最多两级 |
| 客户与行业 | `customers` | 客户 | `(industry_id, name)` 唯一;`short_name` 必填 | | 客户与行业 | `customers` | 客户 | `(industry_id, name)` 唯一;`short_name` 必填 |
| 客户与行业 | `customer_groups` | 客户组 | 授权与范围最小单位 | | 客户与行业 | `customer_groups` | 客户组 | 授权与范围最小单位 |
| 客户与行业 | `customer_group_members` | 客户组 ↔ 客户 | 复合主键 | | 客户与行业 | `customer_group_members` | 客户组 ↔ 客户 | 复合主键 |
@@ -888,6 +888,7 @@ erDiagram
| 平台 | `cost_budgets` | 预算与告警阈值 | 按角色/客户/月 | | 平台 | `cost_budgets` | 预算与告警阈值 | 按角色/客户/月 |
| 平台 | `audit_logs` | 审计(append-only) | 按月分区,禁 UPDATE/DELETE | | 平台 | `audit_logs` | 审计(append-only) | 按月分区,禁 UPDATE/DELETE |
| 平台 | `system_configs` | 系统配置(命名、导出、通知) | 带版本与变更审计 | | 平台 | `system_configs` | 系统配置(命名、导出、通知) | 带版本与变更审计 |
| 平台 | `system_config_versions` | 系统配置变更历史(append-only:前后值 / 原因 / 操作人) | A4 新增;配置版本与审计可查 |
| 平台 | `outbox_events` | 事务性事件投递 | 已投递标记 | | 平台 | `outbox_events` | 事务性事件投递 | 已投递标记 |
| 平台 | `idempotency_keys` | 请求幂等 | TTL 24 小时 | | 平台 | `idempotency_keys` | 请求幂等 | TTL 24 小时 |
@@ -900,7 +901,7 @@ erDiagram
| **字段** | **类型** | **说明** | | **字段** | **类型** | **说明** |
| --- | --- | --- | | --- | --- | --- |
| `id` | ulid PK | — | | `id` | ulid PK | — |
| `industry_id` | ulid FK → industries | 不可为空;跨行业移动禁止(应用层 + 触发器可选) | | `industry_id` | ulid FK → industries | 不可为空;必须指向**末级行业**(有子行业的一级行业不可选);跨行业移动禁止(应用层 + 触发器可选) |
| `name` | text | 同行业内唯一 | | `name` | text | 同行业内唯一 |
| `short_name` | text | **必填**,用于自动命名,需满足通用字符规则 | | `short_name` | text | **必填**,用于自动命名,需满足通用字符规则 |
| `status` | enum | 在服务 / 暂停 / 结束 | | `status` | enum | 在服务 / 暂停 / 结束 |
@@ -1102,7 +1103,7 @@ sequenceDiagram
| **方法** | **路径** | **权限点** | **说明** | | **方法** | **路径** | **权限点** | **说明** |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| GET/POST/PATCH | `/industries`、`/industries/{id}` | `industry_pack:view` / `customer:manage` | 行业目录;删除前校验 | | GET/POST/PATCH | `/industries`、`/industries/{id}` | `customer:read` / `customer:manage` | 两级行业树;删除前校验无子行业 / 客户 / 资产;有启用子行业禁止停用 |
| GET/POST/PATCH | `/customers`、`/customers/{id}` | `customer:view` / `customer:manage` | 客户;`short_name` 必填 | | GET/POST/PATCH | `/customers`、`/customers/{id}` | `customer:view` / `customer:manage` | 客户;`short_name` 必填 |
| GET/POST | `/customer-groups`、`/customer-groups/{id}/members` | `customer_group:manage` | 客户组与成员 | | GET/POST | `/customer-groups`、`/customer-groups/{id}/members` | `customer_group:manage` | 客户组与成员 |
| POST | `/customers/{id}/apply-industry-pack` | `customer:manage` | 套用行业资产包(冷启动) | | POST | `/customers/{id}/apply-industry-pack` | `customer:manage` | 套用行业资产包(冷启动) |
@@ -1187,7 +1188,7 @@ sequenceDiagram
| GET/POST/PATCH | `/ai/providers`、`/ai/routes`、`/ai/prompts` | `ai:view` / `ai:configure` | 模型、路由、提示词 | | GET/POST/PATCH | `/ai/providers`、`/ai/routes`、`/ai/prompts` | `ai:view` / `ai:configure` | 模型、路由、提示词 |
| GET/PUT | `/ai/budgets` | `ai:configure` | 额度与预算 | | GET/PUT | `/ai/budgets` | `ai:configure` | 额度与预算 |
| GET | `/ai/usage` | `ai:view` | 账本查询(按客户 / 模型 / 批次) | | GET | `/ai/usage` | `ai:view` | 账本查询(按客户 / 模型 / 批次) |
| GET/POST/PATCH | `/dicts`、`/dicts/{type}/items` | `dict:view` / `dict:edit` | 字典 | | GET/POST/PATCH | `/dicts`、`/dicts/{type}/items` | `dict:manage` | 字典(A2 定稿的 30 个权限点中字典为单个 `dict:manage`,见 ADR-001) |
| GET | `/audit-logs` | `audit:view` | 审计检索(只读) | | GET | `/audit-logs` | `audit:view` | 审计检索(只读) |
| POST | `/audit-logs/export` | `audit:export` | 审计导出(自身也审计) | | POST | `/audit-logs/export` | `audit:export` | 审计导出(自身也审计) |
| GET/PUT | `/system/configs` | `system:configure` | 命名规则、导出设置、通知 | | GET/PUT | `/system/configs` | `system:configure` | 命名规则、导出设置、通知 |
@@ -2003,6 +2004,7 @@ lingxi/
| V0.4 | 2026-11-02 | 图片线改为确定性流水线(依据素材工坊 `sucaigongfang` 实测实现):`image:matting` / `image:render` / `delivery:zip` 三队列、PSD 图层命名约定与解析入库校验、ONNX 抠图与低质量降级、canvas 合成与字体自检、批次参数与命名快照、模板与批次表(`templates` / `template_versions` / `production_batches` / `production_tasks`);3.4 / 7.3 重写、新增 8.11 D-11、R-T-17~R-T-19、附录 F 补素材工坊行;需求基线对齐 PRD V1.6 | | V0.4 | 2026-11-02 | 图片线改为确定性流水线(依据素材工坊 `sucaigongfang` 实测实现):`image:matting` / `image:render` / `delivery:zip` 三队列、PSD 图层命名约定与解析入库校验、ONNX 抠图与低质量降级、canvas 合成与字体自检、批次参数与命名快照、模板与批次表(`templates` / `template_versions` / `production_batches` / `production_tasks`);3.4 / 7.3 重写、新增 8.11 D-11、R-T-17~R-T-19、附录 F 补素材工坊行;需求基线对齐 PRD V1.6 |
| V0.3 | 2026-11-02 | 吸收 baoliangmofang:素材为主对象 + 规格为属性(`materials` / `material_versions` / `material_specs` / `material_files` / `material_links` / `upload_sessions`);分片直传 + hash 秒传 + 短时效签名 URL + `StorageProvider` 接口(2.5 / 7.9);送审与外部提审登记、分享链接安全口径(`share_links`);投放登记与日指标(`placements` / `material_metrics_daily`)、阈值同源与派生指标不落库(5.7);新增 `media:post` / `embed:index` 队列、素材端点 13 个、3.6 素材与送审状态机、7.9 入库转码链路、R-T-15 / R-T-16、附录 F 补 6 行;向量检索改为「M3 起 pgvector、访问收敛 VectorStore」;需求基线对齐 PRD V1.5 | | V0.3 | 2026-11-02 | 吸收 baoliangmofang:素材为主对象 + 规格为属性(`materials` / `material_versions` / `material_specs` / `material_files` / `material_links` / `upload_sessions`);分片直传 + hash 秒传 + 短时效签名 URL + `StorageProvider` 接口(2.5 / 7.9);送审与外部提审登记、分享链接安全口径(`share_links`);投放登记与日指标(`placements` / `material_metrics_daily`)、阈值同源与派生指标不落库(5.7);新增 `media:post` / `embed:index` 队列、素材端点 13 个、3.6 素材与送审状态机、7.9 入库转码链路、R-T-15 / R-T-16、附录 F 补 6 行;向量检索改为「M3 起 pgvector、访问收敛 VectorStore」;需求基线对齐 PRD V1.5 |
| V0.1.4 | 2026-11-02 | React 19 的 Semi 包名改为 **`@douyinfe/semi-ui-19`**(禁与 `@douyinfe/semi-ui` 混装,ESLint 强制);明确入口仍需 `import '@douyinfe/semi-ui-19/react19-adapter'`(React 19 已移除 `react-dom` 的 `createRoot` 导出) | | V0.1.4 | 2026-11-02 | React 19 的 Semi 包名改为 **`@douyinfe/semi-ui-19`**(禁与 `@douyinfe/semi-ui` 混装,ESLint 强制);明确入口仍需 `import '@douyinfe/semi-ui-19/react19-adapter'`(React 19 已移除 `react-dom` 的 `createRoot` 导出) |
| V0.4(A4 回写) | 2026-11-04 | A4 数据字典与系统配置:新增 `system_config_versions`(配置变更前后值 / 版本 / 审计);字典权限按 A2 定稿统一为 `dict:manage`(ADR-001);明确字典通配表 `dict_types` / `dict_items` 与三层合并、R-10 快照口径 |
## 待办 ## 待办
+50 -1
View File
@@ -3,10 +3,12 @@
## 现有用例 ## 现有用例
| 文件 | 说明 | 默认是否执行 | | 文件 | 说明 | 默认是否执行 |
| --------------------- | --------------------------------------------- | ----------------- | | --------------------------- | ------------------------------------------------------------------------ | ----------------- |
| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 | | `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 |
| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` | | `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` |
| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` | | `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` |
| `a3-client.spec.ts` | A3-QA-01 客户与行业六页可访问 / 数据加载 | 否,需 `A3_E2E=1` |
| `a4-dicts-settings.spec.ts` | A4-QA-01 数据字典 / 三层合并 / 编码只读 / 系统配置 / 阈值确认 / 变更记录 | 否,需 `A4_E2E=1` |
## A1 账号生命周期用例 ## A1 账号生命周期用例
@@ -60,3 +62,50 @@ A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts --reporter=list
- 可选覆盖:`A2_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A2_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)、`A2_E2E_ACTOR_ACCOUNT` / `A2_E2E_ACTOR_PASSWORD`(缺省 `jiang.lan` / `QaE2eAdmin2026`,首跑用种子口令 `lingxi2026` 自动完成首登改密)。 - 可选覆盖:`A2_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A2_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)、`A2_E2E_ACTOR_ACCOUNT` / `A2_E2E_ACTOR_PASSWORD`(缺省 `jiang.lan` / `QaE2eAdmin2026`,首跑用种子口令 `lingxi2026` 自动完成首登改密)。
- 截图:`design/a2-ui-v1/shots/impl/e2e/`(`01-roles-list` … `11-roles-list-disabled`,共 11 张 1440×900)。 - 截图:`design/a2-ui-v1/shots/impl/e2e/`(`01-roles-list` … `11-roles-list-disabled`,共 11 张 1440×900)。
- 说明:用户按 A1 口径只停用不删除(用户不可删);角色在解绑后删除,可重复运行。 - 说明:用户按 A1 口径只停用不删除(用户不可删);角色在解绑后删除,可重复运行。
## A3 客户与行业用例(A3-QA-01)
覆盖:登录注入 → 行业管理 / 客户列表 / 新建客户 / 客户组 / 字段审批五页标题 → 客户列表有数据 → 点击行进入客户详情深链。
### 前置与运行
1. API 3000 + Web 5173(`pnpm dev`,真实 PG;已执行 `pnpm db:init`)。
2. 用例自建临时超管 `qa3.e2e`(Prisma 写入,结束后删除),不依赖本地账号密码。
```bash
A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list
```
- 可选覆盖:`A3_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A3_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)。
- 截图:`design/a3-ui-v1/impl/`(实施报告引用)。
## A4 数据字典与系统配置用例(A4-QA-01)
覆盖 7 条:
1. `/system/dicts` 主从页:h1、左侧「字典类型」、右栏 `script_direction` 类型头、三层 chips(通用 5 + 父行业 2 + 本行业 1 = 生效 7)、默认行业「电商 / 服饰鞋包」、种草短视频(已覆盖)/ 直播切片 / 穿搭教程;树 ↔ 词表切换(`?view=tree|list`);行业作用域切全平台(生效 5)再切回。
2. 条目:新增 SideSheet → 列表出现 → 编辑态编码 disabled 且值锁定 → Modal.confirm 停用 / 启用 → 行状态变化。
3. 类型:新建(`a4e2e_` 唯一 code + 名称 + 分组 + 形态树)→ 左侧类型列表可见 → 编辑类型编码 / 形态只读。
4. `/system/settings`:6 组导航、6 个占位符 chips、实时预览「电商-花间集-口播-20261104-01」、模板行内编辑与放弃恢复。
5. 阈值:客户基础字段上限改 12 → 保存全部更改 → 二次确认 → toast / Prisma 断言版本 +1 / 变更记录抽屉含前后值与原因。
6. 只读态:无 `dict:manage` / `system:configure` 账号访问两页 → `/403`。
7. 未登录访问 `/system/dicts` → 重定向登录页。
### 前置与运行
1. API 3000 + Web 5173(`pnpm dev`,真实 PG;已执行 `pnpm db:init`)。
2. 用例自建临时超管 `qa4.dicts.e2e` 与只读账号 `qa4.dicts.ro`(Prisma 写入,结束后删除);字典类型 / 条目按 `a4e2e_` 前缀清理;系统配置保存后恢复原值并删除本次 history。
```bash
A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts --reporter=list
```
- 可选覆盖:`A4_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A4_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)。
- 结果:7 passed(约 1.1m)。
- 截图:不生成常驻截图;失败时 Playwright 默认 `only-on-failure` 落 `test-results/`。
### 选择器校准(Semi)
- Semi Select 选项可访问名含图标 aria-label(tick),精确匹配不到,使用 `.semi-select-option` 文本点击。
- `Modal.confirm` 的确认 / 取消按钮 `aria-label` 固定为 `confirm` / `cancel`,不要按 `okText` 文本定位。
- `RadioGroup` 隐藏 input 被 addon 拦截,点可见文本或 `.semi-radio-addon-buttonRadio`。
+120
View File
@@ -0,0 +1,120 @@
/// <reference lib="dom" />
/**
* A3-QA-01 · 客户与行业目录端到端用例(真实 API + 真实 PG + 真实浏览器)
*
* 覆盖:登录 → 行业管理树/详情 → 客户列表 → 客户详情 → 新建客户向导 → 客户组 → 字段审批。
* 门控:仅 A3_E2E=1 时执行;默认不跑,避免影响 CI / smoke。
*
* 前置:API 3000、Web 5173(`pnpm dev`);用例自建 `qa3.e2e` 超管账号并在结束后删除。
* 运行:A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list
*/
import { randomBytes } from 'node:crypto';
import { expect, test } from '@playwright/test';
import { PrismaClient } from '@prisma/client';
const API_BASE = process.env.A3_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1';
const WEB_BASE = process.env.A3_E2E_BASE_URL ?? 'http://127.0.0.1:5173';
const ENABLED = process.env.A3_E2E === '1';
const PASSWORD = 'lingxi2026';
const PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
const ACCOUNT = 'qa3.e2e';
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
const prisma = new PrismaClient();
function ulid(): string {
let time = Date.now();
const timeChars = new Array<string>(10);
for (let index = 9; index >= 0; index -= 1) {
timeChars[index] = ENCODING[time % 32] ?? '0';
time = Math.floor(time / 32);
}
const random = randomBytes(16);
return `${timeChars.join('')}${Array.from(random, (byte) => ENCODING[byte & 31] ?? '0').join('')}`;
}
test.describe('A3 客户与行业目录', () => {
test.skip(!ENABLED, 'A3_E2E=1 时执行;需要 API 3000 + Web 5173');
test.beforeAll(async () => {
const [role, department] = await Promise.all([
prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } }),
prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
]);
await prisma.user.deleteMany({ where: { account: ACCOUNT } });
await prisma.user.create({
data: {
id: ulid(),
name: 'A3 端到端',
account: ACCOUNT,
email: `${ACCOUNT}@lingxi.cn`,
phone: '13500008888',
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId: role.id }] },
},
});
});
test.afterAll(async () => {
await prisma.user.deleteMany({ where: { account: ACCOUNT } });
await prisma.$disconnect();
});
test('行业 / 客户 / 客户组 / 字段审批六页可访问且数据加载', async ({ page, request }) => {
const login = await request.post(`${API_BASE}/auth/login`, {
data: { account: ACCOUNT, password: PASSWORD },
});
expect(login.ok()).toBe(true);
const session = (await login.json()) as { accessToken: string; refreshToken: string };
const meResponse = await request.get(`${API_BASE}/me`, {
headers: { authorization: `Bearer ${session.accessToken}` },
});
expect(meResponse.ok()).toBe(true);
const me = (await meResponse.json()) as {
user: { id: string; name: string; account: string };
roles: { name: string }[];
permissions: string[];
};
await page.addInitScript(
(value) => {
window.localStorage.setItem('lingxi.auth.session', JSON.stringify(value));
},
{
accessToken: session.accessToken,
refreshToken: session.refreshToken,
user: {
id: me.user.id,
name: me.user.name,
account: me.user.account,
roleName: me.roles[0]?.name,
},
permissions: me.permissions,
mustChangePassword: false,
},
);
const pages = [
{ path: '/system/industries', title: '行业管理' },
{ path: '/system/customers', title: '客户管理' },
{ path: '/system/customers/new', title: '新建客户' },
{ path: '/system/customer-groups', title: '客户组' },
{ path: '/system/customer-fields/approvals', title: '客户字段审批' },
];
for (const item of pages) {
await page.goto(`${WEB_BASE}${item.path}`, { waitUntil: 'networkidle' });
await expect(page.locator('h1').first()).toHaveText(item.title);
}
await page.goto(`${WEB_BASE}/system/customers`, { waitUntil: 'networkidle' });
const customerRows = page.locator('.lx-client-table tbody tr');
const count = await customerRows.count();
expect(count).toBeGreaterThan(0);
await customerRows.first().click();
await expect(page).toHaveURL(/\/system\/customers\/[A-Z0-9]+$/);
await expect(page.locator('h1').first()).toBeVisible();
});
});
+678
View File
@@ -0,0 +1,678 @@
/// <reference lib="dom" />
/**
* A4-QA-01 · 数据字典与系统配置端到端用例(真实 API + 真实 PG + 真实浏览器)
*
* 覆盖:
* ① /system/dicts:h1「数据字典」、左侧「字典类型」、右栏类型头、三层 chips、树 / 词表切换、行业作用域;
* ② 默认类型 script_direction + 行业视角「电商 / 服饰鞋包」→ 生效 7 项,并可见「种草短视频 / 直播切片 /
* 穿搭教程」与「已覆盖」标签;
* ③ 新增条目 → 列表出现 → 打开编辑时编码只读 → 停用 / 启用(Modal.confirm)→ 行状态变化;
* ④ 新建字典类型(编码前缀 a4e2e_)→ 类型列表可见 → 打开编辑验证编码只读;
* ⑤ /system/settings:h1「系统配置」、左侧 6 组、命名规则占位符 chips 与实时预览、模板行内编辑;
* ⑥ 修改「客户基础字段上限」为 12 → 保存全部更改 → 二次确认 → toast / 版本 +1 / 变更记录抽屉。
* ⑦ 无 dict:manage / system:configure 的账号访问 /system/dicts 与 /system/settings → 403;
* ⑧ 未登录访问 /system/dicts → 重定向登录页。
*
* 门控:仅 A4_E2E=1 时执行;默认 skip,不影响 smoke / CI。
* 前置:API 3000、Web 5173(`pnpm dev`,真实 PG + Redis,已 `pnpm db:init`)。
* 数据自建自清:临时账号 qa4.dicts.e2e(超管)+ qa4.dicts.ro(素材运营,无字典 / 配置权限),
* 用例结束删除;字典条目 / 类型按 `a4e2e_` 前缀清理;系统配置保存后通过 Prisma 恢复原值并删除本次历史行。
*
* 运行:A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts --reporter=list
*/
import { randomBytes } from 'node:crypto';
import { type APIRequestContext, expect, type Page, request, test } from '@playwright/test';
import { PrismaClient } from '@prisma/client';
const API_BASE = process.env.A4_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1';
const WEB_BASE = process.env.A4_E2E_BASE_URL ?? 'http://127.0.0.1:5173';
const ENABLED = process.env.A4_E2E === '1';
const PASSWORD = 'lingxi2026';
/** 与 a3 / a1 共用的 argon2id 哈希(明文 lingxi2026) */
const PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
const ADMIN_ACCOUNT = 'qa4.dicts.e2e';
const READONLY_ACCOUNT = 'qa4.dicts.ro';
/** 类型 / 条目编码前缀:用于自清理与识别异常中断遗留数据 */
const TYPE_CODE_PREFIX = 'a4e2e_';
const ITEM_CODE_PREFIX = 'a4e2e_item_';
const SEED_BASE_FIELD_LIMIT = 10;
const BASE_FIELD_KEY = 'limit.customer.base_field';
const SAVE_REASON = '在系统配置页调整';
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
const prisma = new PrismaClient();
let apiContext: APIRequestContext | null = null;
let adminSession: QaSession | null = null;
let readonlySession: QaSession | null = null;
let bootstrapError: string | null = null;
let baseFieldOriginalValue = SEED_BASE_FIELD_LIMIT;
let baseFieldBeforeVersion = 1;
let baseFieldAfterVersion: number | null = null;
/** 本次运行创建的字典数据(失败时 afterAll 兜底清理) */
let createdItemId: string | null = null;
let createdTypeCode: string | null = null;
interface QaSession {
accessToken: string;
refreshToken: string;
user: { id: string; name: string; account: string; roleName?: string };
permissions: string[];
mustChangePassword: boolean;
}
test.skip(!ENABLED, 'A4_E2E=1 才运行数据字典 / 系统配置 e2e(依赖 API :3000 + web :5173 + PG)');
test.use({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 } });
// ---------------------------------------------------------------------------
// 基础工具
// ---------------------------------------------------------------------------
function randomSuffix(): string {
return randomBytes(3).toString('hex');
}
function ulid(): string {
let time = Date.now();
const timeChars = new Array<string>(10);
for (let index = 9; index >= 0; index -= 1) {
timeChars[index] = ENCODING[time % 32] ?? '0';
time = Math.floor(time / 32);
}
const random = randomBytes(16);
return `${timeChars.join('')}${Array.from(random, (byte) => ENCODING[byte & 31] ?? '0').join('')}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new Error(`${label} 不是 JSON 对象`);
}
return value;
}
function requireString(source: Record<string, unknown>, key: string, label: string): string {
const value = source[key];
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`${label}.${key} 缺失或不是字符串`);
}
return value;
}
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
function requireApi(): APIRequestContext {
if (apiContext === null) {
throw new Error('APIRequestContext 未初始化');
}
return apiContext;
}
function requireAdminSession(): QaSession {
if (adminSession === null) {
throw new Error('超管会话未就绪');
}
return adminSession;
}
function requireReadonlySession(): QaSession {
if (readonlySession === null) {
throw new Error('只读账号会话未就绪');
}
return readonlySession;
}
/** 把登录态写入 localStorage(auth-store 的持久化 key) */
async function injectSession(page: Page, session: QaSession): Promise<void> {
const persisted = {
accessToken: session.accessToken,
refreshToken: session.refreshToken,
user: session.user,
permissions: session.permissions,
mustChangePassword: session.mustChangePassword,
};
await page.addInitScript((value: unknown) => {
window.localStorage.setItem('lingxi.auth.session', JSON.stringify(value));
}, persisted);
}
async function loginViaApi(account: string): Promise<QaSession> {
const context = requireApi();
const login = await context.post(`${API_BASE}/auth/login`, {
data: { account, password: PASSWORD },
});
expect(login.status(), `POST /auth/login ${account}`).toBe(200);
const body = requireRecord((await login.json()) as unknown, '登录响应');
const accessToken = requireString(body, 'accessToken', '登录响应');
const refreshToken = requireString(body, 'refreshToken', '登录响应');
const meResponse = await context.get(`${API_BASE}/me`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(meResponse.status(), `GET /me ${account}`).toBe(200);
const me = requireRecord((await meResponse.json()) as unknown, '/me 响应');
const user = requireRecord(me.user, '/me.user');
const roles = Array.isArray(me.roles) ? me.roles : [];
const firstRole = isRecord(roles[0]) ? roles[0] : null;
return {
accessToken,
refreshToken,
user: {
id: requireString(user, 'id', '/me.user'),
name: requireString(user, 'name', '/me.user'),
account: requireString(user, 'account', '/me.user'),
...(firstRole === null ? {} : { roleName: requireString(firstRole, 'name', '/me.roles[0]') }),
},
permissions: stringArray(me.permissions),
mustChangePassword: me.mustChangePassword === true,
};
}
async function createUser(account: string, name: string, roleCode: string): Promise<void> {
const [role, department] = await Promise.all([
prisma.role.findUniqueOrThrow({ where: { code: roleCode } }),
prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
]);
await prisma.user.deleteMany({ where: { account } });
await prisma.user.create({
data: {
id: ulid(),
name,
account,
email: `${account}@lingxi.cn`,
phone: `139${randomBytes(4).readUInt32BE(0).toString().padStart(8, '0').slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId: role.id }] },
},
});
}
/** 清理异常中断运行遗留的字典数据(前缀匹配,避免影响 seed 与其他用例) */
async function cleanupDictLeftovers(): Promise<void> {
const staleTypes = await prisma.dictType.findMany({
where: { code: { startsWith: TYPE_CODE_PREFIX } },
select: { id: true },
});
if (staleTypes.length > 0) {
await prisma.dictItem.deleteMany({
where: { typeId: { in: staleTypes.map((row) => row.id) } },
});
await prisma.dictType.deleteMany({ where: { id: { in: staleTypes.map((row) => row.id) } } });
}
await prisma.dictItem.deleteMany({ where: { code: { startsWith: ITEM_CODE_PREFIX } } });
}
/** 读取并记录「客户基础字段上限」的原始值 / 版本;异常遗留值先恢复 seed 默认值 */
async function prepareBaseFieldConfig(): Promise<void> {
const row = await prisma.systemConfig.findUnique({ where: { configKey: BASE_FIELD_KEY } });
const original = typeof row?.configValue === 'number' ? row.configValue : SEED_BASE_FIELD_LIMIT;
baseFieldOriginalValue = original;
baseFieldBeforeVersion = row?.version ?? 1;
if (original !== SEED_BASE_FIELD_LIMIT) {
// 上次运行可能异常中断;恢复 seed 默认值,保证本次「改成 12」一定产生变更。
await prisma.systemConfig.update({
where: { configKey: BASE_FIELD_KEY },
data: { configValue: SEED_BASE_FIELD_LIMIT },
});
baseFieldOriginalValue = SEED_BASE_FIELD_LIMIT;
}
}
interface DictTypeRow {
id: string;
code: string;
}
async function readDictTypeByCode(code: string): Promise<DictTypeRow | null> {
return prisma.dictType.findUnique({ where: { code }, select: { id: true, code: true } });
}
// ---------------------------------------------------------------------------
// 浏览器 / 页面辅助
// ---------------------------------------------------------------------------
async function openDicts(page: Page): Promise<void> {
await injectSession(page, requireAdminSession());
await page.goto('/system/dicts');
await expect(page.getByRole('heading', { name: '数据字典' })).toBeVisible();
await expect(page.getByTestId('app-shell')).toBeVisible();
}
async function gotoSystem(page: Page, path: string): Promise<void> {
await injectSession(page, requireAdminSession());
await page.goto(path);
}
function dictItemRow(page: Page, text: string) {
return page.locator('.lx-dict-table tbody tr', { hasText: text });
}
/**
* 选择 Semi Select 下拉项。
* 注意:Semi Select option 的可访问名会带上勾选图标的 aria-label(tick),
* `getByRole('option', { name, exact: true })` 匹配不到,这里统一按选项文本定位。
*/
async function pickSelectOption(page: Page, optionName: string): Promise<void> {
const option = page.locator('.semi-select-option').filter({ hasText: optionName }).first();
await expect(option).toBeVisible({ timeout: 10_000 });
await option.click();
}
async function waitToast(page: Page, text: string | RegExp): Promise<void> {
await expect(page.locator('[role="alert"]', { hasText: text }).first()).toBeVisible({
timeout: 15_000,
});
}
// ---------------------------------------------------------------------------
// 全局编排
// ---------------------------------------------------------------------------
test.beforeAll(async () => {
if (!ENABLED) return;
apiContext = await request.newContext();
try {
const web = await apiContext.get(`${WEB_BASE}/`);
expect(web.status(), `web 未就绪:请在 ${WEB_BASE} 启动前端`).toBe(200);
await cleanupDictLeftovers();
await prepareBaseFieldConfig();
await createUser(ADMIN_ACCOUNT, 'A4 端到端超管', 'super_admin');
await createUser(READONLY_ACCOUNT, 'A4 端到端只读', 'material_operator');
adminSession = await loginViaApi(ADMIN_ACCOUNT);
readonlySession = await loginViaApi(READONLY_ACCOUNT);
expect(adminSession.permissions, '超管应含 dict:manage').toContain('dict:manage');
expect(adminSession.permissions, '超管应含 system:configure').toContain('system:configure');
expect(readonlySession.permissions, '素材运营不应含 dict:manage').not.toContain('dict:manage');
expect(readonlySession.permissions, '素材运营不应含 system:configure').not.toContain(
'system:configure',
);
} catch (error) {
bootstrapError = error instanceof Error ? error.message : String(error);
}
});
test.afterAll(async () => {
if (!ENABLED) return;
try {
// 字典:先删条目再删类型(按本次记录 + 前缀双保险)
if (createdItemId !== null) {
await prisma.dictItem.deleteMany({ where: { id: createdItemId } });
}
if (createdTypeCode !== null) {
const type = await readDictTypeByCode(createdTypeCode);
if (type !== null) {
await prisma.dictItem.deleteMany({ where: { typeId: type.id } });
await prisma.dictType.deleteMany({ where: { id: type.id } });
}
}
await cleanupDictLeftovers();
// 系统配置:恢复原值 / 原版本,并删除本次产生的历史行
if (baseFieldAfterVersion !== null) {
await prisma.systemConfigVersion.deleteMany({
where: { configKey: BASE_FIELD_KEY, version: baseFieldAfterVersion },
});
}
const configRow = await prisma.systemConfig.findUnique({
where: { configKey: BASE_FIELD_KEY },
});
if (configRow !== null) {
await prisma.systemConfig.update({
where: { configKey: BASE_FIELD_KEY },
data: { configValue: baseFieldOriginalValue, version: baseFieldBeforeVersion },
});
}
await prisma.user.deleteMany({ where: { account: { in: [ADMIN_ACCOUNT, READONLY_ACCOUNT] } } });
} catch {
// 清理失败不覆盖真实断言结果;残留由下一次 beforeAll 按前缀清理
}
await apiContext?.dispose();
apiContext = null;
await prisma.$disconnect();
});
test.beforeEach(() => {
test.skip(bootstrapError !== null, bootstrapError ?? '测试环境未就绪');
});
// ---------------------------------------------------------------------------
// ① / ② 字典主从页:默认类型、三层 chips、树表切换、行业作用域
// ---------------------------------------------------------------------------
test('数据字典:默认类型 / 三层 chips / 树词表切换 / 行业作用域', async ({ page }) => {
test.setTimeout(120_000);
await openDicts(page);
// 左侧类型栏与右栏类型头
await expect(page.getByRole('complementary', { name: '字典类型' })).toBeVisible();
await expect(page.locator('.lx-dict-list__title')).toHaveText('字典类型');
const detailHead = page.locator('.lx-dict-head');
await expect(detailHead.locator('.lx-dict-head__name b').first()).toHaveText('脚本方向');
await expect(detailHead.locator('.lx-dict-head__meta code').first()).toHaveText(
'script_direction',
);
// 默认行业视角:电商 / 服饰鞋包
await expect(page.locator('.lx-dict-scope__select')).toContainText('电商 / 服饰鞋包');
// 三层 chips:通用 5 + 父行业 2 + 本行业 1 = 生效 7
const chips = page.locator('.lx-dict-scope__layers .lx-dict-layer-chip');
await expect(chips.filter({ hasText: '通用' })).toContainText('5');
await expect(chips.filter({ hasText: '父行业' })).toContainText('2');
await expect(chips.filter({ hasText: '本行业' })).toContainText('1');
await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('生效');
await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('7');
await expect(page.locator('.lx-dict-viewrow__hint')).toContainText('生效 7 项');
// 词表视图:种短视频 / 直播切片 / 穿搭教程 + 「已覆盖」标签
await expect(page.locator('.lx-dict-table')).toBeVisible();
for (const name of ['种草短视频', '直播切片', '穿搭教程']) {
await expect(dictItemRow(page, name)).toBeVisible();
}
await expect(dictItemRow(page, '种草短视频').getByText('已覆盖')).toBeVisible();
await expect(dictItemRow(page, '直播切片').getByText('已覆盖')).toHaveCount(0);
// 树 / 词表切换:点击「树视图」→ URL ?view=tree 且渲染树
// Semi RadioGroup 的隐藏 input 被 addon 覆盖,直接点击可见的 addon 文本。
const viewSwitcher = page.locator('.lx-dict-viewrow');
await viewSwitcher
.getByText(/树视图/)
.first()
.click();
await expect(page).toHaveURL(/view=tree/);
await expect(page.locator('.lx-dict-tree')).toBeVisible();
await expect(page.locator('.lx-dict-table')).toHaveCount(0);
await viewSwitcher
.getByText(/词表视图/)
.first()
.click();
await expect(page).toHaveURL(/view=list/);
await expect(page.locator('.lx-dict-table')).toBeVisible();
// 行业作用域:切到全平台 → 只保留通用层(生效 5)
await page.locator('.lx-dict-scope__select').click();
await pickSelectOption(page, '全平台');
await expect(page).toHaveURL(/industry=all/);
await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('5');
await expect(page.locator('.lx-dict-layer-chip', { hasText: '父行业' })).toHaveCount(0);
await expect(dictItemRow(page, '直播切片')).toHaveCount(0);
await expect(dictItemRow(page, '口播')).toBeVisible();
// 切回电商 / 服饰鞋包 → 三层恢复为 7
await page.locator('.lx-dict-scope__select').click();
await pickSelectOption(page, '电商 / 服饰鞋包');
await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('7');
await expect(dictItemRow(page, '直播切片')).toBeVisible();
});
// ---------------------------------------------------------------------------
// ③ 条目:新增 → 编辑编码只读 → 停用 / 启用
// ---------------------------------------------------------------------------
test('数据字典:新增条目 → 编辑编码只读 → 停用 / 启用', async ({ page }) => {
test.setTimeout(150_000);
await openDicts(page);
await expect(page.locator('.lx-dict-table tbody tr').first()).toBeVisible();
const suffix = randomSuffix();
const itemCode = `${ITEM_CODE_PREFIX}${suffix}`;
const itemName = `A4E2E测试条目${suffix}`;
await page.getByRole('button', { name: '新增条目' }).first().click();
const sheet = page.locator('.semi-sidesheet');
await expect(sheet).toBeVisible();
await expect(sheet.locator('.semi-sidesheet-title')).toHaveText('新增条目');
await sheet.getByPlaceholder('如:口播').fill(itemName);
await sheet.getByPlaceholder('字母、数字、下划线或短横线').fill(itemCode);
await sheet.getByRole('button', { name: '保存', exact: true }).click();
await waitToast(page, '条目已创建');
const row = dictItemRow(page, itemCode);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row).toContainText(itemName);
await expect(row.locator('.lx-dict-status--active')).toBeVisible();
// 记录 id,afterAll 兜底清理
const createdItem = await prisma.dictItem.findFirst({
where: { code: itemCode },
select: { id: true },
});
createdItemId = createdItem?.id ?? null;
expect(createdItemId, '新增条目应落库').not.toBeNull();
// 打开编辑:编码输入禁用且值不可改
await row.getByRole('button', { name: '编辑', exact: true }).click();
const editSheet = page.locator('.semi-sidesheet');
await expect(editSheet.locator('.semi-sidesheet-title')).toHaveText('编辑条目');
const codeInput = editSheet.getByPlaceholder('字母、数字、下划线或短横线');
await expect(codeInput).toBeDisabled();
await expect(codeInput).toHaveValue(itemCode);
await expect(editSheet.getByText('编码创建后不可修改')).toBeVisible();
await editSheet.getByRole('button', { name: '取消', exact: true }).click();
await expect(editSheet).toHaveCount(0);
// 停用条目:Modal.confirm 点「停用」(Semi 确认按钮的 aria-label 固定为 confirm)
await row.getByRole('button', { name: '停用', exact: true }).click();
const disableDialog = page.getByRole('dialog', { name: new RegExp(`停用条目「${itemName}」`) });
await expect(disableDialog).toBeVisible();
await disableDialog.getByRole('button', { name: 'confirm' }).click();
await waitToast(page, `已停用「${itemName}」`);
await expect(row.locator('.lx-dict-status--disabled')).toBeVisible();
await expect(row).toHaveClass(/is-disabled/);
// 启用条目:Modal.confirm 点「启用」
await row.getByRole('button', { name: '启用', exact: true }).click();
const enableDialog = page.getByRole('dialog', { name: new RegExp(`启用条目「${itemName}」`) });
await expect(enableDialog).toBeVisible();
await enableDialog.getByRole('button', { name: 'confirm' }).click();
await waitToast(page, `已启用「${itemName}」`);
await expect(row.locator('.lx-dict-status--active')).toBeVisible();
await expect(row).not.toHaveClass(/is-disabled/);
});
// ---------------------------------------------------------------------------
// ④ 字典类型:新建 → 列表可见 → 编辑编码只读
// ---------------------------------------------------------------------------
test('数据字典:新建类型 → 列表可见 → 编辑类型编码只读', async ({ page }) => {
test.setTimeout(120_000);
await openDicts(page);
const suffix = randomSuffix();
const typeCode = `${TYPE_CODE_PREFIX}type_${suffix}`;
const typeName = `A4E2E测试类型${suffix}`;
createdTypeCode = typeCode;
await page.getByRole('button', { name: '新建字典类型' }).click();
const modal = page.getByRole('dialog', { name: '新建字典类型' });
await expect(modal).toBeVisible();
await modal.getByPlaceholder('如:投放位置').fill(typeName);
await modal.getByPlaceholder('小写字母、数字与下划线').fill(typeCode);
// 所属分组:质量与合规
const groupField = modal.locator('.lx-dict-field', { hasText: '所属分组' });
await groupField.locator('.semi-select').click();
await pickSelectOption(page, '质量与合规');
// 形态:树
const kindField = modal.locator('.lx-dict-field', { hasText: '形态' });
await kindField.locator('.semi-select').click();
await pickSelectOption(page, '树');
await expect(kindField.locator('.semi-select')).toContainText('树');
await modal.getByRole('button', { name: '保存', exact: true }).click();
await waitToast(page, '字典类型已创建');
// 保存后自动进入新类型详情;左侧类型列表可见
await expect(page).toHaveURL(new RegExp(`/system/dicts/${typeCode}`));
await expect(page.locator('.lx-dict-head__name b').first()).toHaveText(typeName);
const typeRow = page.locator('.lx-dict-type', { hasText: typeCode });
await expect(typeRow).toBeVisible({ timeout: 15_000 });
await expect(typeRow).toContainText(typeName);
// 编辑类型:编码只读、形态只读
await page.getByRole('button', { name: '编辑类型' }).click();
const editModal = page.getByRole('dialog', { name: '编辑字典类型' });
await expect(editModal).toBeVisible();
const codeField = editModal.locator('.lx-dict-field', { hasText: '类型编码' });
await expect(codeField.locator('input')).toBeDisabled();
await expect(codeField.locator('input')).toHaveValue(typeCode);
await expect(editModal.getByText('创建后不可修改').first()).toBeVisible();
const editKindField = editModal.locator('.lx-dict-field', { hasText: '形态' });
await expect(editKindField.locator('.semi-select-disabled')).toBeVisible();
await editModal.getByRole('button', { name: '取消', exact: true }).click();
await expect(editModal).toHaveCount(0);
});
// ---------------------------------------------------------------------------
// ⑤ 系统配置:6 组、命名占位符与实时预览、模板行内编辑
// ---------------------------------------------------------------------------
test('系统配置:6 组导航 / 命名占位符 / 实时预览 / 模板编辑', async ({ page }) => {
test.setTimeout(120_000);
await gotoSystem(page, '/system/settings');
await expect(page.getByRole('heading', { name: '系统配置' })).toBeVisible();
await expect(page.getByTestId('app-shell')).toBeVisible();
// 左侧 6 组
await expect(page.getByRole('complementary', { name: '配置分组' })).toBeVisible();
const navItems = page.locator('.lx-settings-nav__item');
await expect(navItems).toHaveCount(6);
for (const groupName of [
'基础信息',
'命名规则',
'导出与归档',
'通知开关',
'阈值与上限',
'业务阈值',
]) {
await expect(navItems.filter({ hasText: groupName })).toBeVisible();
}
// 命名规则:占位符 chips + 默认实时预览
await expect(page.locator('.lx-settings-naming-aside')).toBeVisible();
await expect(page.locator('.lx-settings-aside-head')).toContainText('命名占位符');
await expect(page.locator('.lx-settings-token')).toHaveCount(6);
await expect(page.locator('.lx-settings-preview__value')).toHaveText(
'电商-花间集-口播-20261104-01',
);
const originalPreview = await page.locator('.lx-settings-preview__value').innerText();
// 点击「编辑」改模板 → 预览实时变化 → 放弃更改恢复
const patternRow = page
.locator('.lx-settings-config.is-pattern')
.filter({ hasText: '脚本命名规则' });
await patternRow.getByRole('button', { name: '编辑', exact: true }).click();
const patternInput = page.getByLabel('命名规则');
await expect(patternInput).toBeVisible();
await patternInput.fill('{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}');
await expect(page.locator('.lx-settings-preview__value')).toHaveText(
'电商-花间集-口播-20261104-01-1080x1920',
);
await page
.locator('.lx-settings-form__foot')
.getByRole('button', { name: '放弃更改', exact: true })
.click();
await expect(page.locator('.lx-settings-preview__value')).toHaveText(originalPreview);
await expect(page.locator('.lx-settings-headindicator')).toContainText('所有修改已保存');
});
// ---------------------------------------------------------------------------
// ⑥ 阈值修改:保存 → 二次确认 → toast / 版本 +1 / 变更记录
// ---------------------------------------------------------------------------
test('系统配置:阈值修改 → 二次确认 → 版本 +1 → 变更记录', async ({ page }) => {
test.setTimeout(150_000);
await gotoSystem(page, '/system/settings');
await expect(page.getByRole('heading', { name: '系统配置' })).toBeVisible();
// 进入「阈值与上限」
await page.locator('.lx-settings-nav__item', { hasText: '阈值与上限' }).click();
await expect(page).toHaveURL(/group=limits/);
await expect(page.locator('.lx-settings-form__head h2')).toHaveText('阈值与上限');
const baseFieldRow = page.locator('.lx-settings-config').filter({ hasText: '客户基础字段上限' });
await expect(baseFieldRow).toBeVisible();
await baseFieldRow.getByLabel('客户基础字段上限').fill('12');
await expect(page.locator('.lx-settings-headindicator')).toContainText('未保存 1 项');
await page
.locator('.lx-settings-form__foot')
.getByRole('button', { name: '保存全部更改', exact: true })
.click();
// 阈值类配置二次确认
const confirmDialog = page.getByRole('dialog', { name: /确认修改阈值与上限/ });
await expect(confirmDialog).toBeVisible();
await expect(confirmDialog.getByText('客户基础字段上限')).toBeVisible();
await expect(confirmDialog).toContainText('12');
await confirmDialog.getByRole('button', { name: 'confirm' }).click();
await waitToast(page, '配置已保存,版本已更新');
await expect(page.locator('.lx-settings-headindicator')).toContainText('所有修改已保存');
// 版本 +1(值来自 Prisma 当前行)
const saved = await prisma.systemConfig.findUnique({ where: { configKey: BASE_FIELD_KEY } });
expect(saved?.configValue, '配置值应为 12').toBe(12);
baseFieldAfterVersion = saved?.version ?? null;
expect(baseFieldAfterVersion, '配置版本应 +1').toBe(baseFieldBeforeVersion + 1);
await expect(
page.locator('.lx-settings-config').filter({ hasText: '客户基础字段上限' }),
).toContainText(`v${String(baseFieldAfterVersion)}`);
// 变更记录抽屉:出现本次记录(版本 / 前后值 / 原因)
await page.getByRole('button', { name: '变更记录' }).click();
const historySheet = page.locator('.semi-sidesheet');
await expect(historySheet).toBeVisible();
await expect(historySheet.locator('.semi-sidesheet-title')).toHaveText('配置变更记录');
const historyItem = page
.locator('.lx-settings-history-item')
.filter({ hasText: '客户基础字段上限' })
.filter({ hasText: `v${String(baseFieldAfterVersion)}` });
await expect(historyItem).toBeVisible({ timeout: 15_000 });
await expect(historyItem).toContainText('12');
await expect(historyItem).toContainText(SAVE_REASON);
});
// ---------------------------------------------------------------------------
// ⑦ 权限:无 dict:manage / system:configure 的账号 → 403
// ---------------------------------------------------------------------------
test('权限:无字典 / 配置权限的账号访问 A4 页面显示 403', async ({ page }) => {
test.setTimeout(90_000);
await injectSession(page, requireReadonlySession());
await page.goto('/system/dicts');
await expect(page).toHaveURL(/\/403/);
await expect(page.getByRole('heading', { name: '没有访问权限' })).toBeVisible();
await page.goto('/system/settings');
await expect(page).toHaveURL(/\/403/);
await expect(page.getByRole('heading', { name: '没有访问权限' })).toBeVisible();
});
// ---------------------------------------------------------------------------
// ⑧ 未登录:访问 /system/dicts 重定向登录页
// ---------------------------------------------------------------------------
test('未登录访问 /system/dicts 重定向到登录页', async ({ page }) => {
await page.goto('/system/dicts');
await expect(page).toHaveURL(/\/login\?redirect=/);
await expect(page.getByRole('heading', { name: '登录灵犀' })).toBeVisible();
});
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more