Compare commits

...
18 Commits
Author SHA1 Message Date
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
Stev_Wang cda4b1825b chore(openapi): 同步部门管理接口的生成类型
ci / 质量门禁 (push) Canceled after 0s
背景:
- 上一批部门管理接口(disable / enable、update、树与字段说明)合入后没有重新导出 OpenAPI,
  apps/web/src/api/generated/openapi.d.ts 仍停留在「部门改名」版本,前端类型与真实契约不一致;
- pnpm ci:local 第 9 步 openapi:export 会自动重生成该文件,导致每次跑完工作区变脏。

改动:
- 执行 pnpm openapi:export,重新生成 apps/web/src/api/generated/openapi.d.ts
  (新增 /departments/{id}/disable、/departments/{id}/enable、update 操作与字段说明)。

影响面:
- 仅前端生成类型,无运行时行为变化;openapi/lingxi.openapi.json 无差异。

回滚方式:
- 回滚本提交即恢复旧生成文件。

验证证据:
- pnpm openapi:diff 通过;
- pnpm ci:local 全部门禁通过。
2026-11-03 16:26:10 +08:00
Stev_Wang 087f27936e fix(ci): 修复本地 CI 端到端冒烟的 demo 会话与 A0 遗留用例
背景:
- .gitea/workflows/ci.yml 在 job env 中设置 VITE_PUBLIC_DEMO_SESSION=true,
  e2e 用 vite preview + 演示会话登录;scripts/ci-local.mjs 没有同步这组 env,
  本地 CI 第 10 步必失败;
- e2e/smoke.spec.ts 是 A0 用例:还在找「请输入账号或邮箱」占位符、断言登录后落在工作台;
  A1 接入真实登录后占位符为「请输入账号或手机号」,且登录默认落地 /system/users。

改动:
- ci-local:构建与 e2e 步骤注入 VITE_PUBLIC_DEMO_SESSION / VITE_PUBLIC_API_BASE_URL
  (与 ci.yml 对齐),只作用于这两个步骤,避免影响单测对真实登录流程的断言;
- turbo.json:声明 globalEnv,保证构建产物随这两个 VITE 变量变化,不复用非 demo 构建缓存;
- smoke.spec:登录占位符改为「请输入账号或手机号」;登录后按 A1 现状断言落地 /system/users,
  再从顶部「工作台」进入首页并校验模块导航;
- README:单独执行 e2e 时补充 demo 会话环境变量,并注明 ci:local 已内置。

影响面:
- 仅本地 CI 与 e2e 冒烟;业务代码与 CI 工作流行为不变(ci.yml 本就在 job env 注入这些变量);
- 本地构建缓存会因 globalEnv 变更失效一次。

回滚方式:
- 回滚本提交即恢复原 ci-local 步骤、冒烟用例与 README。

验证证据:
- pnpm ci:local 全部门禁通过(用时 218.8s);
- e2e smoke 4 项通过(登录 / 403 / 模块切换 / 404;A1 账号周期 7 项按预期 skip)。
2026-11-03 16:25:55 +08:00
Stev_Wang b489cb3152 test(api): 修复集成测试对根部门顺序与文件并行的依赖
ci / 质量门禁 (push) Canceled after 0s
背景:
- pnpm ci:local 的 test:int 在本地真实 PG 上失败,与业务代码无关,是测试自身不稳定:
  1) identity 用例断言部门树第一项根部门必须有子级,但管理员可以新建无子级根部门
     并排在最前(本地已有手工部门「111」,sortOrder=0),该断言不成立;
  2) identity.int.spec 清理用 account startsWith('it.'),会误删 departments.int.spec
     的 it.dep.* 夹具;四个 int 文件默认并行且共用同一个真实 PG,beforeAll 偶发失败。

改动:
- identity.int.spec:部门树断言改为「存在带子级的根部门」,不再依赖根部门顺序;
- vitest.int.config:fileParallelism: false,集成测试文件串行执行,并注释原因。

影响面:
- 仅集成测试的断言与执行方式;业务代码、单测、构建不变;
- test:int 改为串行,耗时略增。

回滚方式:
- 回滚本提交即恢复原断言与并行执行。

验证证据:
- pnpm --filter @lingxi/api test:int 连续 3 次:4 个文件 / 33 项全通过;
- pnpm ci:local 第 1–8 步通过(第 10 步 e2e 为 A0 遗留问题,另行处理);
- pnpm format:check / lint / typecheck / test 通过。
2026-11-03 16:17:21 +08:00
Stev_Wang 4f6ccda6e3 fix(web): 统一用户/角色/部门列表行样式与选中态
ci / 质量门禁 (push) Canceled after 0s
背景:
- 用户、角色列表行为全宽 + 1px 分隔线,选中行与相邻悬停行的高亮背景会盖住分隔线,
  两块色块粘连,看起来间距被挤在一起;
- 部门树为列表内留白 + 圆角行,其左侧 2px 竖条贴行左缘,而用户 / 角色的竖条贴卡片边缘,
  三页选中指示器不一致;部门子级缩进后竖条位置还会随层级漂移;
- 行宽收紧后角色副信息(编码 · 数据范围)会被硬裁掉半个字。

改动:
- 三页列表统一行几何:列表内留白 6px、行间 2px、行圆角 6px,去掉贯穿分隔线;
- 统一取消左侧 2px 竖条,选中态改为品牌浅底 + 品牌色 / 600 字重标题,与侧栏导航一致;
- 行高 64→60,保持 9 条一屏;部门树根级 / 子级行距同步;
- 新建角色页的角色模板列表套用同一套行样式;
- 角色副信息改为块级 text-overflow: ellipsis,右侧统计列 132→112px 归还宽度,
  1440 下角色编码与数据范围不再截断。

影响面:
- 仅管理中心用户 / 角色 / 部门三页与新建角色页的列表视觉;
- 结构与交互不变,接口、权限、路由、数据均不变;≤1280/1080 沿用原响应式收缩规则。

回滚方式:
- 回滚本提交即恢复全宽行 + 1px 分隔线 + 左侧竖条与原行宽。

验证证据:
- pnpm format:check / lint / typecheck 通过;pnpm test:web 120/120 通过;
- Chrome 1440×900 实机截图:选中 + 悬停相邻行留白清晰,三页选中态一致;
- 角色列表副信息溢出检测:overflow: [](无截断)。
2026-11-03 15:55:06 +08:00
Stev_Wang f849e443d7 refactor(web): 侧栏「用户与组织」更名为「用户管理」
背景:
- 管理中心侧栏菜单名为「用户与组织」,与「角色管理」「部门管理」的命名方式不一致;
- 页面标题、面包屑仍沿用旧名,三处展示不统一;
- A1 文档与代码注释中该功能域本来即称「用户管理」。

改动:
- nav-config:菜单 label 改为「用户管理」,description 改为「成员账号、部门归属与登录状态」;
- breadcrumbs:用户新建 / 详情子路由的面包屑 2 处同步;
- UsersPage:页面 h1 同步;
- nav-config.spec / breadcrumbs.spec / e2e a1-identity 的标签与 heading 断言同步;
- 11-前端路由与目录规范、AppLayout 注释同步;PRD P-01 功能名保持「用户与组织管理」不变。

影响面:
- 仅前端文案与对应断言;路由、权限点、接口、数据均不变。

回滚方式:
- 回滚本提交即恢复原「用户与组织」文案与断言。

验证证据:
- pnpm format:check / lint / typecheck 通过;
- pnpm test:web 120/120 通过;
- Chrome 1440×900 实机:侧栏、面包屑、页面标题均显示「用户管理」。
2026-11-03 15:54:13 +08:00
Stev_Wang 3e384febf3 fix(tooling): 给 db:reset 增加显式确认,避免误清本地密码
ci / 质量门禁 (push) Canceled after 0s
背景:
- 本轮部门管理联调后执行了 pnpm db:reset 清理测试数据,清空本地库,
  导致用户手工改过的登录密码被重置回 lingxi2026;
- 之前只保证了 ./dev.sh / pnpm dev / pnpm db:init 不会重置数据,
  db:reset 本身仍可被脚本或助手无提示执行,问题没有真正兜住。

改动:
- 新增 scripts/db-reset.mjs:默认拒绝执行并打印影响;只有显式
  ALLOW_DB_RESET=1 才调用 prisma migrate reset --force;
- package.json 的 db:reset 指向该脚本;
- README / AGENTS §6 §11 / e2e README / A1 实施报告同步:
  日常迁移补种子只用 pnpm db:init,验证/联调中禁止 db:reset;
  e2e 会改写 actor 密码,需使用专用账号或独立测试库。

影响面:
- 确需清库时多一步显式确认;迁移、启动、单测、int 测试不受影响。

回滚方式:
- 回滚本提交即恢复 db:reset 直接执行 prisma migrate reset --force。

验证证据:
- pnpm db:reset 无 ALLOW_DB_RESET 时退出码 1,并打印拦截说明;
- pnpm format:check / lint 通过;node --check scripts/db-reset.mjs 通过。
2026-11-03 15:16:52 +08:00
Stev_Wang 981757b76c docs(plan): 回写 A1 部门管理补强
ci / 质量门禁 (push) Canceled after 0s
背景:
- 部门管理已实现(独立页面 /system/departments、只停用不删除、
  有成员/启用子部门禁止停用),需要同步计划、规范与实施报告。

改动:
- 开发计划:新增 WBS A1-BE-05(部门接口)与 A1-FE-04(部门管理页),
  A1 进度更新为 10/10、测试计数与「补强」说明;附录 C 增加
  /system/departments,§7.1 页面/接口清单更新;版本记录增补。
- A1 实施报告:范围表、契约/后端/前端交付物、测试计数与验证表补充
  部门管理;新增 §2.5 部门管理补强(口径、规则、覆盖范围)与遗留项。
- 11-前端路由与目录规范:管理中心模块表补 /system/departments
  (pages/system/departments/),版本 V1.1。

影响面:
- 仅文档;与已提交的代码行为一致,不改变其它批次范围。

回滚方式:
- 回滚本提交即恢复文档原状;不影响代码。

验证证据:
- pnpm format:check 通过;
- 文档中的测试计数、接口与页面路径与 feat(identity) 提交一致。
2026-11-03 15:02:14 +08:00
Stev_Wang 6661c42e84 feat(identity): 补齐部门管理(两级组织树、停用启用)
ci / 质量门禁 (push) Canceled after 0s
背景:
- A1 只实现了部门树读取与新建 / 改名接口,没有部门管理入口,
  负责人、排序、停用状态都无法维护;
- 用户必须关联部门,部门无法维护会让 A2 的数据范围缺少组织基础。

改动:
- shared:DepartmentNode 增加 status / sortOrder / directMemberCount;
  新增 createDepartmentInput / updateDepartmentInput / departmentTreeQuery 契约;
- api:GET /departments/tree?include_disabled;POST/PATCH /departments
  (名称 / 负责人 / 排序);POST /departments/{id}/disable|enable;
  只停用不删除,有在职成员 / 启用子部门 / 下级在职成员时禁止停用;
  新建 / 编辑用户校验部门必须启用;@Audit 与 OpenAPI 同步;
- web:管理中心新增「部门管理」/system/departments,左两级组织树 +
  右详情 / 内联编辑 + 新建部门/小组 + 停用启用二次确认;显示已停用、
  ?id= 选中可恢复、无 user:manage 只读;用户页部门选择仍只取启用部门;
- 测试:shared 契约、api 单测 + int、web 树/表单/接口单测同步。

影响面:
- 新增部门写接口与页面;GET tree 默认仍只返回启用部门,对用户页无破坏;
- 权限复用 user:view / user:manage,不新增权限点;
- 无数据库迁移(status / leader_user_id / sort_order 列已存在)。

回滚方式:
- 回滚本提交即移除部门管理页与写接口,保留原只读树;数据库无需回滚。

验证证据:
- pnpm format:check / lint / typecheck 全绿;
- pnpm test:shared 121 / api 90 / web 120;test:int 4 files / 33 tests;
- pnpm openapi:diff:无破坏性变更;
- 真实 API + 浏览器(jiang.lan):新建一级部门 / 小组、改名、停用空小组、
  重名 40903、树/详情/停用确认均正常;
  截图 design/a1-ui-v1/shots/impl/70-departments.png。
2026-11-03 15:01:07 +08:00
Stev_Wang f4da1f3c02 refactor(web): 编辑角色主操作移入详情页头并移除页脚
ci / 质量门禁 (push) Canceled after 0s
背景:角色详情页把「编辑角色」放在右下角页脚,与页头的复制/停用分离,主操作位置突兀;页脚只剩一句备注也浪费垂直空间。

改动:
- 页头操作顺序调整为:复制 → 停用/启用(系统角色为改名)→ 编辑角色(实心主按钮);
- 只读用户显示「查看角色」,系统角色也显示「查看角色」(仅名称可改,避免编辑语义过强);
- 移除详情页脚及编辑按钮,权限摘要与绑定用户获得更多高度;
- 更新只读态单测断言。

验证:format:check / lint / typecheck / test 全绿;5173 实测页头操作与主按钮位置正确。
2026-11-03 14:16:39 +08:00
Stev_Wang f5a8afe835 refactor(web): 侧栏与页面模块名由角色权限改为角色管理
ci / 质量门禁 (push) Canceled after 0s
背景:角色权限只描述模块的一部分能力,侧栏同级均为模块级命名(用户与组织 / 客户 / 行业树);角色页实际覆盖角色 CRUD、权限点、数据范围、特殊权限与 SoD。

改动:
- 侧栏、面包屑、页面标题统一为「角色管理」,页面内权限点 / 数据范围 / 特殊权限分区名不变;
- 用户详情与超管授予弹窗中的模块引用同步改为「角色管理」;
- 开发计划页面清单与 A2 实施报告同步;
- 更新导航与面包屑单测、A2 e2e 标题断言。

验证:format:check / lint / typecheck / test 全绿;5173 实测侧栏、面包屑、页标题均为「角色管理」。
2026-11-03 14:03:13 +08:00
Stev_Wang a3419cf5da refactor(web): 收敛角色页权限点目录入口
ci / 质量门禁 (push) Canceled after 0s
背景:列表页页头「权限点目录」与详情面板「查看权限点目录」和编辑器权限点 Tab 信息重复,抽屉只读、无搜索、无操作,且页头出现两个并列动作。

改动:
- 移除列表页页头目录按钮与 PermissionCatalogDrawer 组件及对应样式;
- 详情面板删除「查看权限点目录」入口,保留「已选 n / 30」信息;
- 权限查看统一走编辑器「权限点」Tab(只读用户也可进入查看);
- 同步 A2 实施报告与设计说明。

验证:pnpm format:check / lint / typecheck / test 全绿;5173 实测页头仅保留「新建角色」,详情面板无目录入口。
2026-11-03 13:56:35 +08:00
Stev_Wang fe70a2bcee fix(web): 修复角色列表页头操作按钮未右对齐
ci / 质量门禁 (push) Canceled after 0s
背景:/system/roles 页头标题容器缺少 lx-roles__head-txt 类,且页头未设置 space-between,导致「权限点目录 / 新建角色」紧跟在标题后停在页面中间;编辑器/新建页因标题容器有 flex:1 不受影响。

改动:RolesPage 标题容器补 lx-roles__head-txt;.lx-roles__header 增加 justify-content:space-between,操作区增加 margin-left:auto。

验证:pnpm format:check / lint / typecheck / test 全绿;本地 5173 实测按钮回到右侧(1440×900 截图已更新)。
2026-11-03 13:44:51 +08:00
Stev_Wang 7824bc7b32 fix(dev): 明确启动不重置数据库并新增安全 db:init
ci / 质量门禁 (push) Canceled after 0s
背景:开发机每次重启后被反馈需要重新用默认密码登录并强改,排查确认仓库启动链路(dev.sh / pnpm dev / API / worker / db:seed)均不重置密码;只有 db:reset / migrate reset / 重建数据卷会恢复种子密码。A2 收尾时为干净截图执行过 db:reset,是最近一次重置的直接原因。

改动:
- 新增 pnpm db:init = prisma migrate deploy + 幂等 seed,作为首次/迁移后的安全初始化;不重置数据、不覆盖已有密码。
- README / dev.sh / AGENTS 命令契约 / SEED.md / A1·A2 实施报告明确:日常启动不需要 DB 命令,db:reset 仅用于清库。
- 实测:对已改密的 admin 执行 db:init 后 password_hash 与 must_change_password 均未变化。

验证:pnpm format:check / pnpm lint 通过;db:init 实际执行通过。
2026-11-03 13:37:34 +08:00
106 changed files with 13543 additions and 662 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:
+5 -4
View File
@@ -114,8 +114,8 @@ 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:migrate` · `pnpm db:reset`(禁止手工删库 / 删数据卷) | | 数据库 | `pnpm db:init`(迁移 + 幂等种子,不重置数据)· `pnpm db:migrate` · `pnpm db:reset`(清库重建;默认被拦截,必须 `ALLOW_DB_RESET=1` 显式确认;禁止手工删库 / 删数据卷) |
| 契约 | `pnpm openapi:diff` | | 契约 | `pnpm openapi:diff` |
> 文档、脚本、CI 中出现的命令必须与上表一致,禁止自造别名。 > 文档、脚本、CI 中出现的命令必须与上表一致,禁止自造别名。
@@ -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 图层前缀、规格矩阵、低质量标记 |
@@ -180,6 +180,7 @@ docs/ PRD / 技术架构 / 立项建议 / 工程规范
3. 禁止修改已合并的 `prisma/migrations/`;禁止 `--no-verify` 提交。 3. 禁止修改已合并的 `prisma/migrations/`;禁止 `--no-verify` 提交。
4. 涉及业务口径不确定时,输出 `【待确认 Q-xx】` 并说明影响面,**不要编造**。 4. 涉及业务口径不确定时,输出 `【待确认 Q-xx】` 并说明影响面,**不要编造**。
5. 改动跨 >3 个模块或触及 §10 清单时,先给出方案与影响面,等确认后再动手。 5. 改动跨 >3 个模块或触及 §10 清单时,先给出方案与影响面,等确认后再动手。
6. **禁止在验证 / 联调中执行 `pnpm db:reset`**:它会清空本地库并重置所有账号密码;日常初始化只用 `pnpm db:init`。确需清库须先获得用户明确同意,并显式 `ALLOW_DB_RESET=1`。
--- ---
+17 -7
View File
@@ -21,7 +21,8 @@
| **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、测试与遗留 |
| **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/工程规范/`。
@@ -47,7 +48,7 @@
| **脚本** | 要素卡 / 卖点库 / 模板 / 生成 / 质量校核 / 审核入库 / 版本 | | **脚本** | 要素卡 / 卖点库 / 模板 / 生成 / 质量校核 / 审核入库 / 版本 |
| **素材** | 图片线(PSD 套版、抠图、规格矩阵)、视频线(逐镜生成、变体)、快筛与预览 | | **素材** | 图片线(PSD 套版、抠图、规格矩阵)、视频线(逐镜生成、变体)、快筛与预览 |
| **资产** | 15 类资产的库、版本、引用、复审与复用度量 | | **资产** | 15 类资产的库、版本、引用、复审与复用度量 |
| **平台管理** | 组织账号、客户、角色权限、AI 配置、数据字典、审计、行业包与表单设计器 | | **平台管理** | 组织账号、客户、角色管理、AI 配置、数据字典、审计、行业包与表单设计器 |
> 早期文档中的「四大模块(脚本生成 / 素材生产 / 编辑 / 管理)」已于 PRD V1.6 重构为「脚本 / 素材 / 资产 / 平台管理」三主线 + 一底座。 > 早期文档中的「四大模块(脚本生成 / 素材生产 / 编辑 / 管理)」已于 PRD V1.6 重构为「脚本 / 素材 / 资产 / 平台管理」三主线 + 一底座。
@@ -77,11 +78,20 @@ docs/ PRD / 技术架构 / 开发计划 / 立项建议 / 工程规范
cp .env.example .env.local cp .env.example .env.local
cp .env.local .env # Prisma CLI 只读取 .env(两者均已 gitignore) cp .env.local .env # Prisma CLI 只读取 .env(两者均已 gitignore)
pnpm check:dev # 检查本机 PostgreSQL / Redis 是否可用 pnpm check:dev # 检查本机 PostgreSQL / Redis 是否可用
pnpm db:migrate # 首次建表;后续由 Prisma 迁移管理 pnpm db:init # 首次/迁移后:应用已有迁移 + 幂等种子(不重置数据、不改已有密码)
pnpm db:seed # 幂等写入 system_configs 基线
pnpm dev # 并行启动 api(3000) / web(5173) / worker pnpm dev # 并行启动 api(3000) / web(5173) / worker
``` ```
> **日常启动不需要执行任何数据库命令**。`pnpm dev` / `./dev.sh` 都不会迁移、播种或重置数据库,已有账号密码保持不变。
> **只有需要清空本地库时才用 `pnpm db:reset`**:它会清空全部表并重新 seed,admin 等种子账号会回到初始密码 `lingxi2026` 且 `must_change_password=true`。为避免误清本地改过的密码/数据,该命令默认被拦截,必须显式执行:
>
> ```bash
> ALLOW_DB_RESET=1 pnpm db:reset # 确认清库才执行
> ```
>
> 日常迁移 / 补种子请用 `pnpm db:init`(幂等,不改已有密码)。
> 生产环境部署只用 `prisma migrate deploy`(`db:init` 的第一步),**不执行 seed / reset**;用户密码不会因重启或发布被重置。
也可以使用一键脚本(自动检测并释放端口、带健康检查,启动后默认跟随日志): 也可以使用一键脚本(自动检测并释放端口、带健康检查,启动后默认跟随日志):
```bash ```bash
@@ -104,12 +114,12 @@ pnpm dev # 并行启动 api(3000) / web(5173) / worker
```bash ```bash
pnpm lint && pnpm typecheck && pnpm test # 本地门禁 pnpm lint && pnpm typecheck && pnpm test # 本地门禁
pnpm build && pnpm test:e2e # 构建与端到端冒烟 VITE_PUBLIC_DEMO_SESSION=true pnpm test:e2e # 构建 + 端到端冒烟(demo 会话)
pnpm openapi:export && pnpm openapi:diff # 契约快照与破坏性变更检查 pnpm openapi:export && pnpm openapi:diff # 契约快照与破坏性变更检查
pnpm ci:local # 本地跑完整 CI 门禁 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 · 云白蓝(传统企业后台 + 现代浅色质感)**。
+2
View File
@@ -7,6 +7,7 @@ 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 { 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 +50,7 @@ import { PlatformModule } from './modules/platform/platform.module';
RedisModule, RedisModule,
SecurityModule, SecurityModule,
IdentityModule, IdentityModule,
ClientModule,
PlatformModule, PlatformModule,
], ],
}) })
@@ -1,6 +1,6 @@
import { ErrorCode } from '@lingxi/shared'; import { ErrorCode } from '@lingxi/shared';
import { type PipeTransform } from '@nestjs/common'; import { type PipeTransform } from '@nestjs/common';
import type { ZodSchema } from 'zod'; import type { ZodType, ZodTypeDef } from 'zod';
import { AppException } from '../errors/app-exception'; import { AppException } from '../errors/app-exception';
@@ -9,7 +9,7 @@ import { AppException } from '../errors/app-exception';
* 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。 * 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。
*/ */
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> { export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodSchema<T>) {} constructor(private readonly schema: ZodType<T, ZodTypeDef, unknown>) {}
transform(value: unknown): T { transform(value: unknown): T {
const result = this.schema.safeParse(value); const result = this.schema.safeParse(value);
@@ -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,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,244 @@
/* 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 { 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) {}
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);
if (used >= CUSTOMER_GEN_FIELD_LIMIT) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,请先停用或驳回其他字段`,
});
}
}
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);
if (used >= CUSTOMER_GEN_FIELD_LIMIT) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,审批后仍超限`,
});
}
}
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);
}
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,414 @@
/* 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 { 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,
) {}
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 ?? [];
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) 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: '客户负责人不存在或已停用',
});
}
}
private assertBaseFieldLimit(count: number): void {
if (count > CUSTOMER_BASE_FIELD_LIMIT) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `基础字段最多 ${CUSTOMER_BASE_FIELD_LIMIT} 个,当前 ${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);
}
}
@@ -1,28 +1,21 @@
import { Permission, ulidSchema } from '@lingxi/shared'; import {
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common'; type CreateDepartmentInput,
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; createDepartmentInputSchema,
import { z } from 'zod'; type DepartmentNode,
type DepartmentTreeQuery,
departmentTreeQuerySchema,
Permission,
type UpdateDepartmentInput,
updateDepartmentInputSchema,
} from '@lingxi/shared';
import { Body, Controller, Get, HttpCode, Inject, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { Audit, RequirePermissions } from '../../common/security/decorators'; import { Audit, RequirePermissions } from '../../common/security/decorators';
import { DepartmentsService, type DepartmentTreeNode } from './departments.service'; import { DepartmentsService, type DepartmentTreeNode } from './departments.service';
const createDepartmentSchema = z /** 组织架构:两级部门树 + 新建 / 更新 / 停用 / 启用(不提供删除) */
.object({
name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符'),
parentId: ulidSchema.nullable().default(null),
})
.strict();
type CreateDepartmentBody = z.infer<typeof createDepartmentSchema>;
const updateDepartmentSchema = z
.object({ name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符') })
.strict();
type UpdateDepartmentBody = z.infer<typeof updateDepartmentSchema>;
/** 组织架构:两级部门树(A1 前端只读;写接口先备好,供后续批次使用) */
@ApiTags('identity-departments') @ApiTags('identity-departments')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('departments') @Controller('departments')
@@ -31,30 +24,70 @@ export class DepartmentsController {
@Get('tree') @Get('tree')
@RequirePermissions(Permission.USER_VIEW) @RequirePermissions(Permission.USER_VIEW)
@ApiOperation({ summary: '部门树(两级,含成员数与负责人)' }) @ApiOperation({
async tree(): Promise<{ items: DepartmentTreeNode[] }> { summary: '部门树(两级)',
return { items: await this.departments.tree() }; description:
'memberCount 含下级成员、directMemberCount 仅本部门直属成员;默认只返回启用部门,管理页传 include_disabled=true',
})
@ApiQuery({ name: 'include_disabled', required: false, enum: ['true', 'false'] })
async tree(
@Query(new ZodValidationPipe(departmentTreeQuerySchema)) query: DepartmentTreeQuery,
): Promise<{ items: DepartmentTreeNode[] }> {
return { items: await this.departments.tree(query.include_disabled) };
} }
@Post() @Post()
@RequirePermissions(Permission.USER_MANAGE) @RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.create') @Audit('identity.department.create')
@ApiOperation({ summary: '新建部门 / 小组', description: '最多两级,在小组下新建返回 42201' }) @ApiOperation({
summary: '新建部门 / 小组',
description: '最多两级;父级必须启用;负责人必须是启用用户;同级重名返回 40903',
})
async create( async create(
@Body(new ZodValidationPipe(createDepartmentSchema)) input: CreateDepartmentBody, @Body(new ZodValidationPipe(createDepartmentInputSchema)) input: CreateDepartmentInput,
): Promise<{ id: string }> { ): Promise<{ id: string }> {
return this.departments.create(input); return this.departments.create(input);
} }
@Patch(':id') @Patch(':id')
@RequirePermissions(Permission.USER_MANAGE) @RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.rename') @Audit('identity.department.update')
@ApiOperation({ summary: '部门改名' }) @ApiOperation({
async rename( summary: '更新部门(名称 / 负责人 / 排序)',
description: '上级关系创建后不可移动;负责人必须是启用用户',
})
async update(
@Param('id') id: string, @Param('id') id: string,
@Body(new ZodValidationPipe(updateDepartmentSchema)) input: UpdateDepartmentBody, @Body(new ZodValidationPipe(updateDepartmentInputSchema)) input: UpdateDepartmentInput,
): Promise<{ id: string }> { ): Promise<{ id: string }> {
await this.departments.rename(id, input.name); await this.departments.update(id, input);
return { id }; return { id };
} }
@Post(':id/disable')
@HttpCode(200)
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.disable')
@ApiOperation({
summary: '停用部门',
description:
'本部门仍有在职成员、或一级部门存在启用中的下级小组 / 下级在职成员时返回 42201;已停用返回 40903',
})
async disable(
@Param('id') id: string,
): Promise<{ id: string; status: DepartmentNode['status'] }> {
return this.departments.disable(id);
}
@Post(':id/enable')
@HttpCode(200)
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.enable')
@ApiOperation({
summary: '启用部门',
description: '上级必须启用;已启用返回 40903',
})
async enable(@Param('id') id: string): Promise<{ id: string; status: DepartmentNode['status'] }> {
return this.departments.enable(id);
}
} }
@@ -0,0 +1,284 @@
import { API_PREFIX, ErrorCode, 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';
/** 与 prisma/seed.ts 一致的示例账号初始密码 lingxi2026(Argon2id) */
const PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
const PASSWORD = 'lingxi2026';
const API = API_PREFIX;
const SUFFIX = String(Date.now()).slice(-6);
const prisma = new PrismaClient();
interface Node {
id: string;
name: string;
parentId: string | null;
status: 'active' | 'disabled';
sortOrder: number;
memberCount: number;
directMemberCount: number;
leader: { id: string; name: string } | null;
children: Node[];
}
interface ErrorBody {
code: number;
message: string;
}
function departmentName(label: string): string {
return `测试${label}${SUFFIX}`;
}
async function api(
app: CreatedApiApp['app'],
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
url: string,
token: string,
payload?: unknown,
): Promise<{ status: number; body: unknown }> {
const response = await app.inject({
method,
url: `${API}${url}`,
headers: { authorization: `Bearer ${token}` },
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
});
return { status: response.statusCode, body: response.json() };
}
describe('departments 集成(真实 PG)', () => {
let created: CreatedApiApp;
let token = '';
let managerId = '';
let rootId = '';
let childId = '';
let siblingId = '';
let disabledRootId = '';
async function createDept(
name: string,
parentId: string | null,
extra: Record<string, unknown> = {},
): Promise<string> {
const response = await api(created.app, 'POST', '/departments', token, {
name,
parentId,
...extra,
});
expect(response.status).toBe(201);
return (response.body as { id: string }).id;
}
async function tree(includeDisabled: boolean): Promise<Node[]> {
const response = await api(
created.app,
'GET',
`/departments/tree?include_disabled=${String(includeDisabled)}`,
token,
);
expect(response.status).toBe(200);
return (response.body as { items: Node[] }).items;
}
function findNode(nodes: Node[], id: string): Node | undefined {
for (const node of nodes) {
if (node.id === id) return node;
const found = findNode(node.children, id);
if (found !== undefined) return found;
}
return undefined;
}
beforeAll(async () => {
loadDotenv();
created = await createApp();
await created.app.init();
await prisma.user.deleteMany({ where: { account: { startsWith: 'it.dep.' } } });
await prisma.department.deleteMany({ where: { name: { startsWith: '测试' } } });
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'tech' } });
const role = await prisma.role.findUniqueOrThrow({ where: { code: 'platform_admin' } });
managerId = ulid();
const account = `it.dep.${SUFFIX}`;
await prisma.user.create({
data: {
id: managerId,
name: '部门集成测试',
account,
email: `${account}@lingxi.cn`,
phone: `136${String(Date.now()).slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId: role.id }] },
},
});
const login = await created.app.inject({
method: 'POST',
url: `${API}/auth/login`,
payload: { account, password: PASSWORD },
});
expect(login.statusCode).toBe(200);
token = login.json<{ accessToken: string }>().accessToken;
rootId = await createDept(departmentName('一级部门'), null);
childId = await createDept(departmentName('小组A'), rootId);
siblingId = await createDept(departmentName('小组B'), rootId);
const disabledRoot = await prisma.department.create({
data: {
id: ulid(),
code: `test-disabled-${SUFFIX}`,
name: departmentName('停用部门'),
status: 'DISABLED',
},
select: { id: true },
});
disabledRootId = disabledRoot.id;
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { account: { startsWith: 'it.dep.' } } });
// 只清理本测试创建的部门(先子后父),避免误删种子 / 其他用例的数据
await prisma.department.deleteMany({
where: { name: { startsWith: '测试' }, parentId: { not: null } },
});
await prisma.department.deleteMany({ where: { name: { startsWith: '测试' } } });
await prisma.$disconnect();
await created.app.close();
});
it('部门树:默认过滤停用,include_disabled=true 返回停用节点与新增字段', async () => {
const activeTree = await tree(false);
expect(findNode(activeTree, disabledRootId)).toBeUndefined();
const root = findNode(activeTree, rootId);
expect(root).toBeDefined();
expect(root?.status).toBe('active');
expect(root?.sortOrder).toBe(0);
expect(root?.directMemberCount).toBe(0);
expect(root?.memberCount).toBe(0);
expect(root?.children.map((child) => child.id).sort()).toEqual([childId, siblingId].sort());
const fullTree = await tree(true);
expect(findNode(fullTree, disabledRootId)?.status).toBe('disabled');
});
it('新建:两级可建、超两级 42201、同级重名 40903、负责人非法 42201', async () => {
const tooDeep = await api(created.app, 'POST', '/departments', token, {
name: departmentName('三级组'),
parentId: childId,
});
expect(tooDeep.status).toBe(422);
expect((tooDeep.body as ErrorBody).code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
const duplicated = await api(created.app, 'POST', '/departments', token, {
name: departmentName('小组A'),
parentId: rootId,
});
expect(duplicated.status).toBe(409);
expect((duplicated.body as ErrorBody).code).toBe(ErrorCode.STATE_NOT_ALLOWED);
const badLeader = await api(created.app, 'POST', '/departments', token, {
name: departmentName('小组C'),
parentId: rootId,
leaderUserId: ulid(),
});
expect(badLeader.status).toBe(422);
expect((badLeader.body as ErrorBody).code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
});
it('更新:名称 / 排序 / 负责人生效,重名 40903', async () => {
const renamed = departmentName('小组A改');
const response = await api(created.app, 'PATCH', `/departments/${childId}`, token, {
name: renamed,
sortOrder: 3,
leaderUserId: managerId,
});
expect(response.status).toBe(200);
const root = findNode(await tree(false), rootId);
const child = root?.children.find((item) => item.id === childId);
expect(child?.name).toBe(renamed);
expect(child?.sortOrder).toBe(3);
expect(child?.leader?.id).toBe(managerId);
const conflict = await api(created.app, 'PATCH', `/departments/${childId}`, token, {
name: departmentName('小组B'),
});
expect(conflict.status).toBe(409);
});
it('停用:有在职成员 42201;存在启用子部门 42201;全部停用后可停用一级部门', async () => {
const member = await prisma.user.create({
data: {
id: ulid(),
name: '临时成员',
account: `it.dep.member.${SUFFIX}`,
email: `it.dep.member.${SUFFIX}@lingxi.cn`,
phone: `138${String(Date.now()).slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: childId,
mustChangePassword: false,
},
select: { id: true },
});
const blockedByMember = await api(
created.app,
'POST',
`/departments/${childId}/disable`,
token,
);
expect(blockedByMember.status).toBe(422);
expect((blockedByMember.body as ErrorBody).message).toContain('在职成员');
const blockedByChild = await api(created.app, 'POST', `/departments/${rootId}/disable`, token);
expect(blockedByChild.status).toBe(422);
expect((blockedByChild.body as ErrorBody).message).toMatch(/下级小组|在职成员/);
await prisma.user.delete({ where: { id: member.id } });
const disabledChild = await api(created.app, 'POST', `/departments/${childId}/disable`, token);
expect(disabledChild.status).toBe(200);
expect((disabledChild.body as { status: string }).status).toBe('disabled');
expect(findNode(await tree(false), childId)).toBeUndefined();
const disabledSibling = await api(
created.app,
'POST',
`/departments/${siblingId}/disable`,
token,
);
expect(disabledSibling.status).toBe(200);
const disabledRoot = await api(created.app, 'POST', `/departments/${rootId}/disable`, token);
expect(disabledRoot.status).toBe(200);
const again = await api(created.app, 'POST', `/departments/${rootId}/disable`, token);
expect(again.status).toBe(409);
expect((again.body as ErrorBody).code).toBe(ErrorCode.STATE_NOT_ALLOWED);
});
it('启用:上级停用 42201;启用后上级可启用,子部门可再启用', async () => {
const childBlocked = await api(created.app, 'POST', `/departments/${childId}/enable`, token);
expect(childBlocked.status).toBe(422);
expect((childBlocked.body as ErrorBody).message).toContain('上级部门');
const rootEnabled = await api(created.app, 'POST', `/departments/${rootId}/enable`, token);
expect(rootEnabled.status).toBe(200);
const childEnabled = await api(created.app, 'POST', `/departments/${childId}/enable`, token);
expect(childEnabled.status).toBe(200);
expect(findNode(await tree(false), childId)?.status).toBe('active');
const again = await api(created.app, 'POST', `/departments/${childId}/enable`, token);
expect(again.status).toBe(409);
});
it('不提供删除接口(DELETE 返回 404)', async () => {
const response = await api(created.app, 'DELETE', `/departments/${childId}`, token);
expect(response.status).toBe(404);
});
});
@@ -1,4 +1,10 @@
import { type DepartmentNode, ErrorCode, ulid } from '@lingxi/shared'; import {
type CreateDepartmentInput,
type DepartmentNode,
ErrorCode,
ulid,
type UpdateDepartmentInput,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
@@ -6,14 +12,24 @@ 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';
/** 带负责人的部门行(树查询 select 结果) */
type DepartmentRow = Prisma.DepartmentGetPayload<{
include: { leader: { select: { id: true; name: true } } };
}>;
/** 部门树节点:契约节点 + 子节点(最多两级) */ /** 部门树节点:契约节点 + 子节点(最多两级) */
export interface DepartmentTreeNode extends DepartmentNode { export interface DepartmentTreeNode extends DepartmentNode {
children: DepartmentTreeNode[]; children: DepartmentTreeNode[];
} }
export interface CreateDepartmentPayload { /** 状态在 DB 中用大写枚举值(与 users/roles 一致),API 契约用小写 */
name: string; const DB_ACTIVE = 'ACTIVE';
parentId: string | null; const DB_DISABLED = 'DISABLED';
const DUPLICATE_NAME_MESSAGE = '同级部门名称已存在';
function toApiStatus(status: string): DepartmentNode['status'] {
return status === DB_ACTIVE ? 'active' : 'disabled';
} }
function codeOf(name: string): string { function codeOf(name: string): string {
@@ -27,19 +43,20 @@ function codeOf(name: string): string {
function mapDepartmentError(error: unknown): AppException { function mapDepartmentError(error: unknown): AppException {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '同级部门名称已存在' }); return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: DUPLICATE_NAME_MESSAGE });
} }
return new AppException({ code: ErrorCode.INTERNAL_ERROR, message: '部门保存失败' }); return new AppException({ code: ErrorCode.INTERNAL_ERROR, message: '部门保存失败' });
} }
/** 组织架构:两级部门树(含成员数与负责人)、新建、改名 */ /** 组织架构:两级部门树、新建、更新、停用 / 启用(不提供删除) */
@Injectable() @Injectable()
export class DepartmentsService { export class DepartmentsService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async tree(): Promise<DepartmentTreeNode[]> { async tree(includeDisabled: boolean): Promise<DepartmentTreeNode[]> {
const [departments, counts] = await Promise.all([ const [departments, counts, lineage] = await Promise.all([
this.prisma.department.findMany({ this.prisma.department.findMany({
where: includeDisabled ? {} : { status: DB_ACTIVE },
orderBy: [{ parentId: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }], orderBy: [{ parentId: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
include: { leader: { select: { id: true, name: true } } }, include: { leader: { select: { id: true, name: true } } },
}), }),
@@ -48,16 +65,30 @@ export class DepartmentsService {
where: { status: 'ACTIVE' }, where: { status: 'ACTIVE' },
_count: { _all: true }, _count: { _all: true },
}), }),
// 全量父子关系:即使子部门被过滤,也要把其直属在职成员计入一级部门 memberCount
this.prisma.department.findMany({ select: { id: true, parentId: true } }),
]); ]);
const byDept = new Map(counts.map((row) => [row.departmentId, row._count._all])); const byDept = new Map(counts.map((row) => [row.departmentId, row._count._all]));
return this.buildTree(departments, byDept, lineage);
}
private buildTree(
departments: DepartmentRow[],
byDept: Map<string, number>,
lineage: { id: string; parentId: string | null }[],
): DepartmentTreeNode[] {
const nodes = new Map<string, DepartmentTreeNode>(); const nodes = new Map<string, DepartmentTreeNode>();
for (const department of departments) { for (const department of departments) {
const direct = byDept.get(department.id) ?? 0;
nodes.set(department.id, { nodes.set(department.id, {
id: department.id, id: department.id,
name: department.name, name: department.name,
parentId: department.parentId, parentId: department.parentId,
leader: department.leader, leader: department.leader,
memberCount: byDept.get(department.id) ?? 0, status: toApiStatus(department.status),
sortOrder: department.sortOrder,
memberCount: direct,
directMemberCount: direct,
createdAt: department.createdAt.toISOString(), createdAt: department.createdAt.toISOString(),
children: [], children: [],
}); });
@@ -68,25 +99,36 @@ export class DepartmentsService {
roots.push(node); roots.push(node);
continue; continue;
} }
const parent = nodes.get(node.parentId); nodes.get(node.parentId)?.children.push(node);
if (parent !== undefined) { }
parent.children.push(node); const childDirect = new Map<string, number>();
for (const row of lineage) {
if (row.parentId !== null) {
childDirect.set(
row.parentId,
(childDirect.get(row.parentId) ?? 0) + (byDept.get(row.id) ?? 0),
);
} }
} }
for (const root of roots) { for (const root of roots) {
root.memberCount += root.children.reduce((sum, child) => sum + child.memberCount, 0); root.memberCount += childDirect.get(root.id) ?? 0;
} }
return roots; return roots;
} }
async create(input: CreateDepartmentPayload): Promise<{ id: string }> { async create(input: CreateDepartmentInput): Promise<{ id: string }> {
await this.assertParentUsable(input.parentId); await this.assertParentUsable(input.parentId);
await this.assertLeaderUsable(input.leaderUserId);
await this.assertNameFree(input.name, input.parentId, null);
try { try {
const created = await this.prisma.department.create({ const created = await this.prisma.department.create({
data: { data: {
id: ulid(), id: ulid(),
name: input.name, name: input.name,
parentId: input.parentId, parentId: input.parentId,
leaderUserId: input.leaderUserId ?? null,
sortOrder: input.sortOrder ?? 0,
status: DB_ACTIVE,
code: codeOf(input.name), code: codeOf(input.name),
}, },
select: { id: true }, select: { id: true },
@@ -97,26 +139,85 @@ export class DepartmentsService {
} }
} }
async rename(id: string, name: string): Promise<void> { async update(id: string, input: UpdateDepartmentInput): Promise<void> {
const existing = await this.prisma.department.findUnique({ where: { id } }); const existing = await this.findOrThrow(id);
if (existing === null) { if (input.leaderUserId !== undefined) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' }); await this.assertLeaderUsable(input.leaderUserId);
} }
if (input.name !== undefined) {
await this.assertNameFree(input.name, existing.parentId, id);
}
const data: Prisma.DepartmentUpdateInput = {
...(input.name === undefined ? {} : { name: input.name }),
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
...(input.leaderUserId === undefined
? {}
: {
leader:
input.leaderUserId === null
? { disconnect: true }
: { connect: { id: input.leaderUserId } },
}),
};
try { try {
await this.prisma.department.update({ where: { id }, data: { name } }); await this.prisma.department.update({ where: { id }, data });
} catch (error) { } catch (error) {
throw mapDepartmentError(error); throw mapDepartmentError(error);
} }
} }
async disable(id: string): Promise<{ id: string; status: DepartmentNode['status'] }> {
const existing = await this.findOrThrow(id);
if (existing.status !== DB_ACTIVE) {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '部门已是停用状态' });
}
await this.assertNoActiveMembers(existing);
await this.prisma.department.update({ where: { id }, data: { status: DB_DISABLED } });
return { id, status: 'disabled' };
}
async enable(id: string): Promise<{ id: string; status: DepartmentNode['status'] }> {
const existing = await this.findOrThrow(id);
if (existing.status === DB_ACTIVE) {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '部门已是启用状态' });
}
await this.assertParentActiveForEnable(existing.parentId);
await this.prisma.department.update({ where: { id }, data: { status: DB_ACTIVE } });
return { id, status: 'active' };
}
private async findOrThrow(id: string): Promise<{
id: string;
parentId: string | null;
status: string;
}> {
const department = await this.prisma.department.findUnique({
where: { id },
select: { id: true, parentId: true, status: true },
});
if (department === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' });
}
return department;
}
private async assertParentUsable(parentId: string | null): Promise<void> { private async assertParentUsable(parentId: string | null): Promise<void> {
if (parentId === null) { if (parentId === null) {
return; return;
} }
const parent = await this.prisma.department.findUnique({ where: { id: parentId } }); const parent = await this.prisma.department.findUnique({
where: { id: parentId },
select: { parentId: true, status: true },
});
if (parent === null) { if (parent === null) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '上级部门不存在' }); throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '上级部门不存在' });
} }
if (parent.status !== DB_ACTIVE) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '上级部门已停用,无法在其下新建部门',
});
}
if (parent.parentId !== null) { if (parent.parentId !== null) {
throw new AppException({ throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION, code: ErrorCode.BUSINESS_RULE_VIOLATION,
@@ -124,4 +225,93 @@ export class DepartmentsService {
}); });
} }
} }
private async assertLeaderUsable(leaderUserId: string | null | undefined): Promise<void> {
if (leaderUserId === null || leaderUserId === undefined) {
return;
}
const user = await this.prisma.user.findUnique({
where: { id: leaderUserId },
select: { status: true },
});
if (user?.status !== 'ACTIVE') {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '负责人必须是启用状态的用户',
});
}
}
private async assertNameFree(
name: string,
parentId: string | null,
excludeId: string | null,
): Promise<void> {
const duplicated = await this.prisma.department.findFirst({
where: {
parentId,
name,
...(excludeId === null ? {} : { id: { not: excludeId } }),
},
select: { id: true },
});
if (duplicated !== null) {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: DUPLICATE_NAME_MESSAGE,
});
}
}
private async assertNoActiveMembers(department: {
id: string;
parentId: string | null;
}): Promise<void> {
const direct = await this.prisma.user.count({
where: { departmentId: department.id, status: 'ACTIVE' },
});
if (direct > 0) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: `部门下仍有 ${String(direct)} 名在职成员,请先调整成员所属部门后再停用`,
});
}
if (department.parentId !== null) {
return;
}
const activeChildren = await this.prisma.department.count({
where: { parentId: department.id, status: DB_ACTIVE },
});
if (activeChildren > 0) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: `存在 ${String(activeChildren)} 个启用中的下级小组,请先停用下级小组`,
});
}
const childMembers = await this.prisma.user.count({
where: { status: 'ACTIVE', department: { parentId: department.id } },
});
if (childMembers > 0) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: `下级小组仍有 ${String(childMembers)} 名在职成员,请先调整成员所属部门`,
});
}
}
private async assertParentActiveForEnable(parentId: string | null): Promise<void> {
if (parentId === null) {
return;
}
const parent = await this.prisma.department.findUnique({
where: { id: parentId },
select: { status: true },
});
if (parent?.status !== DB_ACTIVE) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '上级部门已停用,请先启用上级部门',
});
}
}
} }
@@ -318,7 +318,9 @@ describe('identity 集成(真实 PG / Redis)', () => {
expect(tree.statusCode).toBe(200); expect(tree.statusCode).toBe(200);
const treeBody = tree.json<{ items: { name: string; children: unknown[] }[] }>(); const treeBody = tree.json<{ items: { name: string; children: unknown[] }[] }>();
expect(treeBody.items.length).toBeGreaterThanOrEqual(3); expect(treeBody.items.length).toBeGreaterThanOrEqual(3);
expect(treeBody.items[0]?.children.length).toBeGreaterThan(0); // 根部门按 sortOrder 排序,管理员可新建无子级的根部门并排在最前;
// 这里只校验树里存在带子级的根部门,不依赖当前第一项(本地手工数据会让测试失真)。
expect(treeBody.items.some((item) => item.children.length > 0)).toBe(true);
const roles = await created.app.inject({ const roles = await created.app.inject({
method: 'GET', method: 'GET',
@@ -614,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);
@@ -637,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([]);
}); });
}); });
@@ -259,8 +259,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 () => {
@@ -293,73 +291,253 @@ describe('UsersService', () => {
}); });
describe('DepartmentsService', () => { describe('DepartmentsService', () => {
it('部门树:两级嵌套且一级成员数含下级', async () => { function serviceOf(prisma: Record<string, unknown>): DepartmentsService {
const departments = [ return new DepartmentsService(prisma as unknown as PrismaService);
{ }
function departmentRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'd1', id: 'd1',
code: 'content', code: 'content',
name: '内容生产中心', name: '内容生产中心',
parentId: null, parentId: null,
leaderUserId: null, leaderUserId: null,
sortOrder: 1, sortOrder: 0,
status: 'ACTIVE',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date(),
leader: { id: 'u1', name: '陈默' },
},
{
id: 'g1',
code: 'content-g1',
name: '素材一组',
parentId: 'd1',
leaderUserId: null,
sortOrder: 1,
status: 'ACTIVE', status: 'ACTIVE',
createdAt: new Date('2026-01-01T00:00:00.000Z'), createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date(), updatedAt: new Date(),
leader: null, leader: null,
}, ...overrides,
};
}
it('部门树:默认只返回启用部门;memberCount 含下级、directMemberCount 仅直属', async () => {
const rows = [
departmentRow({ id: 'd1', name: '内容生产中心' }),
departmentRow({ id: 'g1', name: '素材一组', parentId: 'd1' }),
]; ];
const service = new DepartmentsService({ const findMany = vi.fn().mockResolvedValue(rows);
department: { findMany: vi.fn().mockResolvedValue(departments) }, const service = serviceOf({
user: { groupBy: vi.fn().mockResolvedValue([{ departmentId: 'g1', _count: { _all: 5 } }]) }, department: { findMany },
} as unknown as PrismaService); user: {
const tree = await service.tree(); groupBy: vi.fn().mockResolvedValue([
{ departmentId: 'd1', _count: { _all: 3 } },
{ departmentId: 'g1', _count: { _all: 5 } },
]),
},
});
const tree = await service.tree(false);
expect(findMany.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ where: { status: 'ACTIVE' } }),
);
expect(tree).toHaveLength(1); expect(tree).toHaveLength(1);
expect(tree[0]?.memberCount).toBe(5); expect(tree[0]?.memberCount).toBe(8);
expect(tree[0]?.directMemberCount).toBe(3);
expect(tree[0]?.children[0]?.name).toBe('素材一组'); expect(tree[0]?.children[0]?.name).toBe('素材一组');
expect(tree[0]?.children[0]?.memberCount).toBe(5);
expect(tree[0]?.status).toBe('active');
}); });
it('新建:父级不存在 40001,超过两级 42201', async () => { it('部门树:include_disabled=true 时包含停用部门且状态映射为 disabled', async () => {
const service = new DepartmentsService({ const service = serviceOf({
department: { department: {
findUnique: vi.fn().mockResolvedValueOnce(null), findMany: vi
.fn()
.mockResolvedValue([
departmentRow({ id: 'd1' }),
departmentRow({ id: 'd2', status: 'DISABLED' }),
]),
}, },
} as unknown as PrismaService); user: { groupBy: vi.fn().mockResolvedValue([]) },
});
const tree = await service.tree(true);
expect(tree).toHaveLength(2);
expect(tree[1]?.status).toBe('disabled');
expect(tree[0]?.sortOrder).toBe(0);
});
it('新建:父级不存在 40001、父级停用 / 超两级 42201、负责人非法 42201', async () => {
await expectRejected( await expectRejected(
service.create({ name: '新组', parentId: 'missing' }), serviceOf({ department: { findUnique: vi.fn().mockResolvedValue(null) } }).create({
name: '新部门',
parentId: 'missing',
}),
ErrorCode.PARAM_INVALID, ErrorCode.PARAM_INVALID,
); );
const service2 = new DepartmentsService({
department: {
findUnique: vi.fn().mockResolvedValue({
id: 'g1',
parentId: 'd1',
}),
},
} as unknown as PrismaService);
await expectRejected( await expectRejected(
service2.create({ name: '三级', parentId: 'g1' }), serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue({ parentId: null, status: 'DISABLED' }),
},
}).create({ name: '新小组', parentId: 'd1' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue({ parentId: 'd0', status: 'ACTIVE' }),
},
}).create({ name: '三级组', parentId: 'g1' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: { findFirst: vi.fn() },
user: { findUnique: vi.fn().mockResolvedValue({ status: 'DISABLED' }) },
}).create({ name: '新部门', parentId: null, leaderUserId: 'u-disabled' }),
ErrorCode.BUSINESS_RULE_VIOLATION, ErrorCode.BUSINESS_RULE_VIOLATION,
); );
}); });
it('重命名不存在的部门返回 40401', async () => { it('新建:同级重名 40903,成功后落库 sortOrder / status / 负责人', async () => {
const service = new DepartmentsService({ const duplicated = serviceOf({
department: { findUnique: vi.fn().mockResolvedValue(null) }, department: { findFirst: vi.fn().mockResolvedValue({ id: 'other' }) },
} as unknown as PrismaService); });
await expectRejected(service.rename('missing', '新名字'), ErrorCode.NOT_FOUND); await expectRejected(
duplicated.create({ name: '内容生产中心', parentId: null }),
ErrorCode.STATE_NOT_ALLOWED,
);
const create = vi.fn().mockResolvedValue({ id: 'new-id' });
const service = serviceOf({
department: { findFirst: vi.fn().mockResolvedValue(null), create },
user: { findUnique: vi.fn().mockResolvedValue({ status: 'ACTIVE' }) },
});
await expect(
service.create({ name: '内容生产中心', parentId: null, leaderUserId: 'u1', sortOrder: 5 }),
).resolves.toEqual({ id: 'new-id' });
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ sortOrder: 5, status: 'ACTIVE', leaderUserId: 'u1' }),
}),
);
});
it('更新:不存在 40401、负责人非法 42201、重名 40903、支持清空负责人', async () => {
await expectRejected(
serviceOf({ department: { findUnique: vi.fn().mockResolvedValue(null) } }).update('missing', {
name: '新名',
}),
ErrorCode.NOT_FOUND,
);
await expectRejected(
serviceOf({
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) },
user: { findUnique: vi.fn().mockResolvedValue(null) },
}).update('d1', { leaderUserId: 'u-missing' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow()),
findFirst: vi.fn().mockResolvedValue({ id: 'other' }),
},
}).update('d1', { name: '旧部门' }),
ErrorCode.STATE_NOT_ALLOWED,
);
const update = vi.fn().mockResolvedValue(departmentRow());
const service = serviceOf({
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()), update },
});
await service.update('d1', { leaderUserId: null, sortOrder: 2 });
expect(update).toHaveBeenCalledWith({
where: { id: 'd1' },
data: { sortOrder: 2, leader: { disconnect: true } },
});
});
it('停用:已停用 40903;本部门有在职成员 / 启用子部门 / 下级成员分别 42201', async () => {
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' })),
},
}).disable('d1'),
ErrorCode.STATE_NOT_ALLOWED,
);
await expectRejected(
serviceOf({
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) },
user: { count: vi.fn().mockResolvedValue(2) },
}).disable('d1'),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow()),
count: vi.fn().mockResolvedValue(1),
},
user: { count: vi.fn().mockResolvedValue(0) },
}).disable('d1'),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow()),
count: vi.fn().mockResolvedValue(0),
},
user: { count: vi.fn().mockResolvedValueOnce(0).mockResolvedValueOnce(3) },
}).disable('d1'),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
});
it('停用成功:无成员无启用子部门时落库 DISABLED;不提供删除方法', async () => {
const update = vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' }));
const service = serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow()),
count: vi.fn().mockResolvedValue(0),
update,
},
user: { count: vi.fn().mockResolvedValue(0) },
});
await expect(service.disable('d1')).resolves.toEqual({ id: 'd1', status: 'disabled' });
expect(update).toHaveBeenCalledWith({ where: { id: 'd1' }, data: { status: 'DISABLED' } });
expect('delete' in service).toBe(false);
});
it('启用:已启用 40903、上级停用 42201、成功后落库 ACTIVE', async () => {
await expectRejected(
serviceOf({
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) },
}).enable('d1'),
ErrorCode.STATE_NOT_ALLOWED,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi
.fn()
.mockResolvedValueOnce(departmentRow({ status: 'DISABLED', parentId: 'd0' }))
.mockResolvedValueOnce({ status: 'DISABLED' }),
},
}).enable('g1'),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
const update = vi.fn().mockResolvedValue(departmentRow());
const service = serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' })),
update,
},
});
await expect(service.enable('d1')).resolves.toEqual({ id: 'd1', status: 'active' });
expect(update).toHaveBeenCalledWith({ where: { id: 'd1' }, data: { status: 'ACTIVE' } });
}); });
}); });
@@ -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 -9
View File
@@ -180,14 +180,6 @@ 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 建议 ≤ 2 人):只记录告警,不阻断保存;
* 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。 * 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。
@@ -219,10 +211,19 @@ export class UsersService {
} }
private async ensureDepartment(departmentId: string): Promise<void> { private async ensureDepartment(departmentId: string): Promise<void> {
const department = await this.prisma.department.findUnique({ where: { id: departmentId } }); const department = await this.prisma.department.findUnique({
where: { id: departmentId },
select: { status: true },
});
if (department === null) { if (department === null) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '所属部门不存在' }); throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '所属部门不存在' });
} }
if (department.status !== 'ACTIVE') {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '所属部门已停用,请选择启用中的部门',
});
}
} }
private async ensureRoles(roleIds: string[]): Promise<void> { private async ensureRoles(roleIds: string[]): Promise<void> {
+3
View File
@@ -6,5 +6,8 @@ export default defineConfig({
include: ['src/**/*.int.spec.ts'], include: ['src/**/*.int.spec.ts'],
testTimeout: 30_000, testTimeout: 30_000,
hookTimeout: 30_000, hookTimeout: 30_000,
// 集成测试共用本地真实 PG / Redis,且各文件会清理带前缀的夹具(如 it.* 与 it.dep.* 重叠)。
// 文件级并行会让跨文件的夹具清理 / 数据断言互相干扰,因此串行执行。
fileParallelism: false,
}, },
}); });
+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);
}
+127
View File
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import {
createDepartment,
disableDepartment,
enableDepartment,
fetchDepartments,
updateDepartment,
} from './identity';
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ROOT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const CHILD_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3';
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
const root = {
id: ROOT_ID,
name: '内容生产中心',
parentId: null,
leader: { id: USER_ID, name: '陈默' },
status: 'active',
sortOrder: 1,
memberCount: 19,
directMemberCount: 4,
createdAt: '2026-11-01T00:00:00.000Z',
children: [
{
id: CHILD_ID,
name: '素材一组',
parentId: ROOT_ID,
leader: null,
status: 'disabled',
sortOrder: 2,
memberCount: 9,
directMemberCount: 9,
createdAt: '2026-11-01T00:00:00.000Z',
children: [],
},
],
};
function calls(): { url: string; init: RequestInit | undefined }[] {
return vi.mocked(globalThis.fetch).mock.calls.map((call) => {
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 = calls().at(-1)?.init?.body;
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
}
describe('部门接口', () => {
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
useAuthStore.getState().clearSession();
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: USER_ID, name: '陈默', account: 'chen.mo' },
permissions: ['user:view', 'user:manage'],
});
});
it('部门树默认只取启用部门,includeDisabled 时带 query', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [root] }))
.mockResolvedValueOnce(jsonResponse({ items: [root] }));
const activeOnly = await fetchDepartments();
expect(String(calls()[0]?.url)).toContain('/departments/tree?include_disabled=false');
expect(activeOnly.map((node) => node.name)).toEqual(['内容生产中心', '素材一组']);
expect(activeOnly[1]?.status).toBe('disabled');
await fetchDepartments(true);
expect(String(calls()[1]?.url)).toContain('/departments/tree?include_disabled=true');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('新建返回部门 id,请求走 POST /departments', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse({ id: CHILD_ID }));
await expect(
createDepartment({
name: '设计组',
parentId: ROOT_ID,
leaderUserId: null,
sortOrder: 3,
}),
).resolves.toBe(CHILD_ID);
expect(calls()[0]?.init?.method).toBe('POST');
expect(String(calls()[0]?.url)).toContain('/departments');
expect(lastBody()).toEqual({
name: '设计组',
parentId: ROOT_ID,
leaderUserId: null,
sortOrder: 3,
});
});
it('更新 / 停用 / 启用使用约定方法与路径', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
await updateDepartment(CHILD_ID, { name: '设计组', leaderUserId: null });
await disableDepartment(CHILD_ID);
await enableDepartment(CHILD_ID);
const requestLines = calls().map((call) => {
const url = new URL(call.url);
return `${call.init?.method ?? 'GET'} ${url.pathname.replace('/api/v1', '')}`;
});
expect(requestLines).toEqual([
`PATCH /departments/${CHILD_ID}`,
`POST /departments/${CHILD_ID}/disable`,
`POST /departments/${CHILD_ID}/enable`,
]);
expect(lastBody()).toBeUndefined();
});
});
+30
View File
@@ -19,7 +19,37 @@ export const ENDPOINTS = {
userTransferCustomers: (userId: string): string => userTransferCustomers: (userId: string): string =>
`${USERS}/${encodeURIComponent(userId)}/transfer-customers`, `${USERS}/${encodeURIComponent(userId)}/transfer-customers`,
departments: '/departments/tree', departments: '/departments/tree',
departmentsRoot: '/departments',
department: (departmentId: string): string => `/departments/${encodeURIComponent(departmentId)}`,
departmentDisable: (departmentId: string): string =>
`/departments/${encodeURIComponent(departmentId)}/disable`,
departmentEnable: (departmentId: string): string =>
`/departments/${encodeURIComponent(departmentId)}/enable`,
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`,
} as const; } as const;
+794 -43
View File
@@ -177,26 +177,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
'/api/v1/users/{id}/transfer-customers': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 客户转交(占位)
* @description A3 客户模块落地前返回 42201
*/
post: operations['UsersController_transferCustomers'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/departments/tree': { '/api/v1/departments/tree': {
parameters: { parameters: {
query?: never; query?: never;
@@ -204,7 +184,10 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** 部门树(两级,含成员数与负责人) */ /**
* 部门树(两级)
* @description memberCount 含下级成员、directMemberCount 仅本部门直属成员;默认只返回启用部门,管理页传 include_disabled=true
*/
get: operations['DepartmentsController_tree']; get: operations['DepartmentsController_tree'];
put?: never; put?: never;
post?: never; post?: never;
@@ -225,7 +208,7 @@ export interface paths {
put?: never; put?: never;
/** /**
* 新建部门 / 小组 * 新建部门 / 小组
* @description 最多两级,在小组下新建返回 42201 * @description 最多两级;父级必须启用;负责人必须是启用用户;同级重名返回 40903
*/ */
post: operations['DepartmentsController_create']; post: operations['DepartmentsController_create'];
delete?: never; delete?: never;
@@ -247,8 +230,51 @@ export interface paths {
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
/** 部门改名 */ /**
patch: operations['DepartmentsController_rename']; * 更新部门(名称 / 负责人 / 排序)
* @description 上级关系创建后不可移动;负责人必须是启用用户
*/
patch: operations['DepartmentsController_update'];
trace?: never;
};
'/api/v1/departments/{id}/disable': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 停用部门
* @description 本部门仍有在职成员、或一级部门存在启用中的下级小组 / 下级在职成员时返回 42201;已停用返回 40903
*/
post: operations['DepartmentsController_disable'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/departments/{id}/enable': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 启用部门
* @description 上级必须启用;已启用返回 40903
*/
post: operations['DepartmentsController_enable'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never; trace?: never;
}; };
'/api/v1/roles': { '/api/v1/roles': {
@@ -320,6 +346,287 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
'/api/v1/industries': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 两级行业树(一级 → 二级) */
get: operations['IndustriesController_list'];
put?: never;
/** 新建一级 / 二级行业 */
post: operations['IndustriesController_create'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/industries/{id}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 行业详情与引用(子行业 / 客户 / 资产) */
get: operations['IndustriesController_detail'];
put?: never;
post?: never;
/** 删除行业(仅无子行业 / 客户 / 资产时) */
delete: operations['IndustriesController_remove'];
options?: never;
head?: never;
/** 更新行业名称 / 排序 / 备注(层级与上级不可变) */
patch: operations['IndustriesController_update'];
trace?: never;
};
'/api/v1/industries/{id}/status': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** 停用 / 启用行业(有启用子行业时禁止停用) */
patch: operations['IndustriesController_setStatus'];
trace?: never;
};
'/api/v1/customers': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选) */
get: operations['CustomersController_list'];
put?: never;
/** 新建客户(简称必填;行业必须为末级行业) */
post: operations['CustomersController_create'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/customers/{id}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 客户详情(跨客户访问统一 404) */
get: operations['CustomersController_detail'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** 更新客户档案(所属行业不可改;暂停 / 结束只读) */
patch: operations['CustomersController_update'];
trace?: never;
};
'/api/v1/customers/{id}/status': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** 客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆) */
patch: operations['CustomersController_setStatus'];
trace?: never;
};
'/api/v1/customers/{id}/transfer': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** 变更客户负责人(写审计) */
post: operations['CustomersController_transfer'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/customers/{id}/groups': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** 设置客户所属客户组(覆盖式) */
put: operations['CustomersController_setGroups'];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/customers/{id}/apply-industry-pack': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** 套用行业资产包(B0 批次提供;当前返回业务提示) */
post: operations['CustomersController_applyIndustryPack'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/users/{id}/transfer-customers': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** 把用户名下客户转交给另一负责人 */
post: operations['CustomerTransferController_transfer'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/customer-groups': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 客户组列表(授权与数据范围最小单位) */
get: operations['CustomerGroupsController_list'];
put?: never;
/** 新建客户组 */
post: operations['CustomerGroupsController_create'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/customer-groups/{id}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 客户组详情与成员 */
get: operations['CustomerGroupsController_detail'];
put?: never;
post?: never;
/** 删除客户组(被角色数据范围引用时拒绝) */
delete: operations['CustomerGroupsController_remove'];
options?: never;
head?: never;
/** 更新客户组名称 / 说明 */
patch: operations['CustomerGroupsController_update'];
trace?: never;
};
'/api/v1/customer-groups/{id}/members': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** 批量添加 / 移除成员(幂等) */
post: operations['CustomerGroupsController_updateMembers'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/customer-fields': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 字段审批队列(待审批 / 已生效 / 已驳回) */
get: operations['CustomerFieldsController_list'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/customer-fields/{id}/approve': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** 审批客户级字段(通过 / 驳回;驳回原因必填) */
post: operations['CustomerFieldsController_approve'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/customers/{id}/fields': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 客户级字段列表(基础字段在 customers.custom_fields) */
get: operations['CustomerFieldsOfCustomerController_list'];
put?: never;
/** 申请客户级字段(进入生成字段需审批;每客户 ≤ 5) */
post: operations['CustomerFieldsOfCustomerController_apply'];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/health': { '/health': {
parameters: { parameters: {
query?: never; query?: never;
@@ -638,26 +945,11 @@ export interface operations {
}; };
}; };
}; };
UsersController_transferCustomers: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DepartmentsController_tree: { DepartmentsController_tree: {
parameters: { parameters: {
query?: never; query?: {
include_disabled?: 'true' | 'false';
};
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
@@ -689,7 +981,41 @@ export interface operations {
}; };
}; };
}; };
DepartmentsController_rename: { DepartmentsController_update: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DepartmentsController_disable: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
DepartmentsController_enable: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -808,6 +1134,431 @@ export interface operations {
}; };
}; };
}; };
IndustriesController_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IndustriesController_create: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IndustriesController_detail: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IndustriesController_remove: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IndustriesController_update: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IndustriesController_setStatus: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomersController_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomersController_create: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomersController_detail: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomersController_update: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomersController_setStatus: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomersController_transfer: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomersController_setGroups: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomersController_applyIndustryPack: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomerTransferController_transfer: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomerGroupsController_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomerGroupsController_create: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomerGroupsController_detail: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomerGroupsController_remove: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomerGroupsController_update: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomerGroupsController_updateMembers: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomerFieldsController_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomerFieldsController_approve: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomerFieldsOfCustomerController_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CustomerFieldsOfCustomerController_apply: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
201: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
HealthController_check: { HealthController_check: {
parameters: { parameters: {
query?: never; query?: never;
+6
View File
@@ -110,7 +110,10 @@ describe('identity 接口封装', () => {
name: '内容生产中心', name: '内容生产中心',
parentId: null, parentId: null,
leader: null, leader: null,
status: 'active',
sortOrder: 1,
memberCount: 19, memberCount: 19,
directMemberCount: 4,
createdAt: '2026-09-01T00:00:00.000Z', createdAt: '2026-09-01T00:00:00.000Z',
children: [ children: [
{ {
@@ -118,7 +121,10 @@ describe('identity 接口封装', () => {
name: '素材一组', name: '素材一组',
parentId: GROUP_ID, parentId: GROUP_ID,
leader: null, leader: null,
status: 'active',
sortOrder: 1,
memberCount: 9, memberCount: 9,
directMemberCount: 9,
createdAt: '2026-09-01T00:00:00.000Z', createdAt: '2026-09-01T00:00:00.000Z',
children: [], children: [],
}, },
+39 -5
View File
@@ -1,6 +1,8 @@
import { import {
type ChangePasswordInput, type ChangePasswordInput,
changePasswordInputSchema, changePasswordInputSchema,
type CreateDepartmentInput,
createDepartmentInputSchema,
type CreateUserInput, type CreateUserInput,
createUserInputSchema, createUserInputSchema,
type DepartmentNode, type DepartmentNode,
@@ -17,6 +19,8 @@ import {
roleSummarySchema, roleSummarySchema,
type TransferCustomersInput, type TransferCustomersInput,
transferCustomersInputSchema, transferCustomersInputSchema,
type UpdateDepartmentInput,
updateDepartmentInputSchema,
type UpdateUserInput, type UpdateUserInput,
updateUserInputSchema, updateUserInputSchema,
} from '@lingxi/shared'; } from '@lingxi/shared';
@@ -32,7 +36,7 @@ export interface CustomerSummary {
} }
const customerSummarySchema = z.object({ id: z.string().min(1), name: z.string().min(1) }); const customerSummarySchema = z.object({ id: z.string().min(1), name: z.string().min(1) });
const createdUserSchema = z.object({ id: z.string().min(1) }).passthrough(); const createdIdSchema = z.object({ id: z.string().min(1) }).passthrough();
/** 兼容 `T[]` 与 `{ items: T[] }` 两种列表返回,逐项用共享 schema 校验 */ /** 兼容 `T[]` 与 `{ items: T[] }` 两种列表返回,逐项用共享 schema 校验 */
function parseList<T>(payload: unknown, itemSchema: z.ZodType<T>): T[] { function parseList<T>(payload: unknown, itemSchema: z.ZodType<T>): T[] {
@@ -69,9 +73,13 @@ export async function fetchMe(): Promise<MeResponse> {
return meResponseSchema.parse(payload); return meResponseSchema.parse(payload);
} }
/** 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用 */ /**
export async function fetchDepartments(): Promise<DepartmentNode[]> { * 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用。
const payload: unknown = await request(ENDPOINTS.departments); * includeDisabled=true 时包含已停用部门(部门管理页需要),默认只看启用。
*/
export async function fetchDepartments(includeDisabled = false): Promise<DepartmentNode[]> {
const search = includeDisabled ? '?include_disabled=true' : '?include_disabled=false';
const payload: unknown = await request(`${ENDPOINTS.departments}${search}`);
const raw: unknown = isRecord(payload) && Array.isArray(payload.items) ? payload.items : payload; const raw: unknown = isRecord(payload) && Array.isArray(payload.items) ? payload.items : payload;
if (!Array.isArray(raw)) { if (!Array.isArray(raw)) {
return []; return [];
@@ -93,6 +101,32 @@ export async function fetchDepartments(): Promise<DepartmentNode[]> {
return flat; return flat;
} }
export async function createDepartment(input: CreateDepartmentInput): Promise<string> {
const body = createDepartmentInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.departmentsRoot, { method: 'POST', body });
const parsed = createdIdSchema.safeParse(payload);
if (!parsed.success) {
throw new Error('部门创建成功但未返回部门 ID');
}
return parsed.data.id;
}
export async function updateDepartment(
departmentId: string,
input: UpdateDepartmentInput,
): Promise<void> {
const body = updateDepartmentInputSchema.parse(input);
await request<unknown>(ENDPOINTS.department(departmentId), { method: 'PATCH', body });
}
export async function disableDepartment(departmentId: string): Promise<void> {
await request<unknown>(ENDPOINTS.departmentDisable(departmentId), { method: 'POST' });
}
export async function enableDepartment(departmentId: string): Promise<void> {
await request<unknown>(ENDPOINTS.departmentEnable(departmentId), { method: 'POST' });
}
export async function fetchRoles(): Promise<RoleSummary[]> { export async function fetchRoles(): Promise<RoleSummary[]> {
const payload: unknown = await request(ENDPOINTS.roles); const payload: unknown = await request(ENDPOINTS.roles);
return parseList(payload, roleSummarySchema); return parseList(payload, roleSummarySchema);
@@ -102,7 +136,7 @@ export async function fetchRoles(): Promise<RoleSummary[]> {
export async function createUser(input: CreateUserInput): Promise<string | null> { export async function createUser(input: CreateUserInput): Promise<string | null> {
const body = createUserInputSchema.parse(input); const body = createUserInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.users, { method: 'POST', body }); const payload: unknown = await request(ENDPOINTS.users, { method: 'POST', body });
const parsed = createdUserSchema.safeParse(payload); const parsed = createdIdSchema.safeParse(payload);
return parsed.success ? parsed.data.id : null; return parsed.success ? parsed.data.id : null;
} }
+14 -1
View File
@@ -8,9 +8,22 @@ export const queryKeys = {
userList: (query: UserListQueryInput) => ['users', 'list', query] as const, userList: (query: UserListQueryInput) => ['users', 'list', query] as const,
user: (userId: string) => ['users', 'detail', userId] as const, user: (userId: string) => ['users', 'detail', userId] as const,
userCustomers: (userId: string) => ['users', 'customers', userId] as const, userCustomers: (userId: string) => ['users', 'customers', userId] as const,
departments: ['departments'] as const, departments: (includeDisabled: boolean) => ['departments', { includeDisabled }] as const,
roles: ['roles'] as const, roles: ['roles'] as const,
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,
}; };
+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();
},
});
}
+85 -6
View File
@@ -1,13 +1,32 @@
import { type DepartmentNode, type RoleSummary } from '@lingxi/shared'; import type {
import { useQuery, type UseQueryResult } from '@tanstack/react-query'; CreateDepartmentInput,
DepartmentNode,
RoleSummary,
UpdateDepartmentInput,
} from '@lingxi/shared';
import {
useMutation,
type UseMutationResult,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import { fetchDepartments, fetchRoles } from '@/api/identity'; import {
createDepartment,
disableDepartment,
enableDepartment,
fetchDepartments,
fetchRoles,
updateDepartment,
} from '@/api/identity';
import { queryKeys } from '@/api/query-keys'; import { queryKeys } from '@/api/query-keys';
export function useDepartmentsQuery(): UseQueryResult<DepartmentNode[]> { /** 部门树:默认只取启用部门(用户页选择器);部门管理页传 includeDisabled=true */
export function useDepartmentsQuery(includeDisabled = false): UseQueryResult<DepartmentNode[]> {
return useQuery({ return useQuery({
queryKey: queryKeys.departments, queryKey: queryKeys.departments(includeDisabled),
queryFn: fetchDepartments, queryFn: () => fetchDepartments(includeDisabled),
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
} }
@@ -19,3 +38,63 @@ export function useRolesQuery(): UseQueryResult<RoleSummary[]> {
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
} }
/** 部门写操作:失效部门树与用户缓存(用户列表/详情的部门名称与人数会变) */
function useInvalidateDirectory(): () => Promise<void> {
const queryClient = useQueryClient();
return async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['departments'] }),
queryClient.invalidateQueries({ queryKey: queryKeys.users }),
]);
};
}
export function useCreateDepartmentMutation(): UseMutationResult<
string,
Error,
CreateDepartmentInput
> {
const invalidate = useInvalidateDirectory();
return useMutation({
mutationFn: (input: CreateDepartmentInput) => createDepartment(input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateDepartmentMutation(): UseMutationResult<
void,
Error,
{ departmentId: string; input: UpdateDepartmentInput }
> {
const invalidate = useInvalidateDirectory();
return useMutation({
mutationFn: ({ departmentId, input }: { departmentId: string; input: UpdateDepartmentInput }) =>
updateDepartment(departmentId, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useDisableDepartmentMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateDirectory();
return useMutation({
mutationFn: (departmentId: string) => disableDepartment(departmentId),
onSuccess: async () => {
await invalidate();
},
});
}
export function useEnableDepartmentMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateDirectory();
return useMutation({
mutationFn: (departmentId: string) => enableDepartment(departmentId),
onSuccess: async () => {
await invalidate();
},
});
}
+6 -2
View File
@@ -11,9 +11,13 @@ 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 pathname === ROUTES.system.users || pathname.startsWith(`${ROUTES.system.users}/`); return (
pathname === ROUTES.system.users ||
pathname.startsWith(`${ROUTES.system.users}/`) ||
pathname === ROUTES.system.departments
);
} }
export function AppLayout(): ReactNode { export function AppLayout(): ReactNode {
+2
View File
@@ -8,6 +8,8 @@ import '@/styles/pages.css';
import '@/styles/users.css'; 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/client.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';
+4 -4
View File
@@ -3,12 +3,12 @@ import { describe, expect, it } from 'vitest';
import { getRoleSectionTrail, getUserSectionTrail } from './breadcrumbs'; import { getRoleSectionTrail, getUserSectionTrail } from './breadcrumbs';
describe('面包屑子路由补全', () => { describe('面包屑子路由补全', () => {
it('角色新建页:工作台 / 管理中心 / 角色权限 / 新建角色', () => { it('角色新建页:工作台 / 管理中心 / 角色管理 / 新建角色', () => {
const trail = getRoleSectionTrail('/system/roles/new'); const trail = getRoleSectionTrail('/system/roles/new');
expect(trail?.map((entry) => entry.label)).toEqual([ expect(trail?.map((entry) => entry.label)).toEqual([
'工作台', '工作台',
'管理中心', '管理中心',
'角色权限', '角色管理',
'新建角色', '新建角色',
]); ]);
expect(trail?.[2]?.path).toBe('/system/roles'); expect(trail?.[2]?.path).toBe('/system/roles');
@@ -19,7 +19,7 @@ describe('面包屑子路由补全', () => {
expect(trail?.map((entry) => entry.label)).toEqual([ expect(trail?.map((entry) => entry.label)).toEqual([
'工作台', '工作台',
'管理中心', '管理中心',
'角色权限', '角色管理',
'角色编辑', '角色编辑',
]); ]);
}); });
@@ -32,7 +32,7 @@ describe('面包屑子路由补全', () => {
expect(getUserSectionTrail('/system/users/new')?.map((entry) => entry.label)).toEqual([ expect(getUserSectionTrail('/system/users/new')?.map((entry) => entry.label)).toEqual([
'工作台', '工作台',
'管理中心', '管理中心',
'用户与组织', '用户管理',
'新建用户', '新建用户',
]); ]);
expect(getUserSectionTrail('/system/users/01HZZZZZZZZZZZZZZZZZZZZZZZ')).not.toBeNull(); expect(getUserSectionTrail('/system/users/01HZZZZZZZZZZZZZZZZZZZZZZZ')).not.toBeNull();
+5 -5
View File
@@ -8,7 +8,7 @@ export function getRoleSectionTrail(pathname: string): BreadcrumbEntry[] | null
return [ return [
{ key: 'home', label: '工作台', path: ROUTES.home }, { key: 'home', label: '工作台', path: ROUTES.home },
{ key: 'system', label: '管理中心' }, { key: 'system', label: '管理中心' },
{ key: 'roles', label: '角色权限', path: ROUTES.system.roles }, { key: 'roles', label: '角色管理', path: ROUTES.system.roles },
{ key: 'role-new', label: '新建角色' }, { key: 'role-new', label: '新建角色' },
]; ];
} }
@@ -16,7 +16,7 @@ export function getRoleSectionTrail(pathname: string): BreadcrumbEntry[] | null
return [ return [
{ key: 'home', label: '工作台', path: ROUTES.home }, { key: 'home', label: '工作台', path: ROUTES.home },
{ key: 'system', label: '管理中心' }, { key: 'system', label: '管理中心' },
{ key: 'roles', label: '角色权限', path: ROUTES.system.roles }, { key: 'roles', label: '角色管理', path: ROUTES.system.roles },
{ key: 'role-detail', label: '角色编辑' }, { key: 'role-detail', label: '角色编辑' },
]; ];
} }
@@ -25,14 +25,14 @@ export function getRoleSectionTrail(pathname: string): BreadcrumbEntry[] | null
/** /**
* 用户模块下的子路由(新建 / 详情)不在 nav-config 的菜单项里, * 用户模块下的子路由(新建 / 详情)不在 nav-config 的菜单项里,
* 这里补齐面包屑:工作台 / 管理中心 / 用户与组织 / 当前页。 * 这里补齐面包屑:工作台 / 管理中心 / 用户管理 / 当前页。
*/ */
export function getUserSectionTrail(pathname: string): BreadcrumbEntry[] | null { export function getUserSectionTrail(pathname: string): BreadcrumbEntry[] | null {
if (pathname === ROUTES.system.userNew) { if (pathname === ROUTES.system.userNew) {
return [ return [
{ key: 'home', label: '工作台', path: ROUTES.home }, { key: 'home', label: '工作台', path: ROUTES.home },
{ key: 'system', label: '管理中心' }, { key: 'system', label: '管理中心' },
{ key: 'users', label: '用户与组织', path: ROUTES.system.users }, { key: 'users', label: '用户管理', path: ROUTES.system.users },
{ key: 'user-new', label: '新建用户' }, { key: 'user-new', label: '新建用户' },
]; ];
} }
@@ -40,7 +40,7 @@ export function getUserSectionTrail(pathname: string): BreadcrumbEntry[] | null
return [ return [
{ key: 'home', label: '工作台', path: ROUTES.home }, { key: 'home', label: '工作台', path: ROUTES.home },
{ key: 'system', label: '管理中心' }, { key: 'system', label: '管理中心' },
{ key: 'users', label: '用户与组织', path: ROUTES.system.users }, { key: 'users', label: '用户管理', path: ROUTES.system.users },
{ key: 'user-detail', label: '成员详情' }, { key: 'user-detail', label: '成员详情' },
]; ];
} }
+30 -5
View File
@@ -26,24 +26,47 @@ describe('导航配置', () => {
expect(modules.map((module) => module.key)).toEqual(['dashboard']); expect(modules.map((module) => module.key)).toEqual(['dashboard']);
}); });
it('子路由(/system/users/new、/system/users/:id)也归属用户与组织', () => { it('子路由(/system/users/new、/system/users/:id)也归属用户管理', () => {
expect(findActiveNav(NAV_MODULES, '/system/users/new')?.item?.label).toBe('用户与组织'); expect(findActiveNav(NAV_MODULES, '/system/users/new')?.item?.label).toBe('用户管理');
expect(findActiveNav(NAV_MODULES, '/system/users/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.module.key).toBe( expect(findActiveNav(NAV_MODULES, '/system/users/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.module.key).toBe(
'system', 'system',
); );
}); });
it('角色权限菜单使用 role:view,只读角色也能进入角色页', () => { it('角色管理菜单使用 role:view,只读角色也能进入角色页', () => {
const rolesItem = NAV_MODULES.find((module) => module.key === 'system')?.items.find( const rolesItem = NAV_MODULES.find((module) => module.key === 'system')?.items.find(
(item) => item.key === 'roles', (item) => item.key === 'roles',
); );
expect(rolesItem?.permission).toBe('role:view'); expect(rolesItem?.permission).toBe('role:view');
expect(findActiveNav(NAV_MODULES, '/system/roles/new')?.item?.label).toBe('角色权限'); expect(findActiveNav(NAV_MODULES, '/system/roles/new')?.item?.label).toBe('角色管理');
expect(findActiveNav(NAV_MODULES, '/system/roles/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.module.key).toBe( expect(findActiveNav(NAV_MODULES, '/system/roles/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.module.key).toBe(
'system', 'system',
); );
}); });
it('部门管理菜单使用 user:view,仅查看权限也能进入', () => {
const departmentsItem = NAV_MODULES.find((module) => module.key === 'system')?.items.find(
(item) => item.key === 'departments',
);
expect(departmentsItem?.path).toBe('/system/departments');
expect(departmentsItem?.permission).toBe('user:view');
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('按路径定位当前模块与菜单', () => { 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');
@@ -54,6 +77,8 @@ describe('导航配置', () => {
it('面包屑从导航配置派生', () => { it('面包屑从导航配置派生', () => {
expect(getBreadcrumbs(NAV_MODULES, '/')).toEqual([{ key: 'home', label: '工作台', path: '/' }]); expect(getBreadcrumbs(NAV_MODULES, '/')).toEqual([{ key: 'home', label: '工作台', path: '/' }]);
const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/system/users'); const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/system/users');
expect(breadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '管理中心', '用户与组织']); expect(breadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '管理中心', '用户管理']);
const deptBreadcrumbs = getBreadcrumbs(NAV_MODULES, '/system/departments');
expect(deptBreadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '管理中心', '部门管理']);
}); });
}); });
+28 -11
View File
@@ -1,5 +1,6 @@
import { Permission, type PermissionCode } from '@lingxi/shared'; import { Permission, type PermissionCode } from '@lingxi/shared';
import { import {
IconApps,
IconAsset, IconAsset,
IconCheck, IconCheck,
IconClock, IconClock,
@@ -248,15 +249,23 @@ export const NAV_MODULES: readonly NavModule[] = [
items: [ items: [
{ {
key: 'users', key: 'users',
label: '用户与组织', label: '用户管理',
path: '/system/users', path: '/system/users',
icon: IconUser, icon: IconUser,
permission: Permission.USER_VIEW, permission: Permission.USER_VIEW,
description: '账号、组织与登录状态', description: '成员账号、部门归属与登录状态',
},
{
key: 'departments',
label: '部门管理',
path: '/system/departments',
icon: IconApps,
permission: Permission.USER_VIEW,
description: '两级组织架构、负责人、排序与停用状态',
}, },
{ {
key: 'roles', key: 'roles',
label: '角色权限', label: '角色管理',
path: '/system/roles', path: '/system/roles',
icon: IconShield, icon: IconShield,
permission: Permission.ROLE_VIEW, permission: Permission.ROLE_VIEW,
@@ -264,19 +273,27 @@ 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',
@@ -329,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,352 @@
/* 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';
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 [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">
审批与变更写入审计 · 未审批字段不进入生成 · 每客户进入生成字段 ≤ 5 个
</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,732 @@
/* 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 { 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,
onClose,
}: {
customer: CustomerDetail;
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">
基础字段仅用于检索与报表,永不进入生成;每客户最多 10 个。
</div>
</Modal>
);
}
function ApplyFieldModal({
customer,
onClose,
}: {
customer: CustomerDetail;
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">进入生成字段需审批后生效;每客户最多 5 个。</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 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 >= 10}
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">
自由键值对,仅用于检索与报表,永不进入生成;每客户 ≤ 10 个(
{customer.baseFields.length}/10)。
</div>
</Section>
<Section
title="进入生成字段"
action={
<Can permission={Permission.CUSTOMER_FIELD_APPLY}>
<Button
size="small"
theme="solid"
type="primary"
disabled={!canWrite || customer.genFieldCount >= 5}
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">
需申请 → 审批 → 生效;每客户 ≤ 5 个({customer.genFieldCount}/5)。
</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} / 5</b>
</div>
<div className="lx-client-kv__item">
<span>基础字段</span>
<b>{customer.baseFieldCount} / 10</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}
onClose={() => {
setModal(null);
}}
/>
) : null}
{modal === 'field' ? (
<ApplyFieldModal
customer={customer}
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,273 @@
/* 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 { 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 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 >= 5 ? ' is-full' : ''}`}
>
{customer.genFieldCount}/5
</span>
</td>
<td>
<span className="lx-client-count">{customer.baseFieldCount}/10</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,370 @@
/* eslint-disable max-lines-per-function -- 详情面板按视图/编辑两种形态分区,集中便于与表单状态对齐 */
import { Button, Input, InputNumber, Select } from '@douyinfe/semi-ui-19';
import type { DepartmentNode, UserSummary } from '@lingxi/shared';
import { Permission } from '@lingxi/shared';
import { PageEmpty } from '@lingxi/ui';
import { IconAdd, IconEdit, IconLock, IconPlay } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { Can } from '@/components/Can';
import type { DepartmentFormErrors, DepartmentFormValues } from './department-form';
import {
childMemberCount,
departmentDisableBlocker,
departmentEnableBlocker,
type DepartmentTreeModel,
} from './department-tree';
import { DepartmentStatusTag } from './DepartmentStatusTag';
export interface DepartmentDetailPanelProps {
node: DepartmentNode | null;
model: DepartmentTreeModel;
canManage: boolean;
editing: boolean;
form: DepartmentFormValues;
errors: DepartmentFormErrors;
saving: boolean;
saveError: string | null;
leaderCandidates: readonly UserSummary[];
onStartEdit: () => void;
onCancelEdit: () => void;
onChangeField: (field: keyof DepartmentFormValues, value: string) => void;
onSave: () => void;
onDisable: () => void;
onEnable: () => void;
onCreateChild: () => void;
onSelect: (departmentId: string) => void;
}
function DetailHead({
node,
parent,
canManage,
disableBlocker,
enableBlocker,
onStartEdit,
onDisable,
onEnable,
}: {
node: DepartmentNode;
parent: DepartmentNode | null;
canManage: boolean;
disableBlocker: string | null;
enableBlocker: string | null;
onStartEdit: () => void;
onDisable: () => void;
onEnable: () => void;
}): ReactNode {
return (
<header className="lx-dept-detail__head">
<span className="lx-dept-detail__avatar" aria-hidden="true">
{node.name.slice(0, 1)}
</span>
<div className="lx-dept-detail__ident">
<div className="lx-dept-detail__name">
<h2>{node.name}</h2>
<DepartmentStatusTag status={node.status} />
<span className="lx-dept-tag">{node.parentId === null ? '一级部门' : '小组'}</span>
</div>
<p className="lx-dept-detail__meta">
上级 {parent?.name ?? '—'} · 负责人 {node.leader?.name ?? '未指定'} · 排序{' '}
{node.sortOrder}
</p>
</div>
<Can permission={Permission.USER_MANAGE}>
<div className="lx-dept-detail__actions">
<Button icon={<IconEdit size={14} />} onClick={onStartEdit}>
编辑
</Button>
{node.status === 'active' ? (
<Button
type="danger"
icon={<IconLock size={14} />}
disabled={disableBlocker !== null}
title={disableBlocker ?? undefined}
onClick={onDisable}
>
停用
</Button>
) : (
<Button
icon={<IconPlay size={14} />}
disabled={enableBlocker !== null}
title={enableBlocker ?? undefined}
onClick={onEnable}
>
启用
</Button>
)}
</div>
</Can>
{canManage ? null : <span className="lx-dept-detail__readonly">只读权限</span>}
</header>
);
}
function InfoGrid({
node,
parent,
}: {
node: DepartmentNode;
parent: DepartmentNode | null;
}): ReactNode {
return (
<section className="lx-dept-detail__section">
<h3 className="lx-dept-detail__section-title">基本信息</h3>
<div className="lx-dept-kv-grid">
<div className="lx-kvi">
<span className="lx-kvi__label">部门名称</span>
<span className="lx-kvi__value">{node.name}</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">上级部门</span>
<span className="lx-kvi__value">{parent?.name ?? '—(一级部门)'}</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">负责人</span>
<span className="lx-kvi__value">{node.leader?.name ?? '未指定'}</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">排序</span>
<span className="lx-kvi__value">{node.sortOrder}</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">状态</span>
<span className="lx-kvi__value">
<DepartmentStatusTag status={node.status} />
</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">创建时间</span>
<span className="lx-kvi__value">{node.createdAt.slice(0, 16).replace('T', ' ')}</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">直属成员</span>
<span className="lx-kvi__value">{node.directMemberCount} 人</span>
</div>
<div className="lx-kvi">
<span className="lx-kvi__label">含下级成员</span>
<span className="lx-kvi__value">{node.memberCount} 人</span>
</div>
</div>
</section>
);
}
function ChildrenSection({
node,
model,
onCreateChild,
onSelect,
}: {
node: DepartmentNode;
model: DepartmentTreeModel;
onCreateChild: () => void;
onSelect: (departmentId: string) => void;
}): ReactNode {
if (node.parentId !== null) {
return null;
}
const children = model.nodes.filter((child) => child.parentId === node.id);
return (
<section className="lx-dept-detail__section">
<div className="lx-dept-detail__section-head">
<h3 className="lx-dept-detail__section-title">下级小组</h3>
<span className="lx-dept-detail__muted">{children.length} 个</span>
<Can permission={Permission.USER_MANAGE}>
<Button size="small" icon={<IconAdd size={14} />} onClick={onCreateChild}>
新建小组
</Button>
</Can>
</div>
{children.length === 0 ? (
<p className="lx-dept-detail__muted">暂无下级小组。</p>
) : (
<ul className="lx-dept-children">
{children.map((child) => (
<li key={child.id}>
<button
type="button"
className="lx-dept-child-row"
onClick={() => {
onSelect(child.id);
}}
>
<span className="lx-dept-child-row__name">
{child.name}
<DepartmentStatusTag status={child.status} />
</span>
<span className="lx-dept-child-row__meta">
负责人 {child.leader?.name ?? '未指定'} · {child.memberCount} 人
</span>
</button>
</li>
))}
</ul>
)}
</section>
);
}
function EditForm({
node,
form,
errors,
saving,
saveError,
leaderCandidates,
onChange,
onCancel,
onSave,
}: {
node: DepartmentNode;
form: DepartmentFormValues;
errors: DepartmentFormErrors;
saving: boolean;
saveError: string | null;
leaderCandidates: readonly UserSummary[];
onChange: (field: keyof DepartmentFormValues, value: string) => void;
onCancel: () => void;
onSave: () => void;
}): ReactNode {
return (
<section className="lx-dept-detail__section lx-dept-edit">
<h3 className="lx-dept-detail__section-title">编辑部门</h3>
<p className="lx-dept-detail__muted">
上级关系创建后不可移动;负责人仅可从启用中的用户选择。
</p>
<div className="lx-dept-edit__grid">
<label className="lx-field">
<span className="lx-field__label">部门名称</span>
<Input
value={form.name}
maxLength={20}
placeholder="请输入部门名称"
onChange={(value) => {
onChange('name', value);
}}
/>
{errors.name === undefined ? null : (
<span className="lx-field__error">{errors.name}</span>
)}
</label>
<label className="lx-field">
<span className="lx-field__label">负责人</span>
<Select
value={form.leaderUserId === '' ? undefined : form.leaderUserId}
placeholder="未指定"
showClear
optionList={leaderCandidates.map((user) => ({
label: `${user.name} · ${user.account}`,
value: user.id,
}))}
onChange={(value) => {
onChange('leaderUserId', typeof value === 'string' ? value : '');
}}
/>
</label>
<label className="lx-field">
<span className="lx-field__label">排序</span>
<InputNumber
value={form.sortOrder === '' ? undefined : Number(form.sortOrder)}
min={0}
max={9999}
placeholder="0"
onChange={(value) => {
onChange('sortOrder', String(value));
}}
/>
{errors.sortOrder === undefined ? null : (
<span className="lx-field__error">{errors.sortOrder}</span>
)}
</label>
</div>
{saveError === null ? null : <p className="lx-field__error">{saveError}</p>}
<div className="lx-dept-edit__actions">
<Button onClick={onCancel} disabled={saving}>
取消
</Button>
<Button theme="solid" type="primary" loading={saving} onClick={onSave}>
保存
</Button>
</div>
<p className="lx-dept-detail__muted">当前编辑:{node.name}</p>
</section>
);
}
/** 右侧部门详情 / 内联编辑 */
export function DepartmentDetailPanel(props: DepartmentDetailPanelProps): ReactNode {
const { node, model } = props;
if (node === null) {
return (
<section className="lx-dept-detail" aria-label="部门详情">
<div className="lx-dept-detail__empty">
<PageEmpty title="选择左侧部门查看详情" description="支持新建一级部门与下级小组。" />
</div>
</section>
);
}
const parent = node.parentId === null ? null : (model.byId.get(node.parentId) ?? null);
const children = model.nodes.filter((child) => child.parentId === node.id);
const disableBlocker = departmentDisableBlocker(node, children);
const enableBlocker = departmentEnableBlocker(node, parent);
return (
<section className="lx-dept-detail" aria-label="部门详情">
<DetailHead
node={node}
parent={parent}
canManage={props.canManage}
disableBlocker={disableBlocker}
enableBlocker={enableBlocker}
onStartEdit={props.onStartEdit}
onDisable={props.onDisable}
onEnable={props.onEnable}
/>
<div className="lx-dept-detail__body">
{disableBlocker !== null && node.status === 'active' ? (
<p className="lx-dept-notice" role="status">
当前不可停用:{disableBlocker}
</p>
) : null}
{enableBlocker !== null && node.status === 'disabled' ? (
<p className="lx-dept-notice" role="status">
当前不可启用:{enableBlocker}
</p>
) : null}
{props.editing ? (
<EditForm
node={node}
form={props.form}
errors={props.errors}
saving={props.saving}
saveError={props.saveError}
leaderCandidates={props.leaderCandidates}
onChange={props.onChangeField}
onCancel={props.onCancelEdit}
onSave={props.onSave}
/>
) : (
<>
<InfoGrid node={node} parent={parent} />
<ChildrenSection
node={node}
model={model}
onCreateChild={props.onCreateChild}
onSelect={props.onSelect}
/>
{childMemberCount(node) > 0 ? (
<p className="lx-dept-detail__muted">
含下级成员 {node.memberCount} 人,其中直属 {node.directMemberCount} 人。
</p>
) : null}
</>
)}
</div>
</section>
);
}
@@ -0,0 +1,17 @@
import type { DepartmentStatus } from '@lingxi/shared';
import type { ReactNode } from 'react';
export interface DepartmentStatusTagProps {
status: DepartmentStatus;
}
/** 部门状态标签:启用 / 停用(停用仅影响新分配与选择器,历史数据保留) */
export function DepartmentStatusTag({ status }: DepartmentStatusTagProps): ReactNode {
const active = status === 'active';
return (
<span className={active ? 'lx-status-tag is-active' : 'lx-status-tag is-disabled'}>
<span className="lx-status-tag__dot" aria-hidden="true" />
{active ? '启用' : '停用'}
</span>
);
}
@@ -0,0 +1,199 @@
/* eslint-disable max-lines-per-function -- 树面板包含根/子节点两种行与三态展示,集中便于对齐交互 */
import { Button, Skeleton, Switch } from '@douyinfe/semi-ui-19';
import type { DepartmentNode } from '@lingxi/shared';
import { ErrorState, PageEmpty } from '@lingxi/ui';
import { IconAdd, IconChevronDown, IconChevronRight } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { childMemberCount, type DepartmentTreeModel } from './department-tree';
import { DepartmentStatusTag } from './DepartmentStatusTag';
export interface DepartmentTreePanelProps {
model: DepartmentTreeModel;
loading: boolean;
error: Error | null;
showDisabled: boolean;
canManage: boolean;
selectedId: string | null;
onSelect: (departmentId: string) => void;
onRetry: () => void;
onToggleShowDisabled: (checked: boolean) => void;
onCreate: () => void;
}
function DepartmentRow({
node,
selected,
depth,
expanded,
hasChildren,
onSelect,
onToggle,
}: {
node: DepartmentNode;
selected: boolean;
depth: 0 | 1;
expanded: boolean;
hasChildren: boolean;
onSelect: () => void;
onToggle: () => void;
}): ReactNode {
const disabled = node.status === 'disabled';
return (
<div
className={[
'lx-dept-row',
depth === 1 ? 'is-child' : '',
selected ? 'is-selected' : '',
disabled ? 'is-disabled' : '',
]
.filter(Boolean)
.join(' ')}
>
{depth === 0 && hasChildren ? (
<button
type="button"
className="lx-dept-row__caret"
aria-label={expanded ? '收起下级小组' : '展开下级小组'}
aria-expanded={expanded}
onClick={onToggle}
>
{expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</button>
) : (
<span className="lx-dept-row__caret lx-dept-row__caret--placeholder" aria-hidden="true" />
)}
<button type="button" className="lx-dept-row__main" onClick={onSelect}>
<span className="lx-dept-row__name">
<b>{node.name}</b>
<DepartmentStatusTag status={node.status} />
</span>
<span className="lx-dept-row__meta">负责人 {node.leader?.name ?? '未指定'}</span>
</button>
<span className="lx-dept-row__count" title={`直属 ${String(node.directMemberCount)} 人`}>
{depth === 0 && childMemberCount(node) > 0
? `${String(node.memberCount)} 人`
: `${String(node.directMemberCount)} 人`}
</span>
</div>
);
}
function TreeBody({
props,
}: {
props: Pick<
DepartmentTreePanelProps,
'model' | 'selectedId' | 'onSelect' | 'canManage' | 'onCreate' | 'onRetry' | 'error' | 'loading'
>;
}): ReactNode {
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
if (props.loading) {
return (
<div className="lx-dept-tree__state">
<Skeleton placeholder={<Skeleton.Paragraph rows={7} />} loading />
</div>
);
}
if (props.error !== null) {
return (
<ErrorState
className="lx-dept-tree__state"
title="部门加载失败"
message={props.error.message}
onRetry={props.onRetry}
/>
);
}
if (props.model.roots.length === 0) {
return (
<PageEmpty
title="暂无部门"
description="创建一级部门后再维护下属小组。"
action={
props.canManage ? (
<Button
theme="solid"
type="primary"
icon={<IconAdd size={16} />}
onClick={props.onCreate}
>
新建部门
</Button>
) : undefined
}
/>
);
}
return (
<ul className="lx-dept-tree__list">
{props.model.roots.map((root) => {
const isExpanded = !collapsed.has(root.node.id);
return (
<li key={root.node.id}>
<DepartmentRow
node={root.node}
depth={0}
selected={root.node.id === props.selectedId}
expanded={isExpanded}
hasChildren={root.children.length > 0}
onSelect={() => {
props.onSelect(root.node.id);
}}
onToggle={() => {
setCollapsed((current) => {
const next = new Set(current);
if (next.has(root.node.id)) {
next.delete(root.node.id);
} else {
next.add(root.node.id);
}
return next;
});
}}
/>
{isExpanded && root.children.length > 0 ? (
<ul className="lx-dept-tree__children">
{root.children.map((child) => (
<li key={child.id}>
<DepartmentRow
node={child}
depth={1}
selected={child.id === props.selectedId}
expanded={false}
hasChildren={false}
onSelect={() => {
props.onSelect(child.id);
}}
onToggle={() => undefined}
/>
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
);
}
/** 左侧组织树:两级结构、人数、负责人、状态与「显示已停用」开关 */
export function DepartmentTreePanel(props: DepartmentTreePanelProps): ReactNode {
return (
<section className="lx-dept-tree" aria-label="组织架构">
<header className="lx-dept-tree__head">
<span className="lx-dept-tree__title">组织架构</span>
<span className="lx-dept-tree__count">{props.model.nodes.length} 个</span>
<label className="lx-dept-tree__toggle">
<Switch size="small" checked={props.showDisabled} onChange={props.onToggleShowDisabled} />
显示已停用
</label>
</header>
<div className="lx-dept-tree__body">
<TreeBody props={props} />
</div>
</section>
);
}
@@ -0,0 +1,303 @@
/* eslint-disable max-lines-per-function, complexity -- 部门管理页负责树选中、内联编辑与三类弹窗编排,集中便于追踪数据流 */
import { Button } from '@douyinfe/semi-ui-19';
import { type CreateDepartmentInput, type DepartmentNode, Permission } from '@lingxi/shared';
import { notifySuccess } from '@lingxi/ui';
import { IconAdd } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { Can } from '@/components/Can';
import {
useCreateDepartmentMutation,
useDepartmentsQuery,
useDisableDepartmentMutation,
useEnableDepartmentMutation,
useUpdateDepartmentMutation,
} from '@/hooks/queries/use-directory';
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
import { usePermission } from '@/hooks/use-permission';
import { CreateDepartmentDialog, DepartmentStatusDialog } from './department-dialogs';
import {
type DepartmentFormErrors,
type DepartmentFormValues,
departmentFormValues,
EMPTY_DEPARTMENT_FORM,
toUpdateDepartmentInput,
} from './department-form';
import {
buildDepartmentTree,
departmentDisableBlocker,
departmentEnableBlocker,
filterTreeByStatus,
} from './department-tree';
import { DepartmentDetailPanel } from './DepartmentDetailPanel';
import { DepartmentTreePanel } from './DepartmentTreePanel';
type CreateParent = DepartmentNode | null | undefined;
interface StatusDialogState {
mode: 'disable' | 'enable';
node: DepartmentNode;
}
function DepartmentsHeader({ onCreate }: { onCreate: () => void }): ReactNode {
return (
<header className="lx-depts__header">
<div>
<h1>部门管理</h1>
<p>维护两级组织架构、负责人、排序与停用状态;部门只停用不删除。</p>
</div>
<Can permission={Permission.USER_MANAGE}>
<Button
theme="solid"
type="primary"
icon={<IconAdd size={16} />}
data-testid="dept-create"
onClick={onCreate}
>
新建部门
</Button>
</Can>
</header>
);
}
/** 部门管理页:左侧两级组织树 + 右侧详情/内联编辑(/system/departments) */
export function DepartmentsPage(): ReactNode {
const canManage = usePermission(Permission.USER_MANAGE);
const [searchParams, setSearchParams] = useSearchParams();
const [showDisabled, setShowDisabled] = useState(true);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState<DepartmentFormValues>(EMPTY_DEPARTMENT_FORM);
const [formErrors, setFormErrors] = useState<DepartmentFormErrors>({});
const [saveError, setSaveError] = useState<string | null>(null);
const [createParent, setCreateParent] = useState<CreateParent>(undefined);
const [statusDialog, setStatusDialog] = useState<StatusDialogState | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const departmentsQuery = useDepartmentsQuery(true);
const candidates = useUserCandidatesQuery(canManage);
const createMutation = useCreateDepartmentMutation();
const updateMutation = useUpdateDepartmentMutation();
const disableMutation = useDisableDepartmentMutation();
const enableMutation = useEnableDepartmentMutation();
const model = useMemo(
() => buildDepartmentTree(departmentsQuery.data ?? []),
[departmentsQuery.data],
);
const visibleModel = useMemo(
() => filterTreeByStatus(model, showDisabled),
[model, showDisabled],
);
const requestedId = searchParams.get('id');
const selectedId =
requestedId !== null && visibleModel.byId.has(requestedId)
? requestedId
: (visibleModel.roots[0]?.node.id ?? visibleModel.nodes[0]?.id ?? null);
const selectedNode = selectedId === null ? null : (visibleModel.byId.get(selectedId) ?? null);
const selectDepartment = (departmentId: string): void => {
const next = new URLSearchParams(searchParams);
next.set('id', departmentId);
setSearchParams(next, { replace: true });
setEditing(false);
setSaveError(null);
};
const openCreate = (parent: DepartmentNode | null): void => {
setActionError(null);
setCreateParent(parent);
setCreateOpen(true);
};
const submitCreate = (input: CreateDepartmentInput): void => {
createMutation.mutate(input, {
onSuccess: (id) => {
setCreateOpen(false);
setCreateParent(undefined);
notifySuccess('部门已创建');
selectDepartment(id);
},
onError: (error) => {
setActionError(error.message);
},
});
};
const submitStatus = (): void => {
if (statusDialog === null) {
return;
}
const { mode, node } = statusDialog;
const onSuccess = (): void => {
setStatusDialog(null);
notifySuccess(mode === 'disable' ? '部门已停用' : '部门已启用');
if (mode === 'disable' && !showDisabled) {
setShowDisabled(true);
}
};
const onError = (error: Error): void => {
setActionError(error.message);
};
if (mode === 'disable') {
disableMutation.mutate(node.id, { onSuccess, onError });
return;
}
enableMutation.mutate(node.id, { onSuccess, onError });
};
const startEdit = (): void => {
if (selectedNode === null) {
return;
}
setForm(departmentFormValues(selectedNode));
setFormErrors({});
setSaveError(null);
setEditing(true);
};
const saveEdit = (): void => {
if (selectedNode === null) {
return;
}
const result = toUpdateDepartmentInput(form);
if (result.input === null) {
setFormErrors(result.errors);
return;
}
updateMutation.mutate(
{ departmentId: selectedNode.id, input: result.input },
{
onSuccess: () => {
setEditing(false);
notifySuccess('部门已更新');
},
onError: (error) => {
setSaveError(error.message);
},
},
);
};
const openStatus = (mode: 'disable' | 'enable', node: DepartmentNode): void => {
setActionError(null);
setStatusDialog({ mode, node });
};
const createDialogParent = createOpen ? (createParent ?? null) : null;
return (
<div className="lx-depts">
<DepartmentsHeader
onCreate={() => {
openCreate(null);
}}
/>
<div className="lx-depts__split">
<DepartmentTreePanel
model={visibleModel}
loading={departmentsQuery.isPending}
error={departmentsQuery.error}
showDisabled={showDisabled}
canManage={canManage}
selectedId={selectedId}
onSelect={selectDepartment}
onRetry={() => {
void departmentsQuery.refetch();
}}
onToggleShowDisabled={setShowDisabled}
onCreate={() => {
openCreate(null);
}}
/>
{departmentsQuery.isPending ? (
<div className="lx-dept-detail lx-dept-detail--loading">
<div className="lx-dept-detail__body">
<p className="lx-dept-detail__muted">部门加载中…</p>
</div>
</div>
) : (
<DepartmentDetailPanel
node={selectedNode}
model={visibleModel}
canManage={canManage}
editing={editing}
form={form}
errors={formErrors}
saving={updateMutation.isPending}
saveError={saveError}
leaderCandidates={candidates.data?.items ?? []}
onStartEdit={startEdit}
onCancelEdit={() => {
setEditing(false);
setSaveError(null);
}}
onChangeField={(field, value) => {
setForm((current) => ({ ...current, [field]: value }));
setFormErrors((current) => ({ ...current, [field]: undefined }));
}}
onSave={saveEdit}
onDisable={() => {
if (selectedNode !== null) {
openStatus('disable', selectedNode);
}
}}
onEnable={() => {
if (selectedNode !== null) {
openStatus('enable', selectedNode);
}
}}
onCreateChild={() => {
if (selectedNode !== null) {
openCreate(selectedNode);
}
}}
onSelect={selectDepartment}
/>
)}
</div>
{createOpen ? (
<CreateDepartmentDialog
parent={createDialogParent}
leaderCandidates={candidates.data?.items ?? []}
loading={createMutation.isPending}
error={actionError}
onCancel={() => {
setCreateOpen(false);
setCreateParent(undefined);
}}
onConfirm={submitCreate}
/>
) : null}
{statusDialog === null ? null : (
<DepartmentStatusDialog
mode={statusDialog.mode}
node={statusDialog.node}
blocker={
statusDialog.mode === 'disable'
? departmentDisableBlocker(
statusDialog.node,
visibleModel.nodes.filter((node) => node.parentId === statusDialog.node.id),
)
: departmentEnableBlocker(
statusDialog.node,
statusDialog.node.parentId === null
? null
: (visibleModel.byId.get(statusDialog.node.parentId) ?? null),
)
}
loading={disableMutation.isPending || enableMutation.isPending}
error={actionError}
onCancel={() => {
setStatusDialog(null);
}}
onConfirm={submitStatus}
/>
)}
</div>
);
}
@@ -0,0 +1,230 @@
/* eslint-disable max-lines-per-function -- 新建与停用/启用两类弹窗按字段与后果顺序展开,集中便于对齐交互 */
import { Button, Checkbox, Input, InputNumber, Modal, Select } from '@douyinfe/semi-ui-19';
import type { CreateDepartmentInput, DepartmentNode, UserSummary } from '@lingxi/shared';
import { IconAlert } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useState } from 'react';
import {
type DepartmentFormErrors,
type DepartmentFormValues,
EMPTY_DEPARTMENT_FORM,
toCreateDepartmentInput,
} from './department-form';
import { DepartmentStatusTag } from './DepartmentStatusTag';
export interface CreateDepartmentDialogProps {
parent: DepartmentNode | null;
leaderCandidates: readonly UserSummary[];
loading: boolean;
error: string | null;
onCancel: () => void;
onConfirm: (input: CreateDepartmentInput) => void;
}
/** 新建部门 / 小组:上级只读(由页面上下文决定),负责人仅取启用用户 */
export function CreateDepartmentDialog({
parent,
leaderCandidates,
loading,
error,
onCancel,
onConfirm,
}: CreateDepartmentDialogProps): ReactNode {
const [form, setForm] = useState<DepartmentFormValues>(EMPTY_DEPARTMENT_FORM);
const [errors, setErrors] = useState<DepartmentFormErrors>({});
const setField = (field: keyof DepartmentFormValues, value: string): void => {
setForm((current) => ({ ...current, [field]: value }));
setErrors((current) => ({ ...current, [field]: undefined }));
};
const submit = (): void => {
const result = toCreateDepartmentInput(form, parent?.id ?? null);
if (result.input === null) {
setErrors(result.errors);
return;
}
onConfirm(result.input);
};
return (
<Modal
visible
title={parent === null ? '新建一级部门' : '新建下级小组'}
width={480}
maskClosable={false}
closeOnEsc={!loading}
onCancel={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button theme="solid" type="primary" loading={loading} onClick={submit}>
创建
</Button>
</>
}
>
<div className="lx-dept-dialog">
<p className="lx-dept-dialog__context">
上级:<b>{parent?.name ?? '无(一级部门)'}</b>
</p>
<label className="lx-field">
<span className="lx-field__label">
部门名称
<span className="lx-field__required">*</span>
</span>
<Input
value={form.name}
maxLength={20}
placeholder="请输入部门或小组名称"
onChange={(value) => {
setField('name', value);
}}
/>
{errors.name === undefined ? null : (
<span className="lx-field__error">{errors.name}</span>
)}
</label>
<label className="lx-field">
<span className="lx-field__label">负责人</span>
<Select
value={form.leaderUserId === '' ? undefined : form.leaderUserId}
placeholder="未指定(可稍后设置)"
showClear
optionList={leaderCandidates.map((user) => ({
label: `${user.name} · ${user.account}`,
value: user.id,
}))}
onChange={(value) => {
setField('leaderUserId', typeof value === 'string' ? value : '');
}}
/>
</label>
<label className="lx-field">
<span className="lx-field__label">排序</span>
<InputNumber
value={form.sortOrder === '' ? undefined : Number(form.sortOrder)}
min={0}
max={9999}
placeholder="0(数字越小越靠前)"
onChange={(value) => {
setField('sortOrder', String(value));
}}
/>
{errors.sortOrder === undefined ? null : (
<span className="lx-field__error">{errors.sortOrder}</span>
)}
</label>
{error === null ? null : <p className="lx-field__error">{error}</p>}
</div>
</Modal>
);
}
export interface DepartmentStatusDialogProps {
mode: 'disable' | 'enable';
node: DepartmentNode;
blocker: string | null;
loading: boolean;
error: string | null;
onCancel: () => void;
onConfirm: () => void;
}
function statusConsequences(mode: 'disable' | 'enable', node: DepartmentNode): string[] {
if (mode === 'disable') {
return [
'停用后该部门不再出现在新建 / 编辑用户的部门选择中',
'历史数据与成员归属保留,可随时重新启用',
`当前含下级成员 ${String(node.memberCount)} 人、直属 ${String(node.directMemberCount)} 人`,
];
}
return ['启用后恢复出现在部门选择器中', '上级部门必须处于启用状态', '本次变更将写入审计日志'];
}
/** 停用 / 启用二次确认:对象卡 → 规则提示 → 勾选后才能提交;后端拒绝时展示服务端文案 */
export function DepartmentStatusDialog({
mode,
node,
blocker,
loading,
error,
onCancel,
onConfirm,
}: DepartmentStatusDialogProps): ReactNode {
const [acknowledged, setAcknowledged] = useState(false);
const disableMode = mode === 'disable';
return (
<Modal
visible
title={disableMode ? '停用部门' : '启用部门'}
width={520}
maskClosable={false}
closeOnEsc={!loading}
onCancel={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={loading}>
取消
</Button>
<Button
theme="solid"
type={disableMode ? 'danger' : 'primary'}
loading={loading}
disabled={!acknowledged || blocker !== null}
onClick={onConfirm}
data-testid="dept-status-confirm"
>
{disableMode ? '停用部门' : '启用部门'}
</Button>
</>
}
>
<div className="lx-confirm-dialog">
<div className="lx-confirm-dialog__object">
<span className="lx-confirm-dialog__object-icon" aria-hidden="true">
{node.name.slice(0, 1)}
</span>
<span className="lx-confirm-dialog__object-copy">
<span className="lx-confirm-dialog__object-title">{node.name}</span>
<span className="lx-confirm-dialog__object-meta">
{node.parentId === null ? '一级部门' : '小组'} · 含下级 {node.memberCount} 人
</span>
</span>
<DepartmentStatusTag status={node.status} />
</div>
<div
className={disableMode ? 'lx-confirm-dialog__warn' : 'lx-confirm-dialog__warn is-info'}
>
<IconAlert size={16} />
<ul>
{statusConsequences(mode, node).map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
{blocker === null ? null : (
<div className="lx-confirm-dialog__warn">
<IconAlert size={16} />
<div>
当前不可{mode === 'disable' ? '停用' : '启用'}:{blocker}
</div>
</div>
)}
{error === null ? null : <p className="lx-field__error">{error}</p>}
<Checkbox
className="lx-confirm-dialog__ack"
checked={acknowledged}
onChange={(event) => {
setAcknowledged(event.target.checked === true);
}}
>
我已了解上述影响,并确认{mode === 'disable' ? '停用' : '启用'}该部门
</Checkbox>
</div>
</Modal>
);
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import {
departmentFormValues,
EMPTY_DEPARTMENT_FORM,
toCreateDepartmentInput,
toUpdateDepartmentInput,
} from './department-form';
const PARENT_ID = '01HZZZZZZZZZZZZZZZZZZZZZ01';
const LEADER_ID = '01HZZZZZZZZZZZZZZZZZZZZZ02';
describe('部门表单校验', () => {
it('新建:名称必填、父级与负责人可选', () => {
const empty = toCreateDepartmentInput(EMPTY_DEPARTMENT_FORM, null);
expect(empty.input).toBeNull();
expect(empty.errors.name).toBeTruthy();
const valid = toCreateDepartmentInput(
{ name: ' 设计组 ', leaderUserId: LEADER_ID, sortOrder: '3' },
PARENT_ID,
);
expect(valid.errors).toEqual({});
expect(valid.input).toEqual({
name: '设计组',
parentId: PARENT_ID,
leaderUserId: LEADER_ID,
sortOrder: 3,
});
});
it('新建:名称超长与非法排序给出字段错误', () => {
const longName = toCreateDepartmentInput(
{ name: '一二三四五六七八九十一二三四五六七八九十一', leaderUserId: '', sortOrder: '' },
null,
);
expect(longName.input).toBeNull();
expect(longName.errors.name).toContain('20');
const badSort = toCreateDepartmentInput(
{ name: '设计组', leaderUserId: '', sortOrder: '-1' },
null,
);
expect(badSort.input).toBeNull();
expect(badSort.errors.sortOrder).toBeTruthy();
});
it('编辑:负责人清空提交 null,上级不可改', () => {
const result = toUpdateDepartmentInput({ name: '素材一组', leaderUserId: '', sortOrder: '' });
expect(result.input).toEqual({ name: '素材一组', leaderUserId: null });
expect(result.errors).toEqual({});
});
it('编辑:未填名称时仍以空字符串报错', () => {
const result = toUpdateDepartmentInput({ name: '', leaderUserId: '', sortOrder: '' });
expect(result.input).toBeNull();
expect(result.errors.name).toBeTruthy();
});
it('回显节点值(负责人 / 排序 / 名称)', () => {
expect(
departmentFormValues({
id: PARENT_ID,
name: '内容生产中心',
parentId: null,
leader: { id: LEADER_ID, name: '陈默' },
status: 'active',
sortOrder: 2,
memberCount: 19,
directMemberCount: 4,
createdAt: '2026-11-01T00:00:00.000Z',
}),
).toEqual({ name: '内容生产中心', leaderUserId: LEADER_ID, sortOrder: '2' });
expect(departmentFormValues(null)).toEqual(EMPTY_DEPARTMENT_FORM);
});
});
@@ -0,0 +1,114 @@
import {
type CreateDepartmentInput,
createDepartmentInputSchema,
type DepartmentNode,
type UpdateDepartmentInput,
updateDepartmentInputSchema,
} from '@lingxi/shared';
export interface DepartmentFormValues {
name: string;
/** 空字符串表示不指定负责人 */
leaderUserId: string;
/** 数字字符串,空字符串表示使用默认排序 0 */
sortOrder: string;
}
export type DepartmentFormField = 'name' | 'sortOrder';
export type DepartmentFormErrors = Partial<Record<DepartmentFormField, string>>;
export const EMPTY_DEPARTMENT_FORM: DepartmentFormValues = {
name: '',
leaderUserId: '',
sortOrder: '',
};
export function departmentFormValues(node?: DepartmentNode | null): DepartmentFormValues {
if (node === undefined || node === null) {
return EMPTY_DEPARTMENT_FORM;
}
return {
name: node.name,
leaderUserId: node.leader?.id ?? '',
sortOrder: String(node.sortOrder),
};
}
function parseSortOrder(value: string): number | undefined | 'invalid' {
const trimmed = value.trim();
if (trimmed === '') {
return undefined;
}
if (!/^\d+$/.test(trimmed)) {
return 'invalid';
}
return Number(trimmed);
}
function collectErrors(
kind: 'create' | 'update',
issues: readonly { path: (string | number)[]; message: string }[],
): DepartmentFormErrors {
const errors: DepartmentFormErrors = {};
for (const issue of issues) {
const field = issue.path[0];
if (field === 'name' || field === 'sortOrder') {
errors[field] ??= issue.message;
}
if (kind === 'create' && field === 'leaderUserId') {
errors.name ??= '负责人不合法,请重新选择';
}
}
return errors;
}
export interface CreateDepartmentFormResult {
input: CreateDepartmentInput | null;
errors: DepartmentFormErrors;
}
/** 新建部门 / 小组:与后端共用 createDepartmentInputSchema 校验 */
export function toCreateDepartmentInput(
values: DepartmentFormValues,
parentId: string | null,
): CreateDepartmentFormResult {
const sortOrder = parseSortOrder(values.sortOrder);
if (sortOrder === 'invalid') {
return { input: null, errors: { sortOrder: '排序必须是非负整数' } };
}
const candidate = {
name: values.name,
parentId,
...(values.leaderUserId === '' ? {} : { leaderUserId: values.leaderUserId }),
...(sortOrder === undefined ? {} : { sortOrder }),
};
const parsed = createDepartmentInputSchema.safeParse(candidate);
if (!parsed.success) {
return { input: null, errors: collectErrors('create', parsed.error.issues) };
}
return { input: parsed.data, errors: {} };
}
export interface UpdateDepartmentFormResult {
input: UpdateDepartmentInput | null;
errors: DepartmentFormErrors;
}
/** 编辑部门:上级关系创建后不可改,只提交名称 / 负责人 / 排序 */
export function toUpdateDepartmentInput(values: DepartmentFormValues): UpdateDepartmentFormResult {
const sortOrder = parseSortOrder(values.sortOrder);
if (sortOrder === 'invalid') {
return { input: null, errors: { sortOrder: '排序必须是非负整数' } };
}
const candidate = {
name: values.name,
leaderUserId: values.leaderUserId === '' ? null : values.leaderUserId,
...(sortOrder === undefined ? {} : { sortOrder }),
};
const parsed = updateDepartmentInputSchema.safeParse(candidate);
if (!parsed.success) {
return { input: null, errors: collectErrors('update', parsed.error.issues) };
}
return { input: parsed.data, errors: {} };
}
@@ -0,0 +1,113 @@
import type { DepartmentNode } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import {
buildDepartmentTree,
childMemberCount,
childrenOf,
departmentDisableBlocker,
departmentEnableBlocker,
filterTreeByStatus,
} from './department-tree';
const ROOT_A = '01HZZZZZZZZZZZZZZZZZZZZZ01';
const ROOT_B = '01HZZZZZZZZZZZZZZZZZZZZZ02';
const CHILD_A1 = '01HZZZZZZZZZZZZZZZZZZZZZ03';
const CHILD_A2 = '01HZZZZZZZZZZZZZZZZZZZZZ04';
const DISABLED_ROOT = '01HZZZZZZZZZZZZZZZZZZZZZ05';
function department(
id: string,
name: string,
parentId: string | null,
overrides: Partial<DepartmentNode> = {},
): DepartmentNode {
return {
id,
name,
parentId,
leader: null,
status: 'active',
sortOrder: 0,
memberCount: 0,
directMemberCount: 0,
createdAt: '2026-11-01T00:00:00.000Z',
...overrides,
};
}
const NODES: DepartmentNode[] = [
department(ROOT_A, '内容生产中心', null, { sortOrder: 1, memberCount: 19, directMemberCount: 4 }),
department(CHILD_A2, '设计组', ROOT_A, { sortOrder: 2, memberCount: 3, directMemberCount: 3 }),
department(CHILD_A1, '素材一组', ROOT_A, { sortOrder: 1, memberCount: 9, directMemberCount: 9 }),
department(ROOT_B, '交付中心', null, { sortOrder: 2, memberCount: 14, directMemberCount: 14 }),
department(DISABLED_ROOT, '历史部门', null, { status: 'disabled', sortOrder: 9 }),
];
describe('部门树构建', () => {
it('按 sortOrder、名称排序并挂载下级小组', () => {
const model = buildDepartmentTree(NODES);
expect(model.roots.map((root) => root.node.name)).toEqual([
'内容生产中心',
'交付中心',
'历史部门',
]);
expect(model.roots[0]?.children.map((child) => child.name)).toEqual(['素材一组', '设计组']);
expect(model.roots[1]?.children).toEqual([]);
expect(childrenOf(model, ROOT_A).map((child) => child.id)).toEqual([CHILD_A1, CHILD_A2]);
expect(model.byId.get(ROOT_A)?.name).toBe('内容生产中心');
});
it('关闭「显示已停用」时过滤停用部门', () => {
const model = buildDepartmentTree(NODES);
const visible = filterTreeByStatus(model, false);
expect(visible.roots.map((root) => root.node.name)).toEqual(['内容生产中心', '交付中心']);
expect(filterTreeByStatus(model, true).nodes).toHaveLength(5);
});
it('含下级人数 = 总人数 - 直属人数', () => {
const model = buildDepartmentTree(NODES);
const root = model.byId.get(ROOT_A);
expect(root === undefined ? null : childMemberCount(root)).toBe(15);
});
});
describe('停用 / 启用前置约束', () => {
const root = department(ROOT_A, '内容生产中心', null, {
memberCount: 19,
directMemberCount: 0,
});
const child = department(CHILD_A1, '素材一组', ROOT_A, {
memberCount: 9,
directMemberCount: 9,
});
it('有直属在职成员时禁止停用', () => {
expect(departmentDisableBlocker(child, [])).toContain('9 名在职成员');
});
it('一级部门存在启用中的下级小组时禁止停用', () => {
expect(departmentDisableBlocker(root, [department(CHILD_A1, '素材一组', ROOT_A)])).toContain(
'启用中的下级小组',
);
});
it('下级仍有在职成员时禁止停用', () => {
const stoppedChildren = [department(CHILD_A1, '素材一组', ROOT_A, { status: 'disabled' })];
expect(departmentDisableBlocker(root, stoppedChildren)).toContain('下级小组仍有');
});
it('空部门允许停用;已停用返回提示', () => {
const emptyRoot = { ...root, memberCount: 0, directMemberCount: 0 };
expect(departmentDisableBlocker(emptyRoot, [])).toBeNull();
expect(departmentDisableBlocker({ ...child, status: 'disabled' }, [])).toBe('部门已停用');
});
it('上级停用时禁止启用下级小组', () => {
const disabledChild = { ...child, status: 'disabled' as const };
const disabledParent = { ...root, status: 'disabled' as const };
expect(departmentEnableBlocker(disabledChild, disabledParent)).toContain('上级部门已停用');
expect(departmentEnableBlocker(disabledChild, root)).toBeNull();
expect(departmentEnableBlocker({ ...child, status: 'active' }, root)).toBe('部门已启用');
});
});
@@ -0,0 +1,109 @@
import type { DepartmentNode } from '@lingxi/shared';
export interface DepartmentTreeGroup {
node: DepartmentNode;
children: DepartmentNode[];
}
export interface DepartmentTreeModel {
roots: DepartmentTreeGroup[];
nodes: DepartmentNode[];
byId: Map<string, DepartmentNode>;
}
function compareDepartments(left: DepartmentNode, right: DepartmentNode): number {
if (left.sortOrder !== right.sortOrder) {
return left.sortOrder - right.sortOrder;
}
return left.name.localeCompare(right.name, 'zh-Hans-CN');
}
/** 拍平列表 → 两级树(一级部门 + 直属小组),按 sortOrder、名称排序 */
export function buildDepartmentTree(nodes: readonly DepartmentNode[]): DepartmentTreeModel {
const sorted = [...nodes].sort(compareDepartments);
const byId = new Map(sorted.map((node) => [node.id, node]));
const childrenByParent = new Map<string, DepartmentNode[]>();
const roots: DepartmentTreeGroup[] = [];
for (const node of sorted) {
if (node.parentId === null) {
roots.push({ node, children: [] });
continue;
}
const children = childrenByParent.get(node.parentId) ?? [];
children.push(node);
childrenByParent.set(node.parentId, children);
}
for (const root of roots) {
root.children = childrenByParent.get(root.node.id) ?? [];
}
return { roots, nodes: sorted, byId };
}
export function childrenOf(model: DepartmentTreeModel, departmentId: string): DepartmentNode[] {
return model.nodes.filter((node) => node.parentId === departmentId);
}
/** 父级 + 自身名称链(详情「上级部门」展示;一级部门返回空数组) */
export function departmentPathNames(model: DepartmentTreeModel, departmentId: string): string[] {
const node = model.byId.get(departmentId);
if (node === undefined || node.parentId === null) {
return [];
}
const parent = model.byId.get(node.parentId);
return parent === undefined ? [] : [parent.name];
}
/** 过滤已停用部门(含其下级);管理页默认展示全部,开关关闭时只看启用 */
export function filterTreeByStatus(
model: DepartmentTreeModel,
showDisabled: boolean,
): DepartmentTreeModel {
if (showDisabled) {
return model;
}
return buildDepartmentTree(model.nodes.filter((node) => node.status === 'active'));
}
/** 下级小组在职成员数(含下级总人数 - 本部门直属人数) */
export function childMemberCount(node: DepartmentNode): number {
return Math.max(0, node.memberCount - node.directMemberCount);
}
/**
* 停用前置校验(与后端规则一致,用于按钮禁用与提示):
* 本部门有在职成员、或一级部门存在启用中的下级小组 / 下级在职成员时不可停用。
*/
export function departmentDisableBlocker(
node: DepartmentNode,
children: readonly DepartmentNode[],
): string | null {
if (node.status === 'disabled') {
return '部门已停用';
}
if (node.directMemberCount > 0) {
return `部门下仍有 ${String(node.directMemberCount)} 名在职成员,请先调整成员所属部门`;
}
const activeChildren = children.filter((child) => child.status === 'active');
if (activeChildren.length > 0) {
return `存在 ${String(activeChildren.length)} 个启用中的下级小组,请先停用下级小组`;
}
const nestedMembers = childMemberCount(node);
if (nestedMembers > 0) {
return `下级小组仍有 ${String(nestedMembers)} 名在职成员,请先调整成员所属部门`;
}
return null;
}
/** 启用前置校验:上级部门必须启用(小组不能挂到停用部门下) */
export function departmentEnableBlocker(
node: DepartmentNode,
parent: DepartmentNode | null,
): string | null {
if (node.status === 'active') {
return '部门已启用';
}
if (parent !== null && parent.status === 'disabled') {
return '上级部门已停用,请先启用上级部门';
}
return null;
}
@@ -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>;
}
@@ -1,97 +0,0 @@
/* eslint-disable max-lines-per-function -- 单组件按原型区块展开,拆分反而降低可读性 */
import { Button, SideSheet } from '@douyinfe/semi-ui-19';
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
import { IconCheck, IconStar } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { scopeLabelOf } from './role-display';
export interface PermissionCatalogDrawerProps {
visible: boolean;
tree: PermissionTree | undefined;
role: RoleDetail | null;
onClose: () => void;
}
/** 权限点目录抽屉:只读展示 30 个权限点与当前角色的持有情况 */
export function PermissionCatalogDrawer({
visible,
tree,
role,
onClose,
}: PermissionCatalogDrawerProps): ReactNode {
const granted = new Set<string>([
...(role?.permissions ?? []),
...(role?.specialPermissions ?? []),
]);
return (
<SideSheet
visible={visible}
title="权限点目录"
width={480}
placement="right"
onCancel={onClose}
footer={
<div className="lx-role-catalog__foot">
<span>权限点变更即时对绑定用户生效</span>
<Button onClick={onClose}>关闭</Button>
</div>
}
>
<div className="lx-role-catalog">
<p className="lx-role-catalog__sub">
{tree?.totalPermissions ?? 30} 个权限点 · {tree?.specialPermissions.length ?? 6}{' '}
个特殊权限;★ 默认关闭、需单独授予。
{role === null
? ' 当前角色尚未加载。'
: ` 当前勾选为「${role.name}」持有(${String(role.permissionCount + role.specialPermissionCount)} 个)。`}
</p>
{tree === undefined ? (
<p className="lx-role-catalog__empty">权限点目录加载中…</p>
) : (
tree.modules.map((module) => (
<section key={module.module} className="lx-role-catalog__module">
<header className="lx-role-catalog__module-head">
<b>{module.moduleLabel}</b>
<span>
{module.permissions.filter((permission) => granted.has(permission.code)).length} /{' '}
{module.permissions.length}
</span>
</header>
{module.permissions.map((permission) => {
const owned = granted.has(permission.code);
return (
<div
key={permission.code}
className={owned ? 'lx-role-catalog__row is-granted' : 'lx-role-catalog__row'}
>
<span className="lx-role-catalog__check" aria-hidden="true">
{owned ? <IconCheck size={12} /> : null}
</span>
<span className="lx-role-catalog__copy">
<span className="lx-role-catalog__name">
{permission.name}
{permission.isSpecial ? (
<span className="lx-role-tag lx-role-tag--special">★ 特殊</span>
) : null}
</span>
<span className="lx-role-catalog__desc">{permission.description}</span>
</span>
<code className="lx-role-catalog__code">{permission.code}</code>
</div>
);
})}
</section>
))
)}
<div className="lx-role-catalog__note">
<IconStar size={14} />
<span>
特殊权限 6 项与普通权限分开授予,不随角色复制带入;当前角色数据范围为「
{role === null ? '—' : scopeLabelOf(role.dataScope.type)}」。
</span>
</div>
</div>
</SideSheet>
);
}
@@ -125,7 +125,6 @@ function renderPanel(overrides: Partial<Parameters<typeof RoleDetailPanel>[0]> =
onRename={vi.fn()} onRename={vi.fn()}
onDisable={vi.fn()} onDisable={vi.fn()}
onEnable={vi.fn()} onEnable={vi.fn()}
onOpenCatalog={vi.fn()}
{...overrides} {...overrides}
/>, />,
); );
@@ -161,7 +160,8 @@ describe('角色详情面板', () => {
renderPanel({ canManage: false }); renderPanel({ canManage: false });
expect(screen.queryByText('复制')).toBeNull(); expect(screen.queryByText('复制')).toBeNull();
expect(screen.queryByText('停用')).toBeNull(); expect(screen.queryByText('停用')).toBeNull();
expect(screen.getByText('编辑角色')).toBeTruthy(); expect(screen.queryByText('编辑角色')).toBeNull();
expect(screen.getByText('查看角色')).toBeTruthy();
}); });
it('未选择角色时展示空态', () => { it('未选择角色时展示空态', () => {
@@ -2,7 +2,15 @@
import { Button } from '@douyinfe/semi-ui-19'; import { Button } from '@douyinfe/semi-ui-19';
import type { PermissionTree, RoleDetail } from '@lingxi/shared'; import type { PermissionTree, RoleDetail } from '@lingxi/shared';
import { ErrorState, PageEmpty, PageSkeleton } from '@lingxi/ui'; import { ErrorState, PageEmpty, PageSkeleton } from '@lingxi/ui';
import { IconCopy, IconEdit, IconPause, IconPlay, IconShield, IconStar } from '@lingxi/ui/icons'; import {
IconCopy,
IconEdit,
IconPause,
IconPlay,
IconShield,
IconStar,
IconView,
} from '@lingxi/ui/icons';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useState } from 'react'; import { useState } from 'react';
@@ -29,7 +37,6 @@ export interface RoleDetailPanelProps {
onRename: (role: RoleDetail) => void; onRename: (role: RoleDetail) => void;
onDisable: (role: RoleDetail) => void; onDisable: (role: RoleDetail) => void;
onEnable: (role: RoleDetail) => void; onEnable: (role: RoleDetail) => void;
onOpenCatalog: (role: RoleDetail) => void;
} }
function RoleAvatar({ status }: { status: 'active' | 'disabled' }): ReactNode { function RoleAvatar({ status }: { status: 'active' | 'disabled' }): ReactNode {
@@ -48,11 +55,15 @@ function RoleAvatar({ status }: { status: 'active' | 'disabled' }): ReactNode {
function DetailHead({ function DetailHead({
role, role,
canManage, canManage,
onEdit,
onCopy, onCopy,
onRename, onRename,
onDisable, onDisable,
onEnable, onEnable,
}: Pick<RoleDetailPanelProps, 'canManage' | 'onCopy' | 'onRename' | 'onDisable' | 'onEnable'> & { }: Pick<
RoleDetailPanelProps,
'canManage' | 'onEdit' | 'onCopy' | 'onRename' | 'onDisable' | 'onEnable'
> & {
role: RoleDetail; role: RoleDetail;
}): ReactNode { }): ReactNode {
return ( return (
@@ -66,8 +77,9 @@ function DetailHead({
</div> </div>
<p className="lx-role-detail__desc">{role.description}</p> <p className="lx-role-detail__desc">{role.description}</p>
</div> </div>
{canManage ? (
<div className="lx-role-detail__actions"> <div className="lx-role-detail__actions">
{canManage ? (
<>
<Button <Button
icon={<IconCopy size={14} />} icon={<IconCopy size={14} />}
onClick={() => { onClick={() => {
@@ -105,8 +117,19 @@ function DetailHead({
启用 启用
</Button> </Button>
)} )}
</div> </>
) : null} ) : null}
<Button
theme="solid"
type="primary"
icon={canManage && !role.isSystem ? <IconEdit size={14} /> : <IconView size={14} />}
onClick={() => {
onEdit(role.id);
}}
>
{canManage && !role.isSystem ? '编辑角色' : '查看角色'}
</Button>
</div>
</header> </header>
); );
} }
@@ -114,11 +137,9 @@ function DetailHead({
function PermissionSummary({ function PermissionSummary({
role, role,
tree, tree,
onOpenCatalog,
}: { }: {
role: RoleDetail; role: RoleDetail;
tree: PermissionTree | undefined; tree: PermissionTree | undefined;
onOpenCatalog: () => void;
}): ReactNode { }): ReactNode {
const selected = [...role.permissions, ...role.specialPermissions]; const selected = [...role.permissions, ...role.specialPermissions];
const modules = tree === undefined ? [] : summarizePermissionModules(tree, selected); const modules = tree === undefined ? [] : summarizePermissionModules(tree, selected);
@@ -130,9 +151,6 @@ function PermissionSummary({
已选 {role.permissionCount + role.specialPermissionCount} /{' '} 已选 {role.permissionCount + role.specialPermissionCount} /{' '}
{tree?.totalPermissions ?? '—'} {tree?.totalPermissions ?? '—'}
</span> </span>
<Button theme="borderless" size="small" onClick={onOpenCatalog}>
查看权限点目录
</Button>
</div> </div>
{modules.length === 0 ? ( {modules.length === 0 ? (
<p className="lx-role-detail__muted">权限点目录加载中…</p> <p className="lx-role-detail__muted">权限点目录加载中…</p>
@@ -306,6 +324,7 @@ export function RoleDetailPanel(props: RoleDetailPanelProps): ReactNode {
<DetailHead <DetailHead
role={detail} role={detail}
canManage={props.canManage} canManage={props.canManage}
onEdit={props.onEdit}
onCopy={props.onCopy} onCopy={props.onCopy}
onRename={props.onRename} onRename={props.onRename}
onDisable={props.onDisable} onDisable={props.onDisable}
@@ -326,31 +345,10 @@ export function RoleDetailPanel(props: RoleDetailPanelProps): ReactNode {
{formatRoleDateTime(detail.updatedAt)} {formatRoleDateTime(detail.updatedAt)}
</span> </span>
</div> </div>
<PermissionSummary <PermissionSummary role={detail} tree={props.tree} />
role={detail}
tree={props.tree}
onOpenCatalog={() => {
props.onOpenCatalog(detail);
}}
/>
<ScopeAndSpecial role={detail} tree={props.tree} /> <ScopeAndSpecial role={detail} tree={props.tree} />
<BoundUsers role={detail} /> <BoundUsers role={detail} />
</div> </div>
<footer className="lx-role-detail__foot">
<span className="lx-role-detail__foot-note">
{detail.isSystem ? '系统内置角色,不可停用或删除' : '自定义角色,可复制、停用或删除'}
</span>
<Button
theme="solid"
type="primary"
icon={<IconEdit size={14} />}
onClick={() => {
props.onEdit(detail.id);
}}
>
编辑角色
</Button>
</footer>
</> </>
) : ( ) : (
<div className="lx-role-detail__empty"> <div className="lx-role-detail__empty">
@@ -83,7 +83,7 @@ function TemplateList({
onRetry={onRetry} onRetry={onRetry}
/> />
) : ( ) : (
<div role="listbox" aria-label="选择模板"> <div className="lx-role-template-list" role="listbox" aria-label="选择模板">
<button <button
type="button" type="button"
role="option" role="option"
+3 -24
View File
@@ -2,7 +2,7 @@
import { Button, Input, Switch } from '@douyinfe/semi-ui-19'; import { Button, Input, Switch } from '@douyinfe/semi-ui-19';
import { Permission, type RoleDetail, type RoleListItem } from '@lingxi/shared'; import { Permission, type RoleDetail, type RoleListItem } from '@lingxi/shared';
import { notifySuccess } from '@lingxi/ui'; import { notifySuccess } from '@lingxi/ui';
import { IconAdd, IconSearch, IconShield } from '@lingxi/ui/icons'; import { IconAdd, IconSearch } from '@lingxi/ui/icons';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -19,7 +19,6 @@ import {
import { usePermission } from '@/hooks/use-permission'; import { usePermission } from '@/hooks/use-permission';
import { roleDetailPath, ROUTES } from '@/routes'; import { roleDetailPath, ROUTES } from '@/routes';
import { PermissionCatalogDrawer } from './PermissionCatalogDrawer';
import { import {
CopyRoleDialog, CopyRoleDialog,
DeleteRoleDialog, DeleteRoleDialog,
@@ -129,7 +128,6 @@ export function RolesPage(): ReactNode {
const [filters, setFilters] = useState<RoleListFilters>(DEFAULT_ROLE_LIST_FILTERS); const [filters, setFilters] = useState<RoleListFilters>(DEFAULT_ROLE_LIST_FILTERS);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [dialog, setDialog] = useState<DialogState>(null); const [dialog, setDialog] = useState<DialogState>(null);
const [catalogOpen, setCatalogOpen] = useState(false);
const filteredRoles = useMemo(() => filterRoles(roles, filters), [roles, filters]); const filteredRoles = useMemo(() => filterRoles(roles, filters), [roles, filters]);
const stats = useMemo(() => summarizeRoles(roles), [roles]); const stats = useMemo(() => summarizeRoles(roles), [roles]);
const effectiveSelectedId = filteredRoles.some((role) => role.id === selectedId) const effectiveSelectedId = filteredRoles.some((role) => role.id === selectedId)
@@ -171,19 +169,11 @@ export function RolesPage(): ReactNode {
return ( return (
<div className="lx-roles"> <div className="lx-roles">
<header className="lx-roles__header"> <header className="lx-roles__header">
<div> <div className="lx-roles__head-txt">
<h1>角色权限</h1> <h1>角色管理</h1>
<p>配置功能权限、数据范围与特殊权限,变更即时生效并留痕</p> <p>配置功能权限、数据范围与特殊权限,变更即时生效并留痕</p>
</div> </div>
<div className="lx-roles__header-actions"> <div className="lx-roles__header-actions">
<Button
icon={<IconShield size={14} />}
onClick={() => {
setCatalogOpen(true);
}}
>
权限点目录
</Button>
<Can permission={Permission.ROLE_MANAGE}> <Can permission={Permission.ROLE_MANAGE}>
<Button <Button
theme="solid" theme="solid"
@@ -265,9 +255,6 @@ export function RolesPage(): ReactNode {
}, },
); );
}} }}
onOpenCatalog={() => {
setCatalogOpen(true);
}}
/> />
</div> </div>
@@ -383,14 +370,6 @@ export function RolesPage(): ReactNode {
); );
}} }}
/> />
<PermissionCatalogDrawer
visible={catalogOpen}
tree={tree.data}
role={selectedDetail ?? null}
onClose={() => {
setCatalogOpen(false);
}}
/>
</div> </div>
); );
} }
@@ -93,7 +93,7 @@ export function UserRolesTab({
<div className="lx-detail-tab"> <div className="lx-detail-tab">
<SectionHead <SectionHead
title="已绑定角色" title="已绑定角色"
hint="角色决定成员可访问的模块与操作;权限点由「角色权限」统一维护。" hint="角色决定成员可访问的模块与操作;权限点由「角色管理」统一维护。"
action={ action={
canManage ? ( canManage ? (
<Button <Button
@@ -31,7 +31,7 @@ function UsersHeader(): ReactNode {
return ( return (
<header className="lx-users__header"> <header className="lx-users__header">
<div> <div>
<h1>用户与组织</h1> <h1>用户管理</h1>
<p>管理成员账号、角色与客户归属</p> <p>管理成员账号、角色与客户归属</p>
</div> </div>
<Can permission={Permission.USER_MANAGE}> <Can permission={Permission.USER_MANAGE}>
@@ -29,7 +29,7 @@ export function SuperAdminGrantDialog({
consequences={[ consequences={[
'超级管理员拥有全部模块权限与全部客户数据范围', '超级管理员拥有全部模块权限与全部客户数据范围',
'授予即时生效并写入审计;建议全平台超级管理员不超过 2 人', '授予即时生效并写入审计;建议全平台超级管理员不超过 2 人',
'如需收回,请在「角色权限」中移除该角色', '如需收回,请在「角色管理」中移除该角色',
]} ]}
acknowledgeLabel="我已确认授权范围,并同意授予超级管理员" acknowledgeLabel="我已确认授权范围,并同意授予超级管理员"
confirmText="确认授予" confirmText="确认授予"
@@ -22,7 +22,10 @@ const departments: DepartmentNode[] = [
name: '内容生产中心', name: '内容生产中心',
parentId: null, parentId: null,
leader: null, leader: null,
status: 'active',
sortOrder: 1,
memberCount: 19, memberCount: 19,
directMemberCount: 4,
createdAt: '2026-09-01T00:00:00.000Z', createdAt: '2026-09-01T00:00:00.000Z',
}, },
{ {
@@ -30,7 +33,10 @@ const departments: DepartmentNode[] = [
name: '素材一组', name: '素材一组',
parentId: ROOT_ID, parentId: ROOT_ID,
leader: null, leader: null,
status: 'active',
sortOrder: 1,
memberCount: 9, memberCount: 9,
directMemberCount: 9,
createdAt: '2026-09-01T00:00:00.000Z', createdAt: '2026-09-01T00:00:00.000Z',
}, },
{ {
@@ -38,7 +44,10 @@ const departments: DepartmentNode[] = [
name: '交付中心', name: '交付中心',
parentId: null, parentId: null,
leader: null, leader: null,
status: 'active',
sortOrder: 2,
memberCount: 14, memberCount: 14,
directMemberCount: 14,
createdAt: '2026-09-01T00:00:00.000Z', createdAt: '2026-09-01T00:00:00.000Z',
}, },
]; ];
+91
View File
@@ -24,6 +24,11 @@ const UserNewPage = lazy(async () => {
return { default: module.UserNewPage }; return { default: module.UserNewPage };
}); });
const DepartmentsPage = lazy(async () => {
const module = await import('@/pages/system/departments/DepartmentsPage');
return { default: module.DepartmentsPage };
});
const RolesPage = lazy(async () => { const RolesPage = lazy(async () => {
const module = await import('@/pages/system/roles/RolesPage'); const module = await import('@/pages/system/roles/RolesPage');
return { default: module.RolesPage }; return { default: module.RolesPage };
@@ -39,6 +44,36 @@ 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 };
});
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>;
} }
@@ -97,6 +132,14 @@ export const router = createBrowserRouter([
</RequirePermission> </RequirePermission>
), ),
}, },
{
path: 'system/departments',
element: (
<RequirePermission permission={Permission.USER_VIEW}>
{withSkeleton(<DepartmentsPage />)}
</RequirePermission>
),
},
{ {
path: 'system/roles', path: 'system/roles',
element: ( element: (
@@ -121,6 +164,54 @@ 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: ':moduleKey/:itemKey?', path: ':moduleKey/:itemKey?',
lazy: async () => ({ lazy: async () => ({
+4
View File
@@ -31,6 +31,10 @@ describe('路由常量与登录回跳', () => {
); );
}); });
it('部门管理路由挂在管理中心下', () => {
expect(ROUTES.system.departments).toBe('/system/departments');
});
it('sanitizeRedirect 只接受站内路径', () => { it('sanitizeRedirect 只接受站内路径', () => {
expect(sanitizeRedirect('/system/users/new')).toBe('/system/users/new'); expect(sanitizeRedirect('/system/users/new')).toBe('/system/users/new');
expect(sanitizeRedirect(' /system/users?status=active ')).toBe('/system/users?status=active'); expect(sanitizeRedirect(' /system/users?status=active ')).toBe('/system/users?status=active');
+24
View File
@@ -12,9 +12,16 @@ export const ROUTES = {
users: '/system/users', users: '/system/users',
userNew: '/system/users/new', userNew: '/system/users/new',
userDetail: '/system/users/:id', userDetail: '/system/users/:id',
departments: '/system/departments',
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',
}, },
unauthorized: '/401', unauthorized: '/401',
forbidden: '/403', forbidden: '/403',
@@ -29,9 +36,16 @@ export type RoutePath =
| typeof ROUTES.system.users | typeof ROUTES.system.users
| typeof ROUTES.system.userNew | typeof ROUTES.system.userNew
| typeof ROUTES.system.userDetail | typeof ROUTES.system.userDetail
| typeof ROUTES.system.departments
| 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.unauthorized | typeof ROUTES.unauthorized
| typeof ROUTES.forbidden | typeof ROUTES.forbidden
| typeof ROUTES.notFound | typeof ROUTES.notFound
@@ -47,6 +61,16 @@ 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)}`;
}
/** 新建客户(四步向导) */
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
+446
View File
@@ -0,0 +1,446 @@
/* 管理中心 · 部门管理(/system/departments):左组织树 + 右详情/编辑 */
.lx-depts {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: var(--lx-space-4);
}
.lx-depts__header {
display: flex;
flex: 0 0 auto;
align-items: flex-start;
justify-content: space-between;
gap: var(--lx-space-4);
}
.lx-depts__header h1 {
font-size: 20px;
font-weight: 600;
line-height: 28px;
color: var(--lx-color-text-primary);
}
.lx-depts__header p {
margin-top: 2px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
}
.lx-depts__split {
display: flex;
min-height: 0;
flex: 1;
gap: var(--lx-space-4);
}
/* ---------- 左侧组织树 ---------- */
.lx-dept-tree {
display: flex;
width: 400px;
flex: 0 0 400px;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-lg);
background: var(--lx-color-bg-panel);
box-shadow: var(--lx-shadow-panel);
}
.lx-dept-tree__head {
display: flex;
height: 48px;
flex: 0 0 48px;
align-items: center;
gap: var(--lx-space-2);
padding: 0 var(--lx-space-4);
border-bottom: 1px solid var(--lx-color-border);
}
.lx-dept-tree__title {
font-size: var(--lx-font-size-sm);
font-weight: 600;
color: var(--lx-color-text-primary);
}
.lx-dept-tree__count {
padding: 0 7px;
border-radius: var(--lx-radius-pill);
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
font-size: 11px;
line-height: 20px;
font-variant-numeric: tabular-nums;
}
.lx-dept-tree__toggle {
display: inline-flex;
margin-left: auto;
align-items: center;
gap: 6px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
}
.lx-dept-tree__body {
min-height: 0;
flex: 1;
overflow-y: auto;
}
.lx-dept-tree__state {
padding: var(--lx-space-5);
}
.lx-dept-tree__list {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px;
}
.lx-dept-tree__children {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 2px;
padding-left: 14px;
}
.lx-dept-row {
display: flex;
min-height: 56px;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: var(--lx-radius-md);
transition: background var(--lx-motion-fast) var(--lx-ease-out);
}
.lx-dept-row:hover {
background: var(--lx-color-bg-hover);
}
/* 选中态:品牌浅底 + 品牌色标题(与侧栏导航同一套语言,不再画左侧竖条) */
.lx-dept-row.is-selected {
background: var(--lx-color-brand-soft);
}
.lx-dept-row.is-disabled .lx-dept-row__name b,
.lx-dept-row.is-disabled .lx-dept-row__count {
color: var(--lx-color-text-tertiary);
}
.lx-dept-row__caret {
display: inline-flex;
width: 22px;
height: 22px;
flex: 0 0 22px;
align-items: center;
justify-content: center;
border-radius: var(--lx-radius-sm);
color: var(--lx-color-text-tertiary);
}
.lx-dept-row__caret:hover {
background: var(--lx-color-bg-active);
color: var(--lx-color-brand);
}
.lx-dept-row__caret--placeholder {
visibility: hidden;
}
.lx-dept-row__main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 4px 2px;
text-align: left;
}
.lx-dept-row__name {
display: inline-flex;
align-items: center;
gap: 6px;
}
.lx-dept-row__name b {
font-size: var(--lx-font-size-sm);
font-weight: 500;
color: var(--lx-color-text-primary);
}
.lx-dept-row.is-selected .lx-dept-row__name b {
color: var(--lx-color-brand);
font-weight: 600;
}
.lx-dept-row__meta {
overflow: hidden;
max-width: 210px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}
.lx-dept-row__count {
flex: 0 0 auto;
padding-right: 4px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
font-variant-numeric: tabular-nums;
}
/* ---------- 右侧详情 ---------- */
.lx-dept-detail {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-lg);
background: var(--lx-color-bg-panel);
box-shadow: var(--lx-shadow-panel);
}
.lx-dept-detail__empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
}
.lx-dept-detail__head {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--lx-space-4);
padding: var(--lx-space-4) var(--lx-space-5);
border-bottom: 1px solid var(--lx-color-border);
}
.lx-dept-detail__avatar {
display: inline-flex;
width: 44px;
height: 44px;
flex: 0 0 44px;
align-items: center;
justify-content: center;
border-radius: var(--lx-radius-md);
background: var(--lx-color-brand-soft);
color: var(--lx-color-brand);
font-size: var(--lx-font-size-lg);
font-weight: 600;
}
.lx-dept-detail__ident {
min-width: 0;
flex: 1;
}
.lx-dept-detail__name {
display: inline-flex;
align-items: center;
gap: var(--lx-space-2);
}
.lx-dept-detail__name h2 {
font-size: var(--lx-font-size-lg);
font-weight: 600;
line-height: 24px;
color: var(--lx-color-text-primary);
}
.lx-dept-detail__meta {
margin-top: 2px;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
font-variant-numeric: tabular-nums;
}
.lx-dept-detail__actions {
display: flex;
flex: 0 0 auto;
gap: var(--lx-space-2);
}
.lx-dept-detail__readonly {
flex: 0 0 auto;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-dept-tag {
display: inline-flex;
height: 20px;
align-items: center;
padding: 0 6px;
border-radius: var(--lx-radius-sm);
background: var(--lx-color-bg-hover);
color: var(--lx-color-text-secondary);
font-size: 11px;
line-height: 20px;
}
.lx-dept-detail__body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: var(--lx-space-6);
padding: var(--lx-space-5);
overflow-y: auto;
}
.lx-dept-detail__section {
display: flex;
flex-direction: column;
gap: var(--lx-space-3);
}
.lx-dept-detail__section-head {
display: flex;
align-items: center;
gap: var(--lx-space-2);
}
.lx-dept-detail__section-head > .semi-button {
margin-left: auto;
}
.lx-dept-detail__section-title {
font-size: var(--lx-font-size-sm);
font-weight: 600;
color: var(--lx-color-text-primary);
}
.lx-dept-notice {
padding: 9px 12px;
border-radius: var(--lx-radius-md);
background: color-mix(in srgb, var(--lx-color-warning) 10%, white);
color: color-mix(in srgb, var(--lx-color-warning) 78%, black);
font-size: var(--lx-font-size-xs);
line-height: 18px;
}
.lx-dept-detail__muted {
font-size: var(--lx-font-size-xs);
line-height: 18px;
color: var(--lx-color-text-tertiary);
}
.lx-dept-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--lx-space-3) var(--lx-space-6);
}
.lx-dept-children {
display: flex;
flex-direction: column;
border: 1px solid var(--lx-color-border);
border-radius: var(--lx-radius-md);
overflow: hidden;
}
.lx-dept-children li + li .lx-dept-child-row {
border-top: 1px solid var(--lx-color-border);
}
.lx-dept-child-row {
display: flex;
width: 100%;
align-items: center;
gap: var(--lx-space-3);
padding: 10px var(--lx-space-4);
text-align: left;
}
.lx-dept-child-row:hover {
background: var(--lx-color-bg-hover);
}
.lx-dept-child-row__name {
display: inline-flex;
align-items: center;
gap: var(--lx-space-2);
font-size: var(--lx-font-size-sm);
color: var(--lx-color-text-primary);
}
.lx-dept-child-row__meta {
margin-left: auto;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
font-variant-numeric: tabular-nums;
}
.lx-dept-edit__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--lx-space-4);
}
.lx-dept-edit__actions {
display: flex;
justify-content: flex-end;
gap: var(--lx-space-2);
}
.lx-dept-dialog {
display: flex;
flex-direction: column;
gap: var(--lx-space-3);
}
.lx-dept-dialog__context {
padding: 8px 12px;
border-radius: var(--lx-radius-md);
background: var(--lx-color-bg-subtle);
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-secondary);
}
.lx-dept-dialog__context b {
color: var(--lx-color-text-primary);
font-weight: 500;
}
.lx-dept-detail--loading {
justify-content: center;
}
/* ---------- 响应式:1280 / 1024 ---------- */
@media (width <= 1280px) {
.lx-dept-tree {
width: 340px;
flex-basis: 340px;
}
}
@media (width <= 1080px) {
.lx-dept-tree {
width: 300px;
flex-basis: 300px;
}
.lx-dept-row__meta {
max-width: 160px;
}
.lx-dept-kv-grid,
.lx-dept-edit__grid {
grid-template-columns: minmax(0, 1fr);
}
}
+24 -165
View File
@@ -21,6 +21,7 @@
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
align-items: flex-start; align-items: flex-start;
justify-content: space-between;
gap: var(--lx-space-3); gap: var(--lx-space-3);
} }
@@ -53,6 +54,7 @@
flex: 0 0 auto; flex: 0 0 auto;
align-items: center; align-items: center;
gap: var(--lx-space-2); gap: var(--lx-space-2);
margin-left: auto;
} }
.lx-roles__back { .lx-roles__back {
@@ -218,8 +220,9 @@
.lx-role-list__items { .lx-role-list__items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px;
margin: 0; margin: 0;
padding: 0; padding: 6px 8px;
list-style: none; list-style: none;
} }
@@ -227,20 +230,16 @@
position: relative; position: relative;
display: flex; display: flex;
width: 100%; width: 100%;
min-height: 64px; min-height: 60px;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 8px 10px 8px 14px; padding: 8px 10px 8px 12px;
border-bottom: 1px solid var(--lx-color-border); border-radius: var(--lx-radius-md);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: background var(--lx-motion-fast) var(--lx-ease-out); transition: background var(--lx-motion-fast) var(--lx-ease-out);
} }
.lx-role-list__items li:last-child .lx-role-row {
border-bottom: none;
}
.lx-role-row:hover { .lx-role-row:hover {
background: var(--lx-color-bg-hover); background: var(--lx-color-bg-hover);
} }
@@ -250,9 +249,9 @@
outline-offset: -2px; outline-offset: -2px;
} }
/* 选中态:品牌浅底 + 品牌色标题(与侧栏导航同一套语言,不再画左侧竖条) */
.lx-role-row.is-selected { .lx-role-row.is-selected {
background: var(--lx-color-brand-soft); background: var(--lx-color-brand-soft);
box-shadow: inset 2px 0 0 var(--lx-color-brand);
} }
.lx-role-row__icon { .lx-role-row__icon {
@@ -300,17 +299,16 @@
.lx-role-row.is-selected .lx-role-row__name b { .lx-role-row.is-selected .lx-role-row__name b {
color: var(--lx-color-brand); color: var(--lx-color-brand);
font-weight: 600;
} }
.lx-role-row.is-disabled .lx-role-row__name b { .lx-role-row.is-disabled .lx-role-row__name b {
color: var(--lx-color-text-secondary); color: var(--lx-color-text-secondary);
} }
/* 块级 + ellipsis:角色编码与数据范围可能超宽,避免被侧栏裁掉半个字 */
.lx-role-row__meta { .lx-role-row__meta {
display: flex;
overflow: hidden; overflow: hidden;
align-items: center;
gap: 4px;
font-size: var(--lx-font-size-xs); font-size: var(--lx-font-size-xs);
line-height: 16px; line-height: 16px;
color: var(--lx-color-text-secondary); color: var(--lx-color-text-secondary);
@@ -320,13 +318,14 @@
} }
.lx-role-row__dot { .lx-role-row__dot {
margin: 0 4px;
color: var(--lx-color-border-strong); color: var(--lx-color-border-strong);
} }
.lx-role-row__side { .lx-role-row__side {
display: flex; display: flex;
width: 132px; width: 112px;
flex: 0 0 132px; flex: 0 0 112px;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
gap: 4px; gap: 4px;
@@ -709,25 +708,6 @@
white-space: nowrap; white-space: nowrap;
} }
.lx-role-detail__foot {
display: flex;
height: 56px;
flex: 0 0 56px;
align-items: center;
justify-content: space-between;
gap: var(--lx-space-3);
padding: 0 18px;
border-top: 1px solid var(--lx-color-border);
}
.lx-role-detail__foot-note {
overflow: hidden;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---------- 角色模板(新建页左列) ---------- */ /* ---------- 角色模板(新建页左列) ---------- */
.lx-role-templates { .lx-role-templates {
@@ -773,13 +753,20 @@
padding: var(--lx-space-4); padding: var(--lx-space-4);
} }
.lx-role-template-list {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
}
.lx-role-template { .lx-role-template {
display: flex; display: flex;
width: 100%; width: 100%;
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 10px;
padding: 10px 14px; padding: 10px 12px;
border-bottom: 1px solid var(--lx-color-border); border-radius: var(--lx-radius-md);
text-align: left; text-align: left;
transition: background var(--lx-motion-fast) var(--lx-ease-out); transition: background var(--lx-motion-fast) var(--lx-ease-out);
} }
@@ -793,9 +780,9 @@
outline-offset: -2px; outline-offset: -2px;
} }
/* 选中态:品牌浅底 + 品牌色标题(与侧栏导航同一套语言,不再画左侧竖条) */
.lx-role-template.is-active { .lx-role-template.is-active {
background: var(--lx-color-brand-soft); background: var(--lx-color-brand-soft);
box-shadow: inset 2px 0 0 var(--lx-color-brand);
} }
.lx-role-template__icon { .lx-role-template__icon {
@@ -834,6 +821,7 @@
.lx-role-template.is-active .lx-role-template__name { .lx-role-template.is-active .lx-role-template__name {
color: var(--lx-color-brand); color: var(--lx-color-brand);
font-weight: 600;
} }
.lx-role-template__desc { .lx-role-template__desc {
@@ -1120,135 +1108,6 @@
word-break: break-all; word-break: break-all;
} }
/* ---------- 权限点目录抽屉 ---------- */
.lx-role-catalog {
display: flex;
flex-direction: column;
gap: var(--lx-space-3);
padding-bottom: var(--lx-space-4);
}
.lx-role-catalog__sub {
font-size: var(--lx-font-size-xs);
line-height: 18px;
color: var(--lx-color-text-secondary);
}
.lx-role-catalog__module {
padding-bottom: 8px;
border-bottom: 1px solid var(--lx-color-border);
}
.lx-role-catalog__module:last-of-type {
border-bottom: none;
}
.lx-role-catalog__module-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0 4px;
font-size: var(--lx-font-size-sm);
font-weight: 600;
color: var(--lx-color-text-primary);
}
.lx-role-catalog__module-head span {
font-size: var(--lx-font-size-xs);
font-weight: 400;
color: var(--lx-color-text-tertiary);
font-variant-numeric: tabular-nums;
}
.lx-role-catalog__row {
display: flex;
align-items: flex-start;
gap: var(--lx-space-2);
padding: 7px 0;
border-bottom: 1px solid var(--lx-color-border);
}
.lx-role-catalog__row:last-child {
border-bottom: none;
}
.lx-role-catalog__check {
display: inline-flex;
width: 15px;
height: 15px;
flex: 0 0 15px;
align-items: center;
justify-content: center;
margin-top: 2px;
border: 1px solid var(--lx-color-border-strong);
border-radius: 3px;
color: var(--lx-color-on-brand);
}
.lx-role-catalog__row.is-granted .lx-role-catalog__check {
border-color: var(--lx-color-brand);
background: var(--lx-color-brand);
}
.lx-role-catalog__copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 1px;
}
.lx-role-catalog__name {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--lx-font-size-sm);
color: var(--lx-color-text-primary);
}
.lx-role-catalog__desc {
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-role-catalog__code {
flex: 0 0 auto;
padding-top: 2px;
font-family: var(--lx-font-mono);
font-size: 11px;
color: var(--lx-color-text-tertiary);
white-space: nowrap;
}
.lx-role-catalog__note {
display: flex;
align-items: flex-start;
gap: var(--lx-space-2);
padding: 10px 12px;
border-radius: var(--lx-radius-md);
background: var(--lx-color-bg-subtle);
font-size: var(--lx-font-size-xs);
line-height: 18px;
color: var(--lx-color-text-secondary);
}
.lx-role-catalog__foot {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
}
.lx-role-catalog__empty {
padding: var(--lx-space-5);
font-size: var(--lx-font-size-xs);
color: var(--lx-color-text-tertiary);
text-align: center;
}
/* ---------- 响应式 ---------- */ /* ---------- 响应式 ---------- */
@media (width <= 1280px) { @media (width <= 1280px) {
+8 -9
View File
@@ -1,4 +1,4 @@
/* A1 · 用户与组织(方案 B:主从分栏 + 分屏登录) */ /* A1 · 用户管理(方案 B:主从分栏 + 分屏登录) */
/* ---------- 页面骨架 ---------- */ /* ---------- 页面骨架 ---------- */
@@ -159,31 +159,29 @@
.lx-user-list__items { .lx-user-list__items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px;
padding: 6px 8px;
} }
.lx-user-row { .lx-user-row {
display: flex; display: flex;
width: 100%; width: 100%;
min-height: 64px; min-height: 60px;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 8px var(--lx-space-4); padding: 8px 12px;
border-bottom: 1px solid var(--lx-color-border); border-radius: var(--lx-radius-md);
text-align: left; text-align: left;
transition: background var(--lx-motion-fast) var(--lx-ease-out); transition: background var(--lx-motion-fast) var(--lx-ease-out);
} }
.lx-user-row:last-child {
border-bottom: none;
}
.lx-user-row:hover { .lx-user-row:hover {
background: var(--lx-color-bg-hover); background: var(--lx-color-bg-hover);
} }
/* 选中态:品牌浅底 + 品牌色标题(与侧栏导航同一套语言,不再画左侧竖条) */
.lx-user-row.is-selected { .lx-user-row.is-selected {
background: var(--lx-color-brand-soft); background: var(--lx-color-brand-soft);
box-shadow: inset 2px 0 0 var(--lx-color-brand);
} }
.lx-user-row__avatar { .lx-user-row__avatar {
@@ -224,6 +222,7 @@
.lx-user-row.is-selected .lx-user-row__name { .lx-user-row.is-selected .lx-user-row__name {
color: var(--lx-color-brand); color: var(--lx-color-brand);
font-weight: 600;
} }
.lx-user-row__phone { .lx-user-row__phone {
+4
View File
@@ -8,6 +8,7 @@
# 特性: # 特性:
# · 启动前自动检测端口占用,占用则结束对应进程(可用 --no-kill 改为直接失败) # · 启动前自动检测端口占用,占用则结束对应进程(可用 --no-kill 改为直接失败)
# · 后台启动 + 健康检查,返回后可用 logs/status/stop 管理 # · 后台启动 + 健康检查,返回后可用 logs/status/stop 管理
# · 不执行任何数据库迁移 / 种子 / 重置;已有账号密码不会被本脚本改动
# · 停止时按进程组结束,并兜底清理端口,避免遗留孤儿进程 # · 停止时按进程组结束,并兜底清理端口,避免遗留孤儿进程
# · api(3000) / web(5173) / worker(无端口) # · api(3000) / web(5173) / worker(无端口)
# #
@@ -71,6 +72,9 @@ usage() {
cat <<'EOF_USAGE' cat <<'EOF_USAGE'
灵犀 · 开发机服务启动脚本(不启动 DB / Redis 容器) 灵犀 · 开发机服务启动脚本(不启动 DB / Redis 容器)
说明:本脚本只启停 api / web / worker,不执行迁移、seed 或 db:reset;
已有账号密码不会被改动。需要清库请显式运行 pnpm db:reset。
用法: 用法:
./dev.sh 启动服务并前台跟随运行日志(Ctrl+C 停止全部) ./dev.sh 启动服务并前台跟随运行日志(Ctrl+C 停止全部)
./dev.sh start 同上 ./dev.sh start 同上
@@ -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 小时内以新提交补齐,并在提交说明记录。
@@ -2,7 +2,7 @@
| 项 | 内容 | | 项 | 内容 |
| --- | --- | | --- | --- |
| 文档版本 | V1.0(2026-11-03) | | 文档版本 | V1.1(2026-11-03) |
| 依据 | 技术架构 §6 / §10、[04 · API 与路由规范](04-API与路由规范.md) §8、`AGENTS.md` §5 | | 依据 | 技术架构 §6 / §10、[04 · API 与路由规范](04-API与路由规范.md) §8、`AGENTS.md` §5 |
| 适用范围 | `apps/web` 全部页面路由、导航配置与页面目录 | | 适用范围 | `apps/web` 全部页面路由、导航配置与页面目录 |
| 强制级别 | 强制 | | 强制级别 | 强制 |
@@ -63,7 +63,7 @@ apps/web/src/pages/
├─ assets/ # 资产(对应 /assets) ├─ assets/ # 资产(对应 /assets)
├─ delivery/ # 交付(对应 /delivery) ├─ delivery/ # 交付(对应 /delivery)
├─ system/ # 管理中心(对应 /system) ├─ system/ # 管理中心(对应 /system)
│ └─ users/ # 用户与组织(对应 /system/users) │ └─ users/ # 用户管理(对应 /system/users)
│ ├─ UsersPage.tsx │ ├─ UsersPage.tsx
│ ├─ UserNewPage.tsx │ ├─ UserNewPage.tsx
│ ├─ UserListPanel.tsx │ ├─ UserListPanel.tsx
@@ -116,10 +116,11 @@ apps/web/src/pages/
| 素材 | `/material/image-compose`、`/material/video`、`/material/tasks`、`/material/quick-review`、`/material/preview` | | 素材 | `/material/image-compose`、`/material/video`、`/material/tasks`、`/material/quick-review`、`/material/preview` |
| 资产 | `/assets/library`、`/assets/templates`、`/assets/pools`、`/assets/prompts` | | 资产 | `/assets/library`、`/assets/templates`、`/assets/pools`、`/assets/prompts` |
| 交付 | `/delivery/export`、`/delivery/records`、`/delivery/manifests` | | 交付 | `/delivery/export`、`/delivery/records`、`/delivery/manifests` |
| 管理中心 | `/system/users`、`/system/roles`、`/system/customers`、`/system/industries`、`/system/dicts`、`/system/audit-logs`、`/system/ai`、`/system/settings`、`/system/industry-packs`、`/system/forms`、`/system/customer-fields`、`/system/compliance` | | 管理中心 | `/system/users`、`/system/departments`、`/system/roles`、`/system/customers`、`/system/industries`、`/system/dicts`、`/system/audit-logs`、`/system/ai`、`/system/settings`、`/system/industry-packs`、`/system/forms`、`/system/customer-fields`、`/system/compliance` |
## 7 修订记录 ## 7 修订记录
| 版本 | 日期 | 变更 | | 版本 | 日期 | 变更 |
| --- | --- | --- | | --- | --- | --- |
| V1.0 | 2026-11-03 | 初版:模块表、路径语法、目录映射、路由注册、旧链重定向与检查清单;管理中心由「平台」改名,A1 用户页迁移至 `/system/users` 与 `pages/system/users/` | | V1.0 | 2026-11-03 | 初版:模块表、路径语法、目录映射、路由注册、旧链重定向与检查清单;管理中心由「平台」改名,A1 用户页迁移至 `/system/users` 与 `pages/system/users/` |
| V1.1 | 2026-11-03 | 管理中心补充部门管理 `/system/departments`(`pages/system/departments/`),作为 A1 组织架构补强 |
@@ -19,8 +19,10 @@
| A1-BE-03 | Jwt → Permission → SpecialPermission → Scope 守卫链、`@Audit` 与请求上下文 | ✅(Scope 骨架) | | A1-BE-03 | Jwt → Permission → SpecialPermission → Scope 守卫链、`@Audit` 与请求上下文 | ✅(Scope 骨架) |
| A1-BE-04 | `/me` 用户 / 角色 / 权限 / 数据范围 / 可见客户;权限即时生效 | ✅(dataScope 仅 all / 本部门及下级,六类留 A2) | | A1-BE-04 | `/me` 用户 / 角色 / 权限 / 数据范围 / 可见客户;权限即时生效 | ✅(dataScope 仅 all / 本部门及下级,六类留 A2) |
| A1-FE-01 | 登录页、token 存储刷新、登出、过期跳转、路由回跳 | ✅ | | A1-FE-01 | 登录页、token 存储刷新、登出、过期跳转、路由回跳 | ✅ |
| A1-FE-02 | 用户列表(游标分页 / 筛选 / 排序)、新建 / 编辑、停用确认、部门树、角色多选、客户转交入口 | ✅ | | A1-FE-02 | 用户列表(游标分页 / 筛选 / 排序)、新建 / 编辑、停用确认、部门筛选、角色多选、客户转交入口 | ✅ |
| A1-FE-03 | `/me` 缓存、`usePermission` / `<Can>`、路由与按钮级控制、403 页 | ✅ | | A1-FE-03 | `/me` 缓存、`usePermission` / `<Can>`、路由与按钮级控制、403 页 | ✅ |
| A1-BE-05 | 部门管理补强:树(含停用)、新建 / 改名 / 负责人 / 排序、停用 / 启用、停用拦截 | ✅(2026-11-03 补强) |
| A1-FE-04 | 部门管理页 `/system/departments`:两级树 + 详情/编辑 + 停用启用确认 | ✅(2026-11-03 补强) |
| A1-QA-01 | 登录 / 刷新 / 登出 / 停用 / 角色变更 / 越权 6 类用例 | ✅(接口 int + 浏览器 e2e) | | A1-QA-01 | 登录 / 刷新 / 登出 / 停用 / 角色变更 / 越权 6 类用例 | ✅(接口 int + 浏览器 e2e) |
口径确认(用户拍板):初始密码由管理员设定、首次登录强制修改;手机号列表掩码、详情全号仅 `user:manage` 可见;路由按开发计划附录 C 使用 `/system/users`(2026-11-03 路由规范调整后)。 口径确认(用户拍板):初始密码由管理员设定、首次登录强制修改;手机号列表掩码、详情全号仅 `user:manage` 可见;路由按开发计划附录 C 使用 `/system/users`(2026-11-03 路由规范调整后)。
@@ -31,7 +33,7 @@
### 2.1 契约(`packages/shared`) ### 2.1 契约(`packages/shared`)
- 新增 `src/schemas/identity.ts`(+ spec):`UserSummary`(`phoneMasked`,永不含全号)、`UserDetail`(`phone?` 仅授权时返回)、`UserListQuery`(`cursor/limit/keyword/department_id/role_id/status/sort/order`)、`CreateUserInput`、`UpdateUserInput`、`DisableUserInput`、`TransferCustomersInput`、`LoginInput`、`RefreshInput`、`LoginResponse = tokenPair + me`、`MeResponse`、`DepartmentNode`、`RoleSummary`。 - 新增 `src/schemas/identity.ts`(+ spec):`UserSummary`(`phoneMasked`,永不含全号)、`UserDetail`(`phone?` 仅授权时返回)、`UserListQuery`(`cursor/limit/keyword/department_id/role_id/status/sort/order`)、`CreateUserInput`、`UpdateUserInput`、`DisableUserInput`、`TransferCustomersInput`、`LoginInput`、`RefreshInput`、`LoginResponse = tokenPair + me`、`MeResponse`、`DepartmentNode`(`status / sortOrder / memberCount / directMemberCount`)、`CreateDepartmentInput`、`UpdateDepartmentInput`、`DepartmentTreeQuery`、`RoleSummary`。
- `permissions.ts` 新增 `USER_VIEW = 'user:view'`;保留 `USER_MANAGE`。 - `permissions.ts` 新增 `USER_VIEW = 'user:view'`;保留 `USER_MANAGE`。
- 写 body camelCase、query snake_case;写接口 `.strict()`;错误码沿用 A0 统一表。 - 写 body camelCase、query snake_case;写接口 `.strict()`;错误码沿用 A0 统一表。
@@ -41,7 +43,8 @@
- 迁移:`prisma/migrations/20261102143519_a1_identity`(expand):`departments`、`users`、`roles`、`user_roles`、`role_permissions`、`refresh_tokens`。 - 迁移:`prisma/migrations/20261102143519_a1_identity`(expand):`departments`、`users`、`roles`、`user_roles`、`role_permissions`、`refresh_tokens`。
- 种子(幂等):8 角色 + 角色权限映射、3 个一级部门 + 6 个小组、11 个用户(`admin` 超管 + 10 位示例成员),统一初始密码 `lingxi2026`、`must_change_password=true`。 - 种子(幂等):8 角色 + 角色权限映射、3 个一级部门 + 6 个小组、11 个用户(`admin` 超管 + 10 位示例成员),统一初始密码 `lingxi2026`、`must_change_password=true`。
- identity 模块(`apps/api/src/modules/identity`): - identity 模块(`apps/api/src/modules/identity`):
- `POST /auth/login|refresh|logout|change-password`、`GET /me`、`GET/POST /users`、`GET/PATCH /users/{id}`、`POST /users/{id}/disable|enable|transfer-customers`、`GET /departments/tree`、`POST/PATCH /departments`、`GET /roles`。 - `POST /auth/login|refresh|logout|change-password`、`GET /me`、`GET/POST /users`、`GET/PATCH /users/{id}`、`POST /users/{id}/disable|enable|transfer-customers`、`GET /departments/tree?include_disabled=`、`POST/PATCH /departments`、`POST /departments/{id}/disable|enable`、`GET /roles`。
- 部门:两级组织树;新建 / 改名 / 负责人 / 排序;只停用不删除;有在职成员、启用中的下级小组或下级在职成员时禁止停用(42201 带数量);启用时上级必须启用;负责人必须是启用用户。
- refresh token 旋转:jti + token_hash + family,旧 token 原子吊销;重放整族吊销。 - refresh token 旋转:jti + token_hash + family,旧 token 原子吊销;重放整族吊销。
- 登录失败 Redis 计数,达阈值锁定 15 分钟(42901)。 - 登录失败 Redis 计数,达阈值锁定 15 分钟(42901)。
- 权限每请求从 DB 解析,角色变更下一次请求生效。 - 权限每请求从 DB 解析,角色变更下一次请求生效。
@@ -57,6 +60,7 @@
- 登录:方案 B 左右分屏(左 560 品牌 + 能力 + CSS 规格缩略,右表单卡);真实 `POST /auth/login`;错误 / loading / 锁定态;首次登录强改在登录页内完成,成功后清会话要求用新密码重登。 - 登录:方案 B 左右分屏(左 560 品牌 + 能力 + CSS 规格缩略,右表单卡);真实 `POST /auth/login`;错误 / loading / 锁定态;首次登录强改在登录页内完成,成功后清会话要求用新密码重登。
- 用户列表 / 详情:左 400 列表 + 右 760 详情(基本资料 / 角色权限 / 负责客户 / 登录与安全 / 操作记录);搜索、部门、角色、状态、排序、游标分页;停用 / 启用 / 客户转交 / 调整角色 / 超管授予均「对象卡 → 后果 → 勾选」二次确认;编辑资料弹窗;空 / 加载 / 错误三态;1024 / 1280 适配。 - 用户列表 / 详情:左 400 列表 + 右 760 详情(基本资料 / 角色权限 / 负责客户 / 登录与安全 / 操作记录);搜索、部门、角色、状态、排序、游标分页;停用 / 启用 / 客户转交 / 调整角色 / 超管授予均「对象卡 → 后果 → 勾选」二次确认;编辑资料弹窗;空 / 加载 / 错误三态;1024 / 1280 适配。
- 新建用户:单栏三段(账号信息 / 组织与角色 / 初始设置),Semi Form + 共享 Zod 同源校验,部门两级联动、角色多选、超管二次确认。 - 新建用户:单栏三段(账号信息 / 组织与角色 / 初始设置),Semi Form + 共享 Zod 同源校验,部门两级联动、角色多选、超管二次确认。
- 部门管理(补强):`/system/departments`,左两级组织树(人数 / 负责人 / 状态、显示已停用、`?id=` 选中)+ 右详情/内联编辑(名称、上级只读、负责人、排序、直属/含下级人数、下级小组);新建部门/小组、停用/启用二次确认;无 `user:manage` 只读;树/表单/接口封装均有单测。
- 样式:`--lx-*` token + Semi 组件 + `styles/users.css`;未引入其他组件库/图标库。 - 样式:`--lx-*` token + Semi 组件 + `styles/users.css`;未引入其他组件库/图标库。
### 2.4 测试(`e2e`) ### 2.4 测试(`e2e`)
@@ -64,6 +68,13 @@
- `e2e/a1-identity.spec.ts`:6 条浏览器端到端用例(`A1_E2E=1` 门控,CI 无依赖时 skip),真实 API + 真实 PG/Redis;测试数据自建自清。 - `e2e/a1-identity.spec.ts`:6 条浏览器端到端用例(`A1_E2E=1` 门控,CI 无依赖时 skip),真实 API + 真实 PG/Redis;测试数据自建自清。
- `e2e/README.md`:运行前置、环境变量、CORS 说明。 - `e2e/README.md`:运行前置、环境变量、CORS 说明。
### 2.5 A1 补强:部门管理(2026-11-03)
- **口径**:独立页面 `/system/departments`;只停用不删除;有在职成员 / 启用中的下级小组 / 下级在职成员时禁止停用;负责人/排序可改;上级关系创建后不可移动;权限复用 `user:view` / `user:manage`,不新增权限点。
- **后端**:`GET /departments/tree?include_disabled`、`POST/PATCH /departments`、`POST /departments/{id}/disable|enable`;无 DELETE;`@Audit` 覆盖写操作;成员统计只算在职。
- **前端**:管理中心新增「部门管理」;两级树 + 详情/编辑 + 新建/停用启用确认;停用前本地预判 + 服务端文案兜底。
- **测试**:shared 契约(节点字段/新建/更新 strict);api 单测 + int 覆盖两级/重名/负责人/三类停用拦截/启用联动/DELETE 404;web 单测覆盖树、约束、表单、接口封装;真实账号 + 真实 API 手工联调通过(新建部门/小组、改名、停用空小组、重名提示、截图 `70-departments.png`)。
--- ---
## 3 验证结果 ## 3 验证结果
@@ -73,10 +84,11 @@
| 格式 | `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 95 / api 60 / web 55,共 210 | | 单测 | `pnpm test` | ✅ shared 121 / api 90 / web 120,共 331 |
| 集成 | `pnpm test:int` | ✅ 2 files / 7 tests(真实 PG + Redis) | | 集成 | `pnpm test:int` | ✅ 4 files / 33 tests(真实 PG + Redis,含部门 6 例) |
| OpenAPI | `pnpm openapi:export` | ✅ 新增 14 条 identity 路径,`apps/web/src/api/generated/openapi.d.ts` 已更新 | | OpenAPI | `pnpm openapi:export` | ✅ identity 路径(含部门 5 条)导出,`apps/web/src/api/generated/openapi.d.ts` 已更新 |
| 浏览器 e2e | `A1_E2E=1 pnpm exec playwright test e2e/a1-identity.spec.ts` | ✅ 6/6(登录+强改、刷新旋转、登出、停用、角色变更、越权 403) | | 浏览器 e2e | `A1_E2E=1 pnpm exec playwright test e2e/a1-identity.spec.ts` | ✅ 6/6(登录+强改、刷新旋转、登出、停用、角色变更、越权 403) |
| 部门管理联调 | 真实 API + 浏览器(jiang.lan) | ✅ 新建一级/小组、改名、停用空小组、重名 40903、树/详情/停用确认均正常 |
联调截图:`design/a1-ui-v1/shots/impl/`(登录、强改、用户列表、详情、角色 Tab、停用弹窗、命中真实接口);e2e 截图:`design/a1-ui-v1/shots/impl/e2e/`。 联调截图:`design/a1-ui-v1/shots/impl/`(登录、强改、用户列表、详情、角色 Tab、停用弹窗、命中真实接口);e2e 截图:`design/a1-ui-v1/shots/impl/e2e/`。
@@ -86,13 +98,15 @@
```bash ```bash
pnpm check:dev # 确认 PG 5432 / Redis 6379 pnpm check:dev # 确认 PG 5432 / Redis 6379
pnpm db:reset # 迁移 + 种子(admin / lingxi2026,首次登录须改密) pnpm db:init # 首次/迁移后:migrate deploy + 幂等种子(不重置数据、不改已有密码)
pnpm dev # API :3000 + Web :5173 pnpm dev # API :3000 + Web :5173(日常启动不需要任何 DB 命令)
# 浏览器打开 http://127.0.0.1:5173/login # 浏览器打开 http://127.0.0.1:5173/login
``` ```
种子账号:`admin`(超级管理员);示例成员 `chen.mo` / `lin.qing` / `zhou.an` / `zhao.yinuo` / `gao.cen` / `su.he` / `shen.zhiyuan` / `jiang.lan`;停用示例 `tang.jia` / `he.an`。初始密码统一 `lingxi2026`,首次登录强制修改。 种子账号:`admin`(超级管理员);示例成员 `chen.mo` / `lin.qing` / `zhou.an` / `zhao.yinuo` / `gao.cen` / `su.he` / `shen.zhiyuan` / `jiang.lan`;停用示例 `tang.jia` / `he.an`。初始密码统一 `lingxi2026`,首次登录强制修改。
> 只有需要清空本地库时才执行 `ALLOW_DB_RESET=1 pnpm db:reset`(默认被拦截;会重建并恢复种子密码)。`./dev.sh` / `pnpm dev` / `pnpm db:init` 不会重置已有账号密码;e2e 会改写 actor 密码,请使用专用账号/测试库。
--- ---
## 5 已知差异与遗留 ## 5 已知差异与遗留
@@ -109,6 +123,8 @@ pnpm dev # API :3000 + Web :5173
| 8 | CORS / e2e | API `WEB_ORIGIN` 仅允许 `http://127.0.0.1:5173`;e2e 跑在 dev 5173。若改用 preview 4173,需把该 origin 加进 `WEB_ORIGIN`。 | | 8 | CORS / e2e | API `WEB_ORIGIN` 仅允许 `http://127.0.0.1:5173`;e2e 跑在 dev 5173。若改用 preview 4173,需把该 origin 加进 `WEB_ORIGIN`。 |
| 9 | 本地账号状态 | 联调过程中曾修改本地 admin/jiang.lan 口令并触发锁定;交付前已 `pnpm db:reset` 恢复种子(`lingxi2026`)。 | | 9 | 本地账号状态 | 联调过程中曾修改本地 admin/jiang.lan 口令并触发锁定;交付前已 `pnpm db:reset` 恢复种子(`lingxi2026`)。 |
| 10 | 客户转交必填 | PRD 要求离职停用前必须转交名下客户;当前因 A3 未落地,停用弹窗的转交为可选项,A3 之后应收紧为有客户时必填。 | | 10 | 客户转交必填 | PRD 要求离职停用前必须转交名下客户;当前因 A3 未落地,停用弹窗的转交为可选项,A3 之后应收紧为有客户时必填。 |
| 11 | 部门管理 e2e | 部门管理暂无独立浏览器 e2e,由 api int(6 例)+ web 单测 + 真实账号手工联调覆盖;后续补 e2e 时建议覆盖「有成员禁止停用」与「停用后不可再选」。 |
| 12 | 部门删除 | 按口径不提供删除,仅停用;如后续确需删除空部门,需先补 PRD/接口口径。 |
--- ---
@@ -56,7 +56,7 @@ H1 权限门口径(用户确认):权限点保留现有 `read/write` 编码
- 路由:`/system/roles`(`role:view`,左 400 角色列表 + 右详情)、`/system/roles/:id`(编辑器,Tab:基本信息/权限点/数据范围/特殊权限 + 底部唯一保存)、`/system/roles/new`(`role:manage`,左模板 + 右表单);导航 `/system/roles` 权限由 `ROLE_MANAGE` 调整为 `ROLE_VIEW`;面包屑补角色三页。 - 路由:`/system/roles`(`role:view`,左 400 角色列表 + 右详情)、`/system/roles/:id`(编辑器,Tab:基本信息/权限点/数据范围/特殊权限 + 底部唯一保存)、`/system/roles/new`(`role:manage`,左模板 + 右表单);导航 `/system/roles` 权限由 `ROLE_MANAGE` 调整为 `ROLE_VIEW`;面包屑补角色三页。
- API/Query:`api/roles.ts`、`hooks/queries/use-roles.ts`、`query-keys`、`endpoints`;全部用 shared Zod schema 解析。 - API/Query:`api/roles.ts`、`hooks/queries/use-roles.ts`、`query-keys`、`endpoints`;全部用 shared Zod schema 解析。
- 页面能力:权限树 10 模块/30 点三态 + 搜索 + 展开;特殊权限 6 项二次确认;数据范围六类单选 + 客户组空态;模板预填(特殊不带入);复制/停用/删除/系统角色改名;保存前影响确认;SoD 实时冲突与 40304 回填;未保存离开拦截;权限点目录抽屉;只读态;加载/空/错误态;1024 适配。 - 页面能力:权限树 10 模块/30 点三态 + 搜索 + 展开;特殊权限 6 项二次确认;数据范围六类单选 + 客户组空态;模板预填(特殊不带入);复制/停用/删除/系统角色改名;保存前影响确认;SoD 实时冲突与 40304 回填;未保存离开拦截;只读态;加载/空/错误态;1024 适配。
- 样式:`styles/roles.css`、`styles/role-editor.css`,复用 `--lx-*` token 与现有 `.lx-*` 组件类;未新增依赖、未内联 SVG。 - 样式:`styles/roles.css`、`styles/role-editor.css`,复用 `--lx-*` token 与现有 `.lx-*` 组件类;未新增依赖、未内联 SVG。
### 2.5 UI 原型与决策 ### 2.5 UI 原型与决策
@@ -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 变更审计。
--- ---
@@ -87,12 +89,14 @@ A2-QA-01 覆盖:6 个角色端点权限三态(200/201/204 vs 403 `40301`)
```bash ```bash
pnpm check:dev # 确认 PG 5432 / Redis 6379 pnpm check:dev # 确认 PG 5432 / Redis 6379
pnpm db:reset # 迁移 + 种子(admin / lingxi2026,首次登录须改密) pnpm db:init # 首次/迁移后:migrate deploy + 幂等种子(不重置数据、不改已有密码)
pnpm dev # API :3000 + Web :5173 pnpm dev # API :3000 + Web :5173(日常启动不需要任何 DB 命令)
# 浏览器打开 http://127.0.0.1:5173/login → 管理中心 / 角色权限 # 浏览器打开 http://127.0.0.1:5173/login → 管理中心 / 角色管理
``` ```
种子账号:`admin` 超管、`jiang.lan` 平台管理员等 11 个账号;初始密码 `lingxi2026`,首次登录强制修改。 种子账号:`admin` 超管、`jiang.lan` 平台管理员等 11 个账号;初始密码 `lingxi2026`,首次登录强制修改(仅 `must_change_password=true` 时;改完即持久为 false)。
> 只有需要清空本地库时才执行 `pnpm db:reset`:它会 `migrate reset --force` 并重新 seed,admin 回到 `lingxi2026` + 首登改密标记。`./dev.sh` / `pnpm dev` / `pnpm db:init` 都不会重置账号密码。生产部署只执行 `prisma migrate deploy`,不执行 seed / reset。
--- ---
@@ -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
```
@@ -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 |
| 需求基线 | 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 预研边界(不排期)** |
@@ -25,6 +25,7 @@
| V0.1(进度回写) | 2026-11-02 | A0 工程骨架与契约基线完成:批次总览增加状态列、A0 WBS 标注完成、回写执行证据与偏差;不改变原计划范围与验收口径 | | V0.1(进度回写) | 2026-11-02 | A0 工程骨架与契约基线完成:批次总览增加状态列、A0 WBS 标注完成、回写执行证据与偏差;不改变原计划范围与验收口径 |
| 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,补页面与接口清单;只停用不删除、有成员/启用子部门禁止停用;不改变其它批次范围 |
### 0.2 引用约定 ### 0.2 引用约定
@@ -380,7 +381,7 @@ 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 → 完成 / 失败;幂等;取消 / 重试 |
@@ -394,9 +395,9 @@ flowchart LR
> **A0 进度(2026-11-02)**:✅ 已完成。10/10 项 WBS 通过;本批门禁 `format:check / lint / typecheck / test / build / test:int / openapi:export / openapi:diff / e2e` 与本地 CI `pnpm ci:local` 全绿;`/health`、`/api/docs`、web 布局与登录壳、worker 启动均实测通过。实现记录与偏差见 [A0 实施报告](./灵犀_A0工程骨架与契约基线_实施报告_V0.1.md)。环境适配:本地 PG/Redis 复用已有 Docker、对象存储使用公网 RustFS、种子仅 `system_configs`(`Q-A0-01`)、Gitea 暂无 runner 时以 `pnpm ci:local` 为准。 > **A0 进度(2026-11-02)**:✅ 已完成。10/10 项 WBS 通过;本批门禁 `format:check / lint / typecheck / test / build / test:int / openapi:export / openapi:diff / e2e` 与本地 CI `pnpm ci:local` 全绿;`/health`、`/api/docs`、web 布局与登录壳、worker 启动均实测通过。实现记录与偏差见 [A0 实施报告](./灵犀_A0工程骨架与契约基线_实施报告_V0.1.md)。环境适配:本地 PG/Redis 复用已有 Docker、对象存储使用公网 RustFS、种子仅 `system_configs`(`Q-A0-01`)、Gitea 暂无 runner 时以 `pnpm ci:local` 为准。
> >
> **A1 进度(2026-11-03)**:✅ 已完成。8/8 项 WBS 通过;`format:check / lint / typecheck / test` 全绿(shared 95 / api 60 / web 55),`test:int` 7 项(真实 PG / Redis),A1-QA-01 浏览器 e2e 6/6;登录 → 首次登录强改 → 用户列表 / 新建 / 详情 / 停用启用 / 角色调整 / 客户转交占位全链路实测。实现记录、口径与遗留见 [A1 实施报告](./灵犀_A1身份认证与用户管理_实施报告_V0.1.md)。已知遗留:六类数据范围(A2)、客户转交真实接口(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 批次依赖图
@@ -541,9 +542,11 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配
| A1-BE-02 | 认证与会话 | BE | 账号密码登录(Argon2)、JWT access + refresh 旋转、`jti` 吊销、登出、失败次数限制、密码策略;SSO 适配器接口(不实现) | A0-BE-02 | 登录 → 刷新 → 登出全链路;旧 refresh 立即失效;错误码 40101 / 40102 正确 | 1.0 | ✅ | | A1-BE-02 | 认证与会话 | BE | 账号密码登录(Argon2)、JWT access + refresh 旋转、`jti` 吊销、登出、失败次数限制、密码策略;SSO 适配器接口(不实现) | A0-BE-02 | 登录 → 刷新 → 登出全链路;旧 refresh 立即失效;错误码 40101 / 40102 正确 | 1.0 | ✅ |
| A1-BE-03 | 守卫链与审计拦截 | BE | `JwtGuard` → `PermissionGuard` → `SpecialPermissionGuard` → `ScopeGuard` 骨架 → 业务校验;`@Audit()` 装饰器与请求上下文(用户、客户、trace_id) | A1-BE-02、A0-SH-01 | 无 token 401;无权限点 40301;守卫顺序可单测 | 1.0 | ✅ | | A1-BE-03 | 守卫链与审计拦截 | BE | `JwtGuard` → `PermissionGuard` → `SpecialPermissionGuard` → `ScopeGuard` 骨架 → 业务校验;`@Audit()` 装饰器与请求上下文(用户、客户、trace_id) | A1-BE-02、A0-SH-01 | 无 token 401;无权限点 40301;守卫顺序可单测 | 1.0 | ✅ |
| A1-BE-04 | `/me` 与权限上下文 | BE | 当前用户、角色、权限点集合、数据范围、可见客户集合;权限变更即时生效(不缓存旧权限) | A1-BE-03 | `/me` 与登录返回一致;角色变更后下一次请求生效 | 0.5 | ✅ | | A1-BE-04 | `/me` 与权限上下文 | BE | 当前用户、角色、权限点集合、数据范围、可见客户集合;权限变更即时生效(不缓存旧权限) | A1-BE-03 | `/me` 与登录返回一致;角色变更后下一次请求生效 | 0.5 | ✅ |
| A1-BE-05 | 部门管理补强 | BE | `GET /departments/tree?include_disabled`、`POST/PATCH /departments`、`POST /departments/{id}/disable|enable`;负责人/排序;只停用不删除;有在职成员或启用子部门禁止停用 | A1-BE-01 | 两级/超两级/重名/负责人校验;停用三类拦截;无 DELETE | 0.5 | ✅ |
| A1-FE-01 | 登录与会话 | FE | 登录页、token 存储与刷新、登出、会话过期跳转、路由回跳 | A0-FE-03 | 登录 → 刷新页面仍在线;token 过期自动刷新一次;刷新失败回登录 | 0.5 | ✅ | | A1-FE-01 | 登录与会话 | FE | 登录页、token 存储与刷新、登出、会话过期跳转、路由回跳 | A0-FE-03 | 登录 → 刷新页面仍在线;token 过期自动刷新一次;刷新失败回登录 | 0.5 | ✅ |
| A1-FE-02 | 用户管理页 | FE | 用户列表(游标分页 / 筛选)、新建 / 编辑、停用确认、部门树、角色多选、客户转交入口 | A0-FE-02 | 列表可分页筛选;停用需二次确认;页面按 `user:manage` 隐藏写操作 | 1.0 | ✅ | | A1-FE-02 | 用户管理页 | FE | 用户列表(游标分页 / 筛选)、新建 / 编辑、停用确认、部门筛选、角色多选、客户转交入口 | A0-FE-02 | 列表可分页筛选;停用需二次确认;页面按 `user:manage` 隐藏写操作 | 1.0 | ✅ |
| A1-FE-03 | 权限上下文与路由守卫 | FE | `/me` 全局缓存、权限点 Hook(`usePermission`)、路由级与按钮级控制、403 页 | A1-FE-01 | 无权限路由被拦截;按钮级无权限不渲染(非 `removeChild` 式隐藏) | 0.5 | ✅ | | A1-FE-03 | 权限上下文与路由守卫 | FE | `/me` 全局缓存、权限点 Hook(`usePermission`)、路由级与按钮级控制、403 页 | A1-FE-01 | 无权限路由被拦截;按钮级无权限不渲染(非 `removeChild` 式隐藏) | 0.5 | ✅ |
| A1-FE-04 | 部门管理页 | FE | `/system/departments` 两级组织树 + 详情/内联编辑 + 新建部门/小组 + 停用/启用二次确认;显示已停用、`?id=` 选中可恢复 | A1-FE-01、A1-FE-03 | 无 `user:manage` 只读;停用被拒有明确提示;刷新恢复选中 | 0.5 | ✅ |
| A1-QA-01 | 账号生命周期用例 | QA | 登录 / 刷新 / 登出 / 停用 / 角色变更 / 越权 6 类用例 | A1-BE-04 | 用例全部通过并进 CI | 0.5 | ✅ | | A1-QA-01 | 账号生命周期用例 | QA | 登录 / 刷新 / 登出 / 停用 / 角色变更 / 越权 6 类用例 | A1-BE-04 | 用例全部通过并进 CI | 0.5 | ✅ |
**本批门禁**:登录闭环;用户生命周期可演示;权限有 / 无用例通过(范围外 ScopeContext 随 A2 补齐);审计已记录登录与用户变更(当前为结构化日志,落库随 A5)。 **本批门禁**:登录闭环;用户生命周期可演示;权限有 / 无用例通过(范围外 ScopeContext 随 A2 补齐);审计已记录登录与用户变更(当前为结构化日志,落库随 A5)。
@@ -563,29 +566,33 @@ 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 数据字典与系统配置
**目标**:字典与配置成为唯一来源,杜绝前后端硬编码。**对应**: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。
@@ -917,8 +924,8 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配
| 项 | 内容 | | 项 | 内容 |
| --- | --- | | --- | --- |
| 页面 | `/login`、`/system/users`、`/system/users/new`、`/system/users/:id`、部门树(内嵌用户页) | | 页面 | `/login`、`/system/users`、`/system/users/new`、`/system/users/:id`、`/system/departments`(部门管理) |
| 接口 | `POST /auth/login`、`/auth/refresh`、`/auth/logout`、`GET /me`、`GET /users`、`POST /users`、`PATCH /users/{id}`(含角色分配)、`POST /users/{id}/transfer-customers` | | 接口 | `POST /auth/login`、`/auth/refresh`、`/auth/logout`、`GET /me`、`GET /users`、`POST /users`、`PATCH /users/{id}`(含角色分配)、`POST /users/{id}/transfer-customers`;`GET /departments/tree`、`POST/PATCH /departments`、`POST /departments/{id}/disable`、`/enable`(不提供删除) |
| 关键规则 | 部门最多两级;手机号唯一;一人可多角色;离职**停用不删**,名下客户必须转交;超管建议 ≤ 2 人、授予二次确认并审计;停用即时生效 | | 关键规则 | 部门最多两级;手机号唯一;一人可多角色;离职**停用不删**,名下客户必须转交;超管建议 ≤ 2 人、授予二次确认并审计;停用即时生效 |
| 页面状态 | 空态(无用户 / 无搜索结果)、加载骨架、错误重试、无权限 403;停用用户列表默认隐藏、可筛选查看 | | 页面状态 | 空态(无用户 / 无搜索结果)、加载骨架、错误重试、无权限 403;停用用户列表默认隐藏、可筛选查看 |
| 验收 | ① 新建用户 → 分配角色 → 登录成功;② 停用后无法登录且在线会话失效;③ 删除按钮不存在,只有停用;④ 部门超两级被拒;⑤ 用户变更进审计 | | 验收 | ① 新建用户 → 分配角色 → 登录成功;② 停用后无法登录且在线会话失效;③ 删除按钮不存在,只有停用;④ 部门超两级被拒;⑤ 用户变更进审计 |
@@ -949,7 +956,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、未审批字段不进入生成 |
@@ -1229,7 +1236,7 @@ infra/:compose、Nginx、监控、备份脚本占位
- [ ] 超管首登强制改密;用户 / 角色 / 部门可用; - [ ] 超管首登强制改密;用户 / 角色 / 部门可用;
- [ ] 自定义角色 + 权限点 + 六类数据范围 + 特殊权限可用; - [ ] 自定义角色 + 权限点 + 六类数据范围 + 特殊权限可用;
- [ ] SoD 三对规则生效且有拒绝用例; - [ ] SoD 三对规则生效且有拒绝用例;
- [ ] 行业两级、客户 / 客户组 / 客户端字段审批可用; - [ ] 行业两级树(一级 → 二级,客户挂末级)、客户 / 客户组 / 客户端字段审批可用;
- [ ] 通用字典三层合并、编码不可改、历史快照可用; - [ ] 通用字典三层合并、编码不可改、历史快照可用;
- [ ] 审计七类关键动作留痕、只读、导出留痕; - [ ] 审计七类关键动作留痕、只读、导出留痕;
- [ ] AI 四层配置 + Mock 全链路 + 密钥不回显; - [ ] AI 四层配置 + Mock 全链路 + 密钥不回显;
@@ -1374,8 +1381,9 @@ infra/:compose、Nginx、监控、备份脚本占位
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `/login` | 登录 | 公开 | A1 | | `/login` | 登录 | 公开 | A1 |
| `/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/roles`、`/system/roles/:id` | 角色与权限 | `role:view` / `role:manage` | A2 | | `/system/departments` | 部门管理(两级组织树、停用/启用) | `user:view` / `user:manage` | A1 |
| `/system/industries` | 行业管理 | `industry_pack:view` / `customer:manage` | A3 | | `/system/roles`、`/system/roles/:id` | 角色管理 | `role:view` / `role:manage` | A2 |
| `/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 |
@@ -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` | 客户组 ↔ 客户 | 复合主键 |
@@ -900,7 +900,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 +1102,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` | 套用行业资产包(冷启动) |
+20 -2
View File
@@ -7,6 +7,7 @@
| `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` |
## A1 账号生命周期用例 ## A1 账号生命周期用例
@@ -14,11 +15,12 @@
### 前置 ### 前置
1. API:`pnpm --filter @lingxi/api dev`(`http://127.0.0.1:3000/api/v1`,真实 PG + Redis,已 `db:reset` + seed)。 1. API:`pnpm --filter @lingxi/api dev`(`http://127.0.0.1:3000/api/v1`,真实 PG + Redis,已 `pnpm db:init`;**不要执行 `db:reset`**,会清空本地账号密码)。
2. Web:`pnpm --filter @lingxi/web dev`(`http://127.0.0.1:5173`,需 `VITE_PUBLIC_DEMO_SESSION=false`,否则登录走演示会话)。 2. Web:`pnpm --filter @lingxi/web dev`(`http://127.0.0.1:5173`,需 `VITE_PUBLIC_DEMO_SESSION=false`,否则登录走演示会话)。
3. 管理员 actor:默认用种子超管 `jiang.lan`(首登会把初始密码 `lingxi2026` 改成 `QaE2eAdmin2026`,之后复用)。 3. 管理员 actor:默认用种子超管 `jiang.lan`(首登会把初始密码 `lingxi2026` 改成 `QaE2eAdmin2026`,之后复用)。
若本地已改密或想换账号,覆盖:`A1_E2E_ACTOR_ACCOUNT` / `A1_E2E_ACTOR_PASSWORD`。 若本地已改密或想换账号,覆盖:`A1_E2E_ACTOR_ACCOUNT` / `A1_E2E_ACTOR_PASSWORD`。
⚠️ 不要用 `admin`:本地 `admin` 密码可能已被人工修改或触发登录锁定。 ⚠️ 不要用 `admin`:本地 `admin` 密码可能已被人工修改或触发登录锁定。
⚠️ e2e 会把 actor 密码改成 `QaE2eAdmin2026`(之后复用),请用专用测试账号/测试库,不要指向日常在用的账号。
### 运行 ### 运行
@@ -49,7 +51,7 @@ A1_E2E=1 pnpm exec playwright test e2e/a1-identity.spec.ts --reporter=list
### 前置与运行 ### 前置与运行
前置与 A1 相同(API `http://127.0.0.1:3000/api/v1`、Web `http://127.0.0.1:5173` 非 demo 会话、已 `db:reset` + seed): 前置与 A1 相同(API `http://127.0.0.1:3000/api/v1`、Web `http://127.0.0.1:5173` 非 demo 会话、已 `pnpm db:init`):
```bash ```bash
A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts --reporter=list A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts --reporter=list
@@ -59,3 +61,19 @@ 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/`(实施报告引用)。
+2 -2
View File
@@ -408,7 +408,7 @@ async function submitLogin(page: Page): Promise<void> {
async function expectUsersPage(page: Page): Promise<void> { async function expectUsersPage(page: Page): Promise<void> {
await expect(page.getByTestId('app-shell')).toBeVisible(); await expect(page.getByTestId('app-shell')).toBeVisible();
await expect(page.getByRole('heading', { name: '用户与组织' })).toBeVisible(); await expect(page.getByRole('heading', { name: '用户管理' })).toBeVisible();
} }
async function readSession(page: Page): Promise<SessionSnapshot> { async function readSession(page: Page): Promise<SessionSnapshot> {
@@ -641,7 +641,7 @@ test('④ 停用后旧会话被拒(40301),再登录提示「账号已停
await page.goto('/system/users'); await page.goto('/system/users');
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/);
await expect(page.locator('.lx-login__notice')).toContainText('账号已被停用'); await expect(page.locator('.lx-login__notice')).toContainText('账号已被停用');
await expect(page.getByRole('heading', { name: '用户与组织' })).toHaveCount(0); await expect(page.getByRole('heading', { name: '用户管理' })).toHaveCount(0);
await expect await expect
.poll( .poll(
async () => async () =>
+2 -2
View File
@@ -533,7 +533,7 @@ test('角色管理:新建 → 编辑器 → SoD 拦截 → 特殊权限二次
// ① 管理员登录 → /system/roles // ① 管理员登录 → /system/roles
await loginAsAdmin(page); await loginAsAdmin(page);
await page.goto('/system/roles'); await page.goto('/system/roles');
await expect(page.getByRole('heading', { name: '角色权限' })).toBeVisible(); await expect(page.getByRole('heading', { name: '角色管理' })).toBeVisible();
await expect(page.getByTestId('app-shell')).toBeVisible(); await expect(page.getByTestId('app-shell')).toBeVisible();
await screenshot(page, '01-roles-list'); await screenshot(page, '01-roles-list');
@@ -699,7 +699,7 @@ test('角色管理:新建 → 编辑器 → SoD 拦截 → 特殊权限二次
// ⑥ 列表状态变化:停用筛选下可见该角色,且行内标记「停用」 // ⑥ 列表状态变化:停用筛选下可见该角色,且行内标记「停用」
await page.goto('/system/roles'); await page.goto('/system/roles');
await expect(page.getByRole('heading', { name: '角色权限' })).toBeVisible(); await expect(page.getByRole('heading', { name: '角色管理' })).toBeVisible();
const disabledSeg = page const disabledSeg = page
.getByRole('group', { name: '角色状态' }) .getByRole('group', { name: '角色状态' })
.getByRole('button', { name: /^停用/ }); .getByRole('button', { name: /^停用/ });
+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();
});
});
+4 -1
View File
@@ -2,7 +2,7 @@ import { expect, type Page, test } from '@playwright/test';
async function signIn(page: Page): Promise<void> { async function signIn(page: Page): Promise<void> {
await page.goto('/login'); await page.goto('/login');
await page.getByPlaceholder('请输入账号或邮箱').fill('admin'); await page.getByPlaceholder('请输入账号或手机号').fill('admin');
await page.getByPlaceholder('请输入密码').fill('demo1234'); await page.getByPlaceholder('请输入密码').fill('demo1234');
await page.getByTestId('login-submit').click(); await page.getByTestId('login-submit').click();
await expect(page.getByTestId('app-shell')).toBeVisible(); await expect(page.getByTestId('app-shell')).toBeVisible();
@@ -12,6 +12,9 @@ test('登录页打开并进入工作台', async ({ page }) => {
await page.goto('/login'); await page.goto('/login');
await expect(page.getByRole('heading', { name: '登录灵犀' })).toBeVisible(); await expect(page.getByRole('heading', { name: '登录灵犀' })).toBeVisible();
await signIn(page); await signIn(page);
// A1 起登录默认落地 /system/users,再从顶部模块导航进入工作台
await expect(page).toHaveURL(/\/system\/users/);
await page.getByRole('button', { name: '工作台', exact: true }).click();
await expect(page).toHaveURL('/'); await expect(page).toHaveURL('/');
await expect(page.getByRole('heading', { name: '工作台', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: '工作台', exact: true })).toBeVisible();
await expect(page.getByRole('navigation', { name: '模块导航' })).toBeVisible(); await expect(page.getByRole('navigation', { name: '模块导航' })).toBeVisible();
+544 -26
View File
@@ -203,31 +203,24 @@
] ]
} }
}, },
"/api/v1/users/{id}/transfer-customers": {
"post": {
"description": "A3 客户模块落地前返回 42201",
"operationId": "UsersController_transferCustomers",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "客户转交(占位)",
"tags": [
"identity-users"
]
}
},
"/api/v1/departments/tree": { "/api/v1/departments/tree": {
"get": { "get": {
"description": "memberCount 含下级成员、directMemberCount 仅本部门直属成员;默认只返回启用部门,管理页传 include_disabled=true",
"operationId": "DepartmentsController_tree", "operationId": "DepartmentsController_tree",
"parameters": [], "parameters": [
{
"name": "include_disabled",
"required": false,
"in": "query",
"schema": {
"enum": [
"true",
"false"
],
"type": "string"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "" "description": ""
@@ -238,7 +231,7 @@
"bearer": [] "bearer": []
} }
], ],
"summary": "部门树(两级,含成员数与负责人)", "summary": "部门树(两级)",
"tags": [ "tags": [
"identity-departments" "identity-departments"
] ]
@@ -246,7 +239,7 @@
}, },
"/api/v1/departments": { "/api/v1/departments": {
"post": { "post": {
"description": "最多两级,在小组下新建返回 42201", "description": "最多两级;父级必须启用;负责人必须是启用用户;同级重名返回 40903",
"operationId": "DepartmentsController_create", "operationId": "DepartmentsController_create",
"parameters": [], "parameters": [],
"responses": { "responses": {
@@ -267,7 +260,8 @@
}, },
"/api/v1/departments/{id}": { "/api/v1/departments/{id}": {
"patch": { "patch": {
"operationId": "DepartmentsController_rename", "description": "上级关系创建后不可移动;负责人必须是启用用户",
"operationId": "DepartmentsController_update",
"parameters": [], "parameters": [],
"responses": { "responses": {
"200": { "200": {
@@ -279,7 +273,49 @@
"bearer": [] "bearer": []
} }
], ],
"summary": "部门改名", "summary": "更新部门(名称 / 负责人 / 排序)",
"tags": [
"identity-departments"
]
}
},
"/api/v1/departments/{id}/disable": {
"post": {
"description": "本部门仍有在职成员、或一级部门存在启用中的下级小组 / 下级在职成员时返回 42201;已停用返回 40903",
"operationId": "DepartmentsController_disable",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "停用部门",
"tags": [
"identity-departments"
]
}
},
"/api/v1/departments/{id}/enable": {
"post": {
"description": "上级必须启用;已启用返回 40903",
"operationId": "DepartmentsController_enable",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "启用部门",
"tags": [ "tags": [
"identity-departments" "identity-departments"
] ]
@@ -404,6 +440,488 @@
] ]
} }
}, },
"/api/v1/industries": {
"get": {
"operationId": "IndustriesController_list",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "两级行业树(一级 → 二级)",
"tags": [
"client-industries"
]
},
"post": {
"operationId": "IndustriesController_create",
"parameters": [],
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "新建一级 / 二级行业",
"tags": [
"client-industries"
]
}
},
"/api/v1/industries/{id}": {
"get": {
"operationId": "IndustriesController_detail",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "行业详情与引用(子行业 / 客户 / 资产)",
"tags": [
"client-industries"
]
},
"patch": {
"operationId": "IndustriesController_update",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "更新行业名称 / 排序 / 备注(层级与上级不可变)",
"tags": [
"client-industries"
]
},
"delete": {
"operationId": "IndustriesController_remove",
"parameters": [],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "删除行业(仅无子行业 / 客户 / 资产时)",
"tags": [
"client-industries"
]
}
},
"/api/v1/industries/{id}/status": {
"patch": {
"operationId": "IndustriesController_setStatus",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "停用 / 启用行业(有启用子行业时禁止停用)",
"tags": [
"client-industries"
]
}
},
"/api/v1/customers": {
"get": {
"operationId": "CustomersController_list",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选)",
"tags": [
"client-customers"
]
},
"post": {
"operationId": "CustomersController_create",
"parameters": [],
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "新建客户(简称必填;行业必须为末级行业)",
"tags": [
"client-customers"
]
}
},
"/api/v1/customers/{id}": {
"get": {
"operationId": "CustomersController_detail",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "客户详情(跨客户访问统一 404)",
"tags": [
"client-customers"
]
},
"patch": {
"operationId": "CustomersController_update",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "更新客户档案(所属行业不可改;暂停 / 结束只读)",
"tags": [
"client-customers"
]
}
},
"/api/v1/customers/{id}/status": {
"patch": {
"operationId": "CustomersController_setStatus",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆)",
"tags": [
"client-customers"
]
}
},
"/api/v1/customers/{id}/transfer": {
"post": {
"operationId": "CustomersController_transfer",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "变更客户负责人(写审计)",
"tags": [
"client-customers"
]
}
},
"/api/v1/customers/{id}/groups": {
"put": {
"operationId": "CustomersController_setGroups",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "设置客户所属客户组(覆盖式)",
"tags": [
"client-customers"
]
}
},
"/api/v1/customers/{id}/apply-industry-pack": {
"post": {
"operationId": "CustomersController_applyIndustryPack",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "套用行业资产包(B0 批次提供;当前返回业务提示)",
"tags": [
"client-customers"
]
}
},
"/api/v1/users/{id}/transfer-customers": {
"post": {
"operationId": "CustomerTransferController_transfer",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "把用户名下客户转交给另一负责人",
"tags": [
"client-customers"
]
}
},
"/api/v1/customer-groups": {
"get": {
"operationId": "CustomerGroupsController_list",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "客户组列表(授权与数据范围最小单位)",
"tags": [
"client-customer-groups"
]
},
"post": {
"operationId": "CustomerGroupsController_create",
"parameters": [],
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "新建客户组",
"tags": [
"client-customer-groups"
]
}
},
"/api/v1/customer-groups/{id}": {
"get": {
"operationId": "CustomerGroupsController_detail",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "客户组详情与成员",
"tags": [
"client-customer-groups"
]
},
"patch": {
"operationId": "CustomerGroupsController_update",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "更新客户组名称 / 说明",
"tags": [
"client-customer-groups"
]
},
"delete": {
"operationId": "CustomerGroupsController_remove",
"parameters": [],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "删除客户组(被角色数据范围引用时拒绝)",
"tags": [
"client-customer-groups"
]
}
},
"/api/v1/customer-groups/{id}/members": {
"post": {
"operationId": "CustomerGroupsController_updateMembers",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "批量添加 / 移除成员(幂等)",
"tags": [
"client-customer-groups"
]
}
},
"/api/v1/customer-fields": {
"get": {
"operationId": "CustomerFieldsController_list",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "字段审批队列(待审批 / 已生效 / 已驳回)",
"tags": [
"client-customer-fields"
]
}
},
"/api/v1/customer-fields/{id}/approve": {
"post": {
"operationId": "CustomerFieldsController_approve",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "审批客户级字段(通过 / 驳回;驳回原因必填)",
"tags": [
"client-customer-fields"
]
}
},
"/api/v1/customers/{id}/fields": {
"get": {
"operationId": "CustomerFieldsOfCustomerController_list",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "客户级字段列表(基础字段在 customers.custom_fields)",
"tags": [
"client-customer-fields"
]
},
"post": {
"operationId": "CustomerFieldsOfCustomerController_apply",
"parameters": [],
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
}
],
"summary": "申请客户级字段(进入生成字段需审批;每客户 ≤ 5)",
"tags": [
"client-customer-fields"
]
}
},
"/health": { "/health": {
"get": { "get": {
"description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。", "description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。",
+2 -1
View File
@@ -25,7 +25,8 @@
"ci:local": "node scripts/ci-local.mjs", "ci:local": "node scripts/ci-local.mjs",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:reset": "prisma migrate reset --force", "db:init": "prisma migrate deploy && pnpm db:seed",
"db:reset": "node scripts/db-reset.mjs",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"openapi:export": "tsx --tsconfig apps/api/tsconfig.json apps/api/src/scripts/export-openapi.ts && openapi-typescript openapi/lingxi.openapi.json -o apps/web/src/api/generated/openapi.d.ts && prettier --write apps/web/src/api/generated/openapi.d.ts", "openapi:export": "tsx --tsconfig apps/api/tsconfig.json apps/api/src/scripts/export-openapi.ts && openapi-typescript openapi/lingxi.openapi.json -o apps/web/src/api/generated/openapi.d.ts && prettier --write apps/web/src/api/generated/openapi.d.ts",
"openapi:diff": "node scripts/openapi-diff.mjs", "openapi:diff": "node scripts/openapi-diff.mjs",
+1
View File
@@ -4,6 +4,7 @@ export * from './errors/codes.js';
export * from './naming.js'; export * from './naming.js';
export * from './permissions.js'; export * from './permissions.js';
export * from './schemas/api.js'; export * from './schemas/api.js';
export * from './schemas/client.js';
export * from './schemas/common.js'; export * from './schemas/common.js';
export * from './schemas/identity.js'; export * from './schemas/identity.js';
export * from './schemas/roles.js'; export * from './schemas/roles.js';
+192
View File
@@ -0,0 +1,192 @@
import { describe, expect, it } from 'vitest';
import {
applyCustomerFieldInputSchema,
approveCustomerFieldInputSchema,
createCustomerGroupInputSchema,
createCustomerInputSchema,
createIndustryInputSchema,
customerFieldCodeSchema,
customerFieldListQuerySchema,
customerGroupListQuerySchema,
customerListQuerySchema,
customerNameSchema,
customerShortNameSchema,
industryListQuerySchema,
setCustomerGroupsInputSchema,
setCustomerStatusInputSchema,
setIndustryStatusInputSchema,
transferCustomerInputSchema,
updateCustomerGroupInputSchema,
updateCustomerGroupMembersInputSchema,
updateCustomerInputSchema,
updateIndustryInputSchema,
} from './client.js';
const ULID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ULID2 = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
describe('行业契约', () => {
it('新建:一级 parentId 缺省、默认排序;二级必须带 parentId', () => {
expect(createIndustryInputSchema.parse({ name: '电商' })).toEqual({
name: '电商',
sortOrder: 10,
remark: '',
});
expect(createIndustryInputSchema.parse({ name: '服饰', parentId: ULID })).toMatchObject({
parentId: ULID,
});
expect(createIndustryInputSchema.safeParse({ name: '电' }).success).toBe(false);
expect(createIndustryInputSchema.safeParse({ name: '电商', unknown: 1 }).success).toBe(false);
});
it('更新 / 状态 / 列表查询:层级与上级不可改,状态枚举与默认分页', () => {
expect(updateIndustryInputSchema.safeParse({ parentId: ULID }).success).toBe(false);
expect(
updateIndustryInputSchema.parse({ name: '电商2', sortOrder: 20, remark: '说明' }),
).toEqual({
name: '电商2',
sortOrder: 20,
remark: '说明',
});
expect(setIndustryStatusInputSchema.safeParse({ status: 'disabled' }).success).toBe(true);
expect(setIndustryStatusInputSchema.safeParse({ status: 'deleted' }).success).toBe(false);
expect(industryListQuerySchema.parse({})).toEqual({ limit: 20, status: 'all' });
});
});
describe('客户契约', () => {
it('名称 / 简称:简称必填且限制字符', () => {
expect(customerNameSchema.safeParse(' ').success).toBe(false);
expect(customerShortNameSchema.safeParse('HJ').success).toBe(true);
expect(customerShortNameSchema.safeParse('花间集').success).toBe(true);
expect(customerShortNameSchema.safeParse('H').success).toBe(false);
expect(customerShortNameSchema.safeParse('HJ!').success).toBe(false);
});
it('新建:默认不写偏好与基础字段,未知字段拒绝', () => {
const parsed = createCustomerInputSchema.parse({
industryId: ULID,
name: '花间集',
shortName: 'HJ',
ownerUserId: ULID2,
});
expect(parsed.preferences).toBeUndefined();
expect(parsed.baseFields).toBeUndefined();
expect(
createCustomerInputSchema.safeParse({
industryId: ULID,
name: '花间集',
shortName: 'HJ',
ownerUserId: ULID2,
extra: true,
}).success,
).toBe(false);
});
it('新建:基础字段 ≤ 10,接受完整偏好', () => {
const baseFields = Array.from({ length: 11 }, (_, index) => ({
key: `k${index}`,
value: `v${index}`,
}));
expect(
createCustomerInputSchema.safeParse({
industryId: ULID,
name: '花间集',
shortName: 'HJ',
ownerUserId: ULID2,
baseFields,
}).success,
).toBe(false);
const parsed = createCustomerInputSchema.parse({
industryId: ULID,
name: '花间集',
shortName: 'HJ',
ownerUserId: ULID2,
preferences: { naming: 'n', directory: 'd', specs: ['1080×1920'], taboo: ['绝对化用语'] },
baseFields: [{ key: '门店', value: '沪-001' }],
});
expect(parsed.preferences?.specs).toHaveLength(1);
});
it('更新不接受所属行业;状态 / 转交 / 组 / 列表查询口径', () => {
expect(updateCustomerInputSchema.safeParse({ industryId: ULID }).success).toBe(false);
expect(updateCustomerInputSchema.parse({ shortName: 'HJ2' })).toEqual({ shortName: 'HJ2' });
expect(setCustomerStatusInputSchema.safeParse({ status: 'paused' }).success).toBe(true);
expect(setCustomerStatusInputSchema.safeParse({ status: 'deleted' }).success).toBe(false);
expect(transferCustomerInputSchema.parse({ ownerUserId: ULID })).toEqual({ ownerUserId: ULID });
expect(setCustomerGroupsInputSchema.parse({ groupIds: [ULID] })).toEqual({ groupIds: [ULID] });
expect(customerListQuerySchema.parse({})).toEqual({ limit: 20, status: 'all' });
expect(customerListQuerySchema.parse({ status: 'ended', industry_id: ULID })).toMatchObject({
status: 'ended',
industry_id: ULID,
});
});
});
describe('客户组契约', () => {
it('名称 / 说明 / 成员批量操作 / 列表查询', () => {
expect(createCustomerGroupInputSchema.parse({ name: '电商核心组' })).toEqual({
name: '电商核心组',
});
expect(createCustomerGroupInputSchema.safeParse({ name: '组' }).success).toBe(false);
expect(updateCustomerGroupInputSchema.parse({ description: '说明' })).toEqual({
description: '说明',
});
expect(updateCustomerGroupMembersInputSchema.parse({ addCustomerIds: [ULID] })).toEqual({
addCustomerIds: [ULID],
removeCustomerIds: [],
});
expect(customerGroupListQuerySchema.parse({})).toEqual({ limit: 20 });
});
});
describe('客户字段契约', () => {
it('编码格式:小写字母开头,字母数字下划线', () => {
expect(customerFieldCodeSchema.parse('campaign_code')).toBe('campaign_code');
expect(customerFieldCodeSchema.safeParse('Campaign').success).toBe(false);
expect(customerFieldCodeSchema.safeParse('1abc').success).toBe(false);
expect(customerFieldCodeSchema.safeParse('a').success).toBe(false);
});
it('申请:单选必须有选项,进入生成默认 false', () => {
const parsed = applyCustomerFieldInputSchema.parse({
name: '活动编码',
code: 'campaign_code',
type: 'text',
purpose: '检索',
});
expect(parsed.entersGeneration).toBe(false);
expect(parsed.options).toEqual([]);
expect(
applyCustomerFieldInputSchema.safeParse({
name: '系列',
code: 'series',
type: 'select',
purpose: '检索',
}).success,
).toBe(false);
expect(
applyCustomerFieldInputSchema.parse({
name: '系列',
code: 'series',
type: 'select',
options: ['秋冬'],
purpose: '检索',
entersGeneration: true,
}).entersGeneration,
).toBe(true);
});
it('审批:驳回必须带原因;列表状态枚举', () => {
expect(approveCustomerFieldInputSchema.safeParse({ decision: 'reject' }).success).toBe(false);
expect(approveCustomerFieldInputSchema.parse({ decision: 'reject', reason: '重复' })).toEqual({
decision: 'reject',
reason: '重复',
});
expect(approveCustomerFieldInputSchema.parse({ decision: 'approve' })).toEqual({
decision: 'approve',
});
expect(customerFieldListQuerySchema.parse({})).toEqual({ limit: 20, status: 'all' });
});
});
+437
View File
@@ -0,0 +1,437 @@
/**
* 客户与行业共享契约(A3:两级行业、客户档案与状态机、客户组、客户级字段)
* 依据:PRD R-20 / R-07、§5.4.2 / §5.4.9;技术架构 §5.3 / §5.4.1 / §6.3.2;ADR-002。
*
* 约定:
* - query 用 snake_case;写 body 用 camelCase 且 `.strict()`;
* - 行业最多两级;一级 parentId 为空,二级 parentId 指向一级;
* - 客户 `industryId` 必须指向末级行业;客户不可跨行业移动(update 不接收 industryId);
* - 基础字段 ≤ 10(JSONB 键值);进入生成字段 ≤ 5(审批通过后生效);
* - 客户状态 active 在服务 / paused 暂停 / ended 结束(结束不可逆)。
*/
import { z } from 'zod';
import { cursorPaginationQuerySchema } from './api.js';
import { isoDateTimeSchema, ulidSchema } from './common.js';
// ---------------------------------------------------------------------------
// 枚举
// ---------------------------------------------------------------------------
export const INDUSTRY_STATUSES = ['active', 'disabled'] as const;
export const industryStatusSchema = z.enum(INDUSTRY_STATUSES);
export type IndustryStatus = z.infer<typeof industryStatusSchema>;
export const industryLevelSchema = z.union([z.literal(1), z.literal(2)]);
export type IndustryLevel = z.infer<typeof industryLevelSchema>;
export const CUSTOMER_STATUSES = ['active', 'paused', 'ended'] as const;
export const customerStatusSchema = z.enum(CUSTOMER_STATUSES);
export type CustomerStatus = z.infer<typeof customerStatusSchema>;
export const CUSTOMER_FIELD_TYPES = ['text', 'number', 'date', 'select'] as const;
export const customerFieldTypeSchema = z.enum(CUSTOMER_FIELD_TYPES);
export type CustomerFieldType = z.infer<typeof customerFieldTypeSchema>;
export const CUSTOMER_FIELD_STATUSES = ['pending', 'active', 'rejected', 'disabled'] as const;
export const customerFieldStatusSchema = z.enum(CUSTOMER_FIELD_STATUSES);
export type CustomerFieldStatus = z.infer<typeof customerFieldStatusSchema>;
/** 上限:只存系统配置前的共享常量(A4 迁入 system_configs,R-26) */
export const CUSTOMER_BASE_FIELD_LIMIT = 10;
export const CUSTOMER_GEN_FIELD_LIMIT = 5;
// ---------------------------------------------------------------------------
// 行业
// ---------------------------------------------------------------------------
export const industryNameSchema = z
.string()
.trim()
.min(2, '行业名称至少 2 个字符')
.max(20, '行业名称最多 20 个字符');
export const industrySortSchema = z.coerce.number().int().min(0).max(9999);
export const industryRemarkSchema = z.string().trim().max(200, '备注最多 200 个字符');
export const industryNodeSchema = z.object({
id: ulidSchema,
parentId: ulidSchema.nullable(),
level: industryLevelSchema,
name: z.string().min(1),
sortOrder: z.number().int(),
status: industryStatusSchema,
remark: z.string(),
customerCount: z.number().int().nonnegative(),
assetCount: z.number().int().nonnegative(),
updatedAt: isoDateTimeSchema,
});
export type IndustryNode = z.infer<typeof industryNodeSchema>;
export const industryTreeItemSchema = industryNodeSchema.extend({
children: z.array(industryNodeSchema),
});
export type IndustryTreeItem = z.infer<typeof industryTreeItemSchema>;
/** 行业引用信息:删除 / 停用前的引用清单 */
export const industryReferencesSchema = z.object({
childCount: z.number().int().nonnegative(),
activeChildCount: z.number().int().nonnegative(),
customerCount: z.number().int().nonnegative(),
customers: z.array(z.object({ id: ulidSchema, name: z.string(), status: customerStatusSchema })),
assetCount: z.number().int().nonnegative(),
});
export type IndustryReferences = z.infer<typeof industryReferencesSchema>;
export const industryDetailSchema = industryNodeSchema.extend({
parentName: z.string().nullable(),
references: industryReferencesSchema,
});
export type IndustryDetail = z.infer<typeof industryDetailSchema>;
export const industryListQuerySchema = cursorPaginationQuerySchema.extend({
keyword: z.string().trim().max(64).optional(),
status: z.enum(['all', 'active', 'disabled']).default('all'),
parent_id: ulidSchema.optional(),
});
export type IndustryListQuery = z.infer<typeof industryListQuerySchema>;
export const createIndustryInputSchema = z
.object({
name: industryNameSchema,
parentId: ulidSchema.nullable().optional(),
sortOrder: industrySortSchema.default(10),
remark: industryRemarkSchema.default(''),
})
.strict();
export type CreateIndustryInput = z.infer<typeof createIndustryInputSchema>;
/** 行业层级与上级不可变;名称 / 排序 / 备注可改(状态走独立接口) */
export const updateIndustryInputSchema = z
.object({
name: industryNameSchema.optional(),
sortOrder: industrySortSchema.optional(),
remark: industryRemarkSchema.optional(),
})
.strict();
export type UpdateIndustryInput = z.infer<typeof updateIndustryInputSchema>;
export const setIndustryStatusInputSchema = z
.object({
status: industryStatusSchema,
})
.strict();
export type SetIndustryStatusInput = z.infer<typeof setIndustryStatusInputSchema>;
// ---------------------------------------------------------------------------
// 客户
// ---------------------------------------------------------------------------
export const customerNameSchema = z
.string()
.trim()
.min(1, '请输入客户名称')
.max(128, '客户名称最多 128 个字符');
/** 简称用于全部自动命名:中文 / 字母 / 数字 / 下划线 / 短横线,2–20 字符 */
export const customerShortNameSchema = z
.string()
.trim()
.min(2, '客户简称至少 2 个字符')
.max(20, '客户简称最多 20 个字符')
.regex(/^[\u4e00-\u9fa5A-Za-z0-9_-]+$/, '客户简称只能包含中文、字母、数字、下划线或短横线');
export const customerContactNameSchema = z.string().trim().max(32).optional();
export const customerContactPhoneSchema = z.string().trim().max(32).optional();
export const customerRemarkSchema = z.string().trim().max(500).optional();
export const customerPreferencesSchema = z
.object({
naming: z.string().trim().max(120).default(''),
directory: z.string().trim().max(200).default(''),
specs: z.array(z.string().trim().max(32)).max(10).default([]),
taboo: z.array(z.string().trim().max(32)).max(20).default([]),
})
.strict();
export type CustomerPreferences = z.infer<typeof customerPreferencesSchema>;
export const baseFieldSchema = z
.object({
key: z.string().trim().min(1, '字段键不能为空').max(32),
value: z.string().trim().max(200),
})
.strict();
export type BaseField = z.infer<typeof baseFieldSchema>;
export const customerGroupRefSchema = z.object({
id: ulidSchema,
name: z.string().min(1),
});
export type CustomerGroupRef = z.infer<typeof customerGroupRefSchema>;
export const customerFieldSchema = z.object({
id: ulidSchema,
customerId: ulidSchema,
name: z.string().min(1),
code: z.string().min(1),
type: customerFieldTypeSchema,
options: z.array(z.string()).default([]),
purpose: z.string(),
entersGeneration: z.boolean(),
status: customerFieldStatusSchema,
applicantName: z.string(),
approverName: z.string().nullable(),
appliedAt: isoDateTimeSchema,
approvedAt: isoDateTimeSchema.nullable(),
rejectReason: z.string().nullable(),
});
export type CustomerField = z.infer<typeof customerFieldSchema>;
/** 审批队列列表项:附带客户上下文,避免前端再逐条查客户 */
export const customerFieldListItemSchema = customerFieldSchema.extend({
customerName: z.string().min(1),
customerShortName: z.string().min(1),
industryPath: z.string().min(1),
});
export type CustomerFieldListItem = z.infer<typeof customerFieldListItemSchema>;
export const customerSummarySchema = z.object({
id: ulidSchema,
name: z.string().min(1),
shortName: z.string().min(1),
industryId: ulidSchema,
industryName: z.string().min(1),
industryPath: z.string().min(1),
status: customerStatusSchema,
ownerUserId: ulidSchema,
ownerName: z.string().min(1),
groups: z.array(customerGroupRefSchema),
baseFieldCount: z.number().int().nonnegative(),
genFieldCount: z.number().int().nonnegative(),
updatedAt: isoDateTimeSchema,
});
export type CustomerSummary = z.infer<typeof customerSummarySchema>;
export const customerDetailSchema = customerSummarySchema.extend({
contactName: z.string().nullable(),
contactPhone: z.string().nullable(),
preferences: customerPreferencesSchema,
baseFields: z.array(baseFieldSchema),
fields: z.array(customerFieldSchema),
remark: z.string().nullable(),
createdAt: isoDateTimeSchema,
});
export type CustomerDetail = z.infer<typeof customerDetailSchema>;
export const customerListQuerySchema = cursorPaginationQuerySchema.extend({
keyword: z.string().trim().max(64).optional(),
industry_id: ulidSchema.optional(),
status: z.enum(['all', 'active', 'paused', 'ended']).default('all'),
owner_user_id: ulidSchema.optional(),
group_id: ulidSchema.optional(),
});
export type CustomerListQuery = z.infer<typeof customerListQuerySchema>;
export const createCustomerInputSchema = z
.object({
industryId: ulidSchema,
name: customerNameSchema,
shortName: customerShortNameSchema,
ownerUserId: ulidSchema,
contactName: customerContactNameSchema,
contactPhone: customerContactPhoneSchema,
preferences: customerPreferencesSchema.optional(),
baseFields: z.array(baseFieldSchema).max(CUSTOMER_BASE_FIELD_LIMIT).optional(),
remark: customerRemarkSchema,
})
.strict();
export type CreateCustomerInput = z.infer<typeof createCustomerInputSchema>;
/** 所属行业不可修改(客户不可跨行业移动),状态走独立接口 */
export const updateCustomerInputSchema = z
.object({
name: customerNameSchema.optional(),
shortName: customerShortNameSchema.optional(),
ownerUserId: ulidSchema.optional(),
contactName: customerContactNameSchema,
contactPhone: customerContactPhoneSchema,
preferences: customerPreferencesSchema.optional(),
baseFields: z.array(baseFieldSchema).max(CUSTOMER_BASE_FIELD_LIMIT).optional(),
remark: customerRemarkSchema,
})
.strict();
export type UpdateCustomerInput = z.infer<typeof updateCustomerInputSchema>;
export const setCustomerStatusInputSchema = z
.object({
status: customerStatusSchema,
})
.strict();
export type SetCustomerStatusInput = z.infer<typeof setCustomerStatusInputSchema>;
export const transferCustomerInputSchema = z
.object({
ownerUserId: ulidSchema,
})
.strict();
export type TransferCustomerInput = z.infer<typeof transferCustomerInputSchema>;
// ---------------------------------------------------------------------------
// 客户组
// ---------------------------------------------------------------------------
export const customerGroupNameSchema = z
.string()
.trim()
.min(2, '客户组名称至少 2 个字符')
.max(64, '客户组名称最多 64 个字符');
export const customerGroupDescSchema = z.string().trim().max(255).default('');
export const customerGroupSummarySchema = z.object({
id: ulidSchema,
name: z.string().min(1),
description: z.string(),
memberCount: z.number().int().nonnegative(),
updatedAt: isoDateTimeSchema,
});
export type CustomerGroupSummary = z.infer<typeof customerGroupSummarySchema>;
export const customerGroupDetailSchema = customerGroupSummarySchema.extend({
members: z.array(
z.object({
id: ulidSchema,
name: z.string(),
shortName: z.string(),
industryPath: z.string(),
status: customerStatusSchema,
ownerName: z.string(),
}),
),
});
export type CustomerGroupDetail = z.infer<typeof customerGroupDetailSchema>;
export const customerGroupListQuerySchema = cursorPaginationQuerySchema.extend({
keyword: z.string().trim().max(64).optional(),
});
export type CustomerGroupListQuery = z.infer<typeof customerGroupListQuerySchema>;
export const createCustomerGroupInputSchema = z
.object({
name: customerGroupNameSchema,
description: customerGroupDescSchema.optional(),
customerIds: z.array(ulidSchema).max(500).optional(),
})
.strict();
export type CreateCustomerGroupInput = z.infer<typeof createCustomerGroupInputSchema>;
export const updateCustomerGroupInputSchema = z
.object({
name: customerGroupNameSchema.optional(),
description: customerGroupDescSchema.optional(),
})
.strict();
export type UpdateCustomerGroupInput = z.infer<typeof updateCustomerGroupInputSchema>;
export const updateCustomerGroupMembersInputSchema = z
.object({
addCustomerIds: z.array(ulidSchema).max(500).default([]),
removeCustomerIds: z.array(ulidSchema).max(500).default([]),
})
.strict();
export type UpdateCustomerGroupMembersInput = z.infer<typeof updateCustomerGroupMembersInputSchema>;
export const setCustomerGroupsInputSchema = z
.object({
groupIds: z.array(ulidSchema).max(50),
})
.strict();
export type SetCustomerGroupsInput = z.infer<typeof setCustomerGroupsInputSchema>;
// ---------------------------------------------------------------------------
// 客户级字段
// ---------------------------------------------------------------------------
export const customerFieldCodeSchema = z
.string()
.trim()
.min(2, '字段编码至少 2 个字符')
.max(32, '字段编码最多 32 个字符')
.regex(/^[a-z][a-z0-9_]{1,31}$/, '字段编码需小写字母开头,只能包含小写字母、数字与下划线');
export const customerFieldNameSchema = z
.string()
.trim()
.min(1, '请输入字段名称')
.max(32, '字段名称最多 32 个字符');
export const customerFieldPurposeSchema = z
.string()
.trim()
.min(1, '请填写用途说明')
.max(200, '用途说明最多 200 个字符');
export const applyCustomerFieldInputSchema = z
.object({
name: customerFieldNameSchema,
code: customerFieldCodeSchema,
type: customerFieldTypeSchema,
options: z.array(z.string().trim().min(1).max(32)).max(20).default([]),
purpose: customerFieldPurposeSchema,
entersGeneration: z.boolean().default(false),
})
.strict()
.refine((input) => input.type !== 'select' || input.options.length > 0, {
message: '单选字段至少提供一个选项',
path: ['options'],
});
export type ApplyCustomerFieldInput = z.infer<typeof applyCustomerFieldInputSchema>;
export const approveCustomerFieldInputSchema = z
.object({
decision: z.enum(['approve', 'reject']),
reason: z.string().trim().max(200).optional(),
})
.strict()
.refine((input) => input.decision !== 'reject' || (input.reason ?? '').length > 0, {
message: '驳回时必须填写原因',
path: ['reason'],
});
export type ApproveCustomerFieldInput = z.infer<typeof approveCustomerFieldInputSchema>;
export const customerFieldListQuerySchema = cursorPaginationQuerySchema.extend({
customer_id: ulidSchema.optional(),
status: z.enum(['all', 'pending', 'active', 'rejected', 'disabled']).default('all'),
});
export type CustomerFieldListQuery = z.infer<typeof customerFieldListQuerySchema>;
+32 -1
View File
@@ -4,6 +4,7 @@ import { ulid } from '../ulid.js';
import { import {
accountSchema, accountSchema,
changePasswordInputSchema, changePasswordInputSchema,
createDepartmentInputSchema,
createUserInputSchema, createUserInputSchema,
DATA_SCOPE_TYPES, DATA_SCOPE_TYPES,
dataScopeSchema, dataScopeSchema,
@@ -22,6 +23,7 @@ import {
roleSummarySchema, roleSummarySchema,
tokenPairSchema, tokenPairSchema,
transferCustomersInputSchema, transferCustomersInputSchema,
updateDepartmentInputSchema,
updateUserInputSchema, updateUserInputSchema,
USER_LIST_SORTS, USER_LIST_SORTS,
USER_LIST_STATUSES, USER_LIST_STATUSES,
@@ -183,7 +185,10 @@ describe('departmentNodeSchema', () => {
name: '运营中心', name: '运营中心',
parentId: null, parentId: null,
leader: null, leader: null,
status: 'active',
sortOrder: 0,
memberCount: 0, memberCount: 0,
directMemberCount: 0,
createdAt: ISO, createdAt: ISO,
}); });
expect(parsed.parentId).toBeNull(); expect(parsed.parentId).toBeNull();
@@ -196,19 +201,25 @@ describe('departmentNodeSchema', () => {
name: '投放一组', name: '投放一组',
parentId: ulid(), parentId: ulid(),
leader: { id: ulid(), name: '王五' }, leader: { id: ulid(), name: '王五' },
status: 'active',
sortOrder: 1,
memberCount: 12, memberCount: 12,
directMemberCount: 12,
createdAt: ISO, createdAt: ISO,
}); });
expect(parsed.leader?.name).toBe('王五'); expect(parsed.leader?.name).toBe('王五');
}); });
it('拒绝负数 / 非整数人数、非法 parentId、负责人缺姓名', () => { it('拒绝负数 / 非整数人数、非法 parentId、负责人缺姓名、非法状态', () => {
const base = { const base = {
id: ulid(), id: ulid(),
name: '运营中心', name: '运营中心',
parentId: null, parentId: null,
leader: null, leader: null,
status: 'active',
sortOrder: 0,
memberCount: 3, memberCount: 3,
directMemberCount: 3,
createdAt: ISO, createdAt: ISO,
}; };
expect(() => departmentNodeSchema.parse({ ...base, memberCount: -1 })).toThrow(); expect(() => departmentNodeSchema.parse({ ...base, memberCount: -1 })).toThrow();
@@ -217,6 +228,26 @@ describe('departmentNodeSchema', () => {
expect(() => expect(() =>
departmentNodeSchema.parse({ ...base, leader: { id: ulid(), name: '' } }), departmentNodeSchema.parse({ ...base, leader: { id: ulid(), name: '' } }),
).toThrow(); ).toThrow();
expect(() => departmentNodeSchema.parse({ ...base, status: 'offline' })).toThrow();
});
});
describe('部门写接口契约', () => {
it('新建部门:名称必填、parentId 可空、排序与负责人可选', () => {
const parsed = createDepartmentInputSchema.parse({ name: '运营中心' });
expect(parsed.parentId).toBeNull();
expect(() => createDepartmentInputSchema.parse({ name: '' })).toThrow();
expect(() =>
createDepartmentInputSchema.parse({ name: '运营中心', parentId: 'bad' }),
).toThrow();
expect(() => createDepartmentInputSchema.parse({ name: '运营中心', unknown: true })).toThrow();
});
it('更新部门:只允许名称 / 负责人 / 排序,未知字段拒绝', () => {
const parsed = updateDepartmentInputSchema.parse({ name: '运营中心', sortOrder: 2 });
expect(parsed.sortOrder).toBe(2);
expect(() => updateDepartmentInputSchema.parse({ parentId: ulid() })).toThrow();
expect(() => updateDepartmentInputSchema.parse({ sortOrder: -1 })).toThrow();
}); });
}); });
+56 -1
View File
@@ -82,7 +82,26 @@ export type RoleSummary = z.infer<typeof roleSummarySchema>;
// 部门 // 部门
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** 部门 / 小组节点;parentId 为 null 表示一级部门(最多两级,见 MAX_DEPARTMENT_DEPTH) */ /** 部门状态:停用后不出现在新建 / 编辑用户的部门选择中,历史数据保留 */
export const DEPARTMENT_STATUSES = ['active', 'disabled'] as const;
export const departmentStatusSchema = z.enum(DEPARTMENT_STATUSES, {
errorMap: () => ({ message: '部门状态只能是 active 或 disabled' }),
});
export type DepartmentStatus = z.infer<typeof departmentStatusSchema>;
/** 部门名称:1–20 个字符(同级唯一由服务端校验) */
export const departmentNameSchema = z
.string()
.trim()
.min(1, '请输入部门名称')
.max(20, '部门名称最多 20 个字符');
/**
* 部门 / 小组节点;parentId 为 null 表示一级部门(最多两级,见 MAX_DEPARTMENT_DEPTH)。
* memberCount 含下级成员,directMemberCount 仅本部门直属成员。
*/
export const departmentNodeSchema = z.object({ export const departmentNodeSchema = z.object({
id: ulidSchema, id: ulidSchema,
name: z.string().min(1, '部门名称不能为空'), name: z.string().min(1, '部门名称不能为空'),
@@ -93,12 +112,48 @@ export const departmentNodeSchema = z.object({
name: z.string().min(1, '负责人姓名不能为空'), name: z.string().min(1, '负责人姓名不能为空'),
}) })
.nullable(), .nullable(),
status: departmentStatusSchema,
sortOrder: z.number().int('排序必须是整数').min(0, '排序不能为负数'),
memberCount: z.number().int('部门人数必须是整数').nonnegative('部门人数不能为负数'), memberCount: z.number().int('部门人数必须是整数').nonnegative('部门人数不能为负数'),
directMemberCount: z.number().int('直属人数必须是整数').nonnegative('直属人数不能为负数'),
createdAt: isoDateTimeSchema, createdAt: isoDateTimeSchema,
}); });
export type DepartmentNode = z.infer<typeof departmentNodeSchema>; export type DepartmentNode = z.infer<typeof departmentNodeSchema>;
/** 新建部门 / 小组:parentId 为 null 建一级部门,否则在该部门下建小组 */
export const createDepartmentInputSchema = z
.object({
name: departmentNameSchema,
parentId: ulidSchema.nullable().default(null),
leaderUserId: ulidSchema.nullable().optional(),
sortOrder: z.number().int().min(0).max(9999).optional(),
})
.strict();
export type CreateDepartmentInput = z.infer<typeof createDepartmentInputSchema>;
/** 更新部门:只允许改名称 / 负责人 / 排序;上级关系创建后不可移动 */
export const updateDepartmentInputSchema = z
.object({
name: departmentNameSchema.optional(),
leaderUserId: ulidSchema.nullable().optional(),
sortOrder: z.number().int().min(0).max(9999).optional(),
})
.strict();
export type UpdateDepartmentInput = z.infer<typeof updateDepartmentInputSchema>;
/** 部门树查询:管理页需要包含已停用部门,普通选择器默认只看启用 */
export const departmentTreeQuerySchema = z.object({
include_disabled: z
.enum(['true', 'false'])
.default('false')
.transform((value) => value === 'true'),
});
export type DepartmentTreeQuery = z.infer<typeof departmentTreeQuerySchema>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 用户 // 用户
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+11 -6
View File
@@ -37,21 +37,26 @@ A0 只 seed「已建表」的 `system_configs`;A1 补齐组织、账号与角
## 使用 ## 使用
```bash ```bash
pnpm db:migrate # 应用 a2_role_permissions_data_scopes 迁移(expand:permissions / data_scopes 两张新表) pnpm db:init # 推荐:migrate deploy + 幂等 seed(不重置数据、不覆盖已有密码)
pnpm db:seed # 幂等写入上述种子(可重复执行;空库与存量库均已验证) pnpm db:migrate # 开发期新增迁移时使用(prisma migrate dev)
# 本地重置:pnpm db:reset(会重新执行全部迁移与 seed) pnpm db:seed # 只跑种子;幂等,不覆盖已有密码
# 本地清库:pnpm db:reset(会重建全部表并重新 seed,账号回到初始密码 + 首登改密标记)
``` ```
> **密码何时会被重置**:只有执行 `db:reset` / `prisma migrate reset` / 手工删库或重建 PG 数据卷时,种子账号才会回到 `lingxi2026` 且 `must_change_password=true`。
> `dev.sh`、`pnpm dev`、`pnpm db:init`、`pnpm db:seed` 都不会覆盖已有用户密码;`must_change_password` 在用户成功改密后写为 `false`,之后登录不再要求改密。
## 与 A1 / A2 遗留的关系 ## 与 A1 / A2 遗留的关系
- A1 记录的角色 / 用户种子已延续;A2 在其上补齐权限点目录、特殊权限口径(默认关闭、显式授予)与六类数据范围。 - A1 记录的角色 / 用户种子已延续;A2 在其上补齐权限点目录、特殊权限口径(默认关闭、显式授予)与六类数据范围。
- 角色权限数按原型 `design/a2-ui-v1/brief.md` 对齐(12/9/4/2/4/3/5/4/5,特殊授予 8 项);用户仍为 A1 的 11 个示例账号,原型「绑定用户 28 名」属 UI 示例数据,未扩种用户。 - 角色权限数按原型 `design/a2-ui-v1/brief.md` 对齐(12/9/4/2/4/3/5/4/5,特殊授予 8 项);用户仍为 A1 的 11 个示例账号,原型「绑定用户 28 名」属 UI 示例数据,未扩种用户。
- 字典(A4)、客户 / 客户组(A3)、行业包(A3)仍留待后续批次建表,不在本种子内。 - A3 起新增客户与行业种子:两级行业「电商(服饰鞋包 / 美妆个护)」「金融(信贷)」、客户「花间集 / 素研」、客户组「电商核心组」、4 条客户级字段(1 条待审批);按业务唯一键幂等写入,不覆盖人工调整。
- `data_scopes` 只存角色声明的范围与授权对象;解析后的可见客户集合由 `CustomerDirectory` 端口在请求期计算,A3 前为空集(种子不写客户 id)。 - 字典(A4)、行业包(B0)仍留待后续批次建表,不在本种子内。
- `data_scopes` 只存角色声明的范围与授权对象;A3 起由真实 `CustomerDirectory` 在请求期解析(客户负责人 / 客户组 / 部门 / 创建人),种子不写解析结果。
- 原有的 `Q-A0-01`(种子验收与建表顺序冲突)已随 A1 建表解除。 - 原有的 `Q-A0-01`(种子验收与建表顺序冲突)已随 A1 建表解除。
## 已知限制 ## 已知限制
- 客户数据(客户、客户组)属 A3,客户转交接口当前返回占位错误 `42201`(`客户转交将在客户模块(A3)提供`),种子不含客户;`customer_groups` 范围暂无可指向的客户组。 - 客户转交已由 A3 client 模块落地(`/customers/{id}/transfer` 与 `/users/{id}/transfer-customers`),不再返回占位错误;客户组范围可指向种子客户组。
- 审计表属 A5,`@Audit` 当前写结构化日志(含前后值与影响用户数);A5 落库后在此扩展日志 + 审计表双写。 - 审计表属 A5,`@Audit` 当前写结构化日志(含前后值与影响用户数);A5 落库后在此扩展日志 + 审计表双写。
- `outbox_events` 已写入 `role.permissions.changed` 占位事件(角色新建 / 更新 / 停用 / 删除),通知投递随 A5 / 任务中心接入。 - `outbox_events` 已写入 `role.permissions.changed` 占位事件(角色新建 / 更新 / 停用 / 删除),通知投递随 A5 / 任务中心接入。
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more