Compare commits
20
Commits
f97f97b0cd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50afaee49c | ||
|
|
c90a036f82 | ||
|
|
3e4bf48841 | ||
|
|
140dc386c1 | ||
|
|
8626996bb1 | ||
|
|
cda4b1825b | ||
|
|
087f27936e | ||
|
|
b489cb3152 | ||
|
|
4f6ccda6e3 | ||
|
|
f849e443d7 | ||
|
|
3e384febf3 | ||
|
|
981757b76c | ||
|
|
6661c42e84 | ||
|
|
f4da1f3c02 | ||
|
|
f5a8afe835 | ||
|
|
a3419cf5da | ||
|
|
fe70a2bcee | ||
|
|
7824bc7b32 | ||
|
|
7fb458f62f | ||
|
|
61894df401 |
No files matched your search
@@ -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:
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
| 立项建议 | [灵犀_信息流素材生产平台_立项建议书_V0.1.md](docs/立项建议/灵犀_信息流素材生产平台_立项建议书_V0.1.md) | V0.1 | 背景、目标、投入产出论证(不约束实现) |
|
| 立项建议 | [灵犀_信息流素材生产平台_立项建议书_V0.1.md](docs/立项建议/灵犀_信息流素材生产平台_立项建议书_V0.1.md) | V0.1 | 背景、目标、投入产出论证(不约束实现) |
|
||||||
| **工程规范** | [docs/工程规范/](docs/工程规范/README.md) | V1.0 | Git、代码风格、格式化、API、数据库、测试、Review、日志、密钥、资产命名 |
|
| **工程规范** | [docs/工程规范/](docs/工程规范/README.md) | V1.0 | Git、代码风格、格式化、API、数据库、测试、Review、日志、密钥、资产命名 |
|
||||||
| **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 完成范围、契约/实现/测试、遗留与下一步 |
|
||||||
|
| **A2 报告** | [灵犀_A2角色权限与数据范围_实施报告_V0.1.md](docs/开发计划/灵犀_A2角色权限与数据范围_实施报告_V0.1.md) | V0.1 | A2 完成范围、H1 决策、前后端/测试、遗留与下一步 |
|
||||||
|
| **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/工程规范/`。
|
||||||
@@ -44,7 +48,7 @@
|
|||||||
| **脚本** | 要素卡 / 卖点库 / 模板 / 生成 / 质量校核 / 审核入库 / 版本 |
|
| **脚本** | 要素卡 / 卖点库 / 模板 / 生成 / 质量校核 / 审核入库 / 版本 |
|
||||||
| **素材** | 图片线(PSD 套版、抠图、规格矩阵)、视频线(逐镜生成、变体)、快筛与预览 |
|
| **素材** | 图片线(PSD 套版、抠图、规格矩阵)、视频线(逐镜生成、变体)、快筛与预览 |
|
||||||
| **资产** | 15 类资产的库、版本、引用、复审与复用度量 |
|
| **资产** | 15 类资产的库、版本、引用、复审与复用度量 |
|
||||||
| **平台管理** | 组织账号、客户、角色权限、AI 配置、数据字典、审计、行业包与表单设计器 |
|
| **平台管理** | 组织账号、客户、角色管理、AI 配置、数据字典、审计、行业包与表单设计器 |
|
||||||
|
|
||||||
> 早期文档中的「四大模块(脚本生成 / 素材生产 / 编辑 / 管理)」已于 PRD V1.6 重构为「脚本 / 素材 / 资产 / 平台管理」三主线 + 一底座。
|
> 早期文档中的「四大模块(脚本生成 / 素材生产 / 编辑 / 管理)」已于 PRD V1.6 重构为「脚本 / 素材 / 资产 / 平台管理」三主线 + 一底座。
|
||||||
|
|
||||||
@@ -74,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
|
||||||
@@ -101,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 · 云白蓝(传统企业后台 + 现代浅色质感)**。
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { tap } from 'rxjs';
|
|||||||
|
|
||||||
import { AUDIT_ACTION_KEY } from './decorators';
|
import { AUDIT_ACTION_KEY } from './decorators';
|
||||||
import {
|
import {
|
||||||
|
type AuditDetail,
|
||||||
buildRequestContext,
|
buildRequestContext,
|
||||||
type RequestContext,
|
type RequestContext,
|
||||||
RequestContextService,
|
RequestContextService,
|
||||||
@@ -26,7 +27,16 @@ interface AuditPayload {
|
|||||||
result: 'success' | 'failure';
|
result: 'success' | 'failure';
|
||||||
actorId: string | null;
|
actorId: string | null;
|
||||||
account: string | null;
|
account: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
targetType: string | null;
|
||||||
targetId: string | null;
|
targetId: string | null;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
changes?: unknown;
|
||||||
|
affectedUserCount?: number;
|
||||||
|
affectedUserIds?: string[];
|
||||||
|
reason?: string;
|
||||||
traceId: string;
|
traceId: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -39,7 +49,10 @@ function actionOf(context: ExecutionContext): string | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局请求上下文 + @Audit 结构化日志。
|
* 全局请求上下文 + @Audit 结构化日志。
|
||||||
* A1 先写日志(Pino);A5 审计表落地后在此追加落库,装饰器签名不变。
|
*
|
||||||
|
* A2 起角色 / 权限变更由业务 Service 通过 `RequestContextService.patchAudit` 写入
|
||||||
|
* 前后值、影响用户数等明细;本拦截器统一输出一条结构化 Pino 日志(含操作人 / IP / UA /
|
||||||
|
* trace_id),A5 审计表落地后在此追加落库(装饰器签名不变)。
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuditInterceptor implements NestInterceptor {
|
export class AuditInterceptor implements NestInterceptor {
|
||||||
@@ -80,15 +93,27 @@ export class AuditInterceptor implements NestInterceptor {
|
|||||||
ctx: RequestContext,
|
ctx: RequestContext,
|
||||||
action: string,
|
action: string,
|
||||||
result: AuditPayload['result'],
|
result: AuditPayload['result'],
|
||||||
targetId: string | null,
|
defaultTargetId: string | null,
|
||||||
error?: unknown,
|
error?: unknown,
|
||||||
): void {
|
): void {
|
||||||
|
const detail: AuditDetail = ctx.audit ?? {};
|
||||||
const payload: AuditPayload = {
|
const payload: AuditPayload = {
|
||||||
action,
|
action,
|
||||||
result,
|
result,
|
||||||
actorId: ctx.userId,
|
actorId: ctx.userId,
|
||||||
account: ctx.account,
|
account: ctx.account,
|
||||||
targetId,
|
ip: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
targetType: detail.targetType ?? null,
|
||||||
|
targetId: detail.targetId ?? defaultTargetId,
|
||||||
|
...(detail.before === undefined ? {} : { before: detail.before }),
|
||||||
|
...(detail.after === undefined ? {} : { after: detail.after }),
|
||||||
|
...(detail.changes === undefined ? {} : { changes: detail.changes }),
|
||||||
|
...(detail.affectedUserCount === undefined
|
||||||
|
? {}
|
||||||
|
: { affectedUserCount: detail.affectedUserCount }),
|
||||||
|
...(detail.affectedUserIds === undefined ? {} : { affectedUserIds: detail.affectedUserIds }),
|
||||||
|
...(detail.reason === undefined ? {} : { reason: detail.reason }),
|
||||||
traceId: ctx.traceId,
|
traceId: ctx.traceId,
|
||||||
durationMs: Date.now() - ctx.startedAt,
|
durationMs: Date.now() - ctx.startedAt,
|
||||||
...(error === undefined
|
...(error === undefined
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
|
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
|
||||||
import type { FastifyRequest } from 'fastify';
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
import type { ScopeContext } from './scope/scope-context';
|
||||||
|
|
||||||
/** access token 载荷:只放用户 id,权限每请求按 DB 解析(角色变更即时生效) */
|
/** access token 载荷:只放用户 id,权限每请求按 DB 解析(角色变更即时生效) */
|
||||||
export interface JwtAccessPayload {
|
export interface JwtAccessPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
@@ -22,12 +24,22 @@ export interface AuthContext {
|
|||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
roles: RoleSummary[];
|
roles: RoleSummary[];
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
dataScope: DataScope;
|
/**
|
||||||
|
* 角色声明的数据范围(未解析客户集合;多角色,每个角色一种类型)。
|
||||||
|
* 超管角色 → `[{ type: 'all' }]`(隐式全集)。
|
||||||
|
*/
|
||||||
|
dataScopes: DataScope[];
|
||||||
|
/** 用户所在部门 + 其下级部门 id(两级组织;用于解析部门类范围) */
|
||||||
|
departmentIds: string[];
|
||||||
|
/** 是否拥有 `is_system` 超管角色(隐式拥有全部权限点 / 特殊权限 / 数据范围) */
|
||||||
|
isSuperAdmin: boolean;
|
||||||
mustChangePassword: boolean;
|
mustChangePassword: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthenticatedRequest extends FastifyRequest {
|
export interface AuthenticatedRequest extends FastifyRequest {
|
||||||
auth?: AuthContext;
|
auth?: AuthContext;
|
||||||
|
/** ScopeGuard 解析后的请求作用域(Repository 客户维度过滤的唯一来源) */
|
||||||
|
scope?: ScopeContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录 / 刷新时随令牌一起返回的调用上下文(用于日志与 @Audit) */
|
/** 登录 / 刷新时随令牌一起返回的调用上下文(用于日志与 @Audit) */
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { PERMISSION_CATALOG, SPECIAL_PERMISSIONS } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PermissionCatalogService } from './permission-catalog.service';
|
||||||
|
|
||||||
|
function makeService(): {
|
||||||
|
service: PermissionCatalogService;
|
||||||
|
upsert: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
const upsert = vi.fn().mockResolvedValue({});
|
||||||
|
const prisma = { permission: { upsert } };
|
||||||
|
return {
|
||||||
|
service: new PermissionCatalogService(prisma as never),
|
||||||
|
upsert,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PermissionCatalogService', () => {
|
||||||
|
it('启动同步:30 个权限点逐个幂等 upsert(不删除历史行)', async () => {
|
||||||
|
const { service, upsert } = makeService();
|
||||||
|
const count = await service.sync();
|
||||||
|
expect(count).toBe(30);
|
||||||
|
expect(upsert).toHaveBeenCalledTimes(30);
|
||||||
|
expect(upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { code: 'role:view' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(upsert.mock.calls[0]?.[0]).toEqual({
|
||||||
|
where: { code: PERMISSION_CATALOG[0]?.code },
|
||||||
|
update: expect.objectContaining({ module: PERMISSION_CATALOG[0]?.module }),
|
||||||
|
create: expect.objectContaining({ code: PERMISSION_CATALOG[0]?.code }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('启动同步失败只告警不阻断(OpenAPI 导出 / 无 DB 命令可继续)', async () => {
|
||||||
|
const { service, upsert } = makeService();
|
||||||
|
upsert.mockRejectedValueOnce(new Error('db unavailable'));
|
||||||
|
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('权限树:10 模块(与 UI 原型顺序一致)+ 6 特殊权限 + 3 对 SoD', () => {
|
||||||
|
const { service } = makeService();
|
||||||
|
const tree = service.buildTree();
|
||||||
|
expect(tree.totalPermissions).toBe(30);
|
||||||
|
expect(tree.modules).toHaveLength(10);
|
||||||
|
expect(tree.modules.map((module) => module.module)).toEqual([
|
||||||
|
'script',
|
||||||
|
'material',
|
||||||
|
'asset',
|
||||||
|
'delivery',
|
||||||
|
'customer',
|
||||||
|
'customer_group',
|
||||||
|
'identity',
|
||||||
|
'industry',
|
||||||
|
'platform',
|
||||||
|
'audit',
|
||||||
|
]);
|
||||||
|
expect(tree.modules.map((module) => module.moduleLabel)).toEqual([
|
||||||
|
'脚本',
|
||||||
|
'素材',
|
||||||
|
'资产',
|
||||||
|
'导出交付',
|
||||||
|
'客户',
|
||||||
|
'客户组',
|
||||||
|
'组织与角色',
|
||||||
|
'行业包',
|
||||||
|
'平台配置',
|
||||||
|
'审计',
|
||||||
|
]);
|
||||||
|
expect(tree.modules.flatMap((module) => module.permissions)).toHaveLength(30);
|
||||||
|
expect(tree.specialPermissions).toHaveLength(6);
|
||||||
|
expect(tree.specialPermissions.map((item) => item.code).sort()).toEqual(
|
||||||
|
[...SPECIAL_PERMISSIONS].sort(),
|
||||||
|
);
|
||||||
|
expect(tree.sodPairs).toHaveLength(3);
|
||||||
|
expect(tree.sodPairs[0]?.permissions).toEqual(['script:write', 'script:review']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
PERMISSION_CATALOG,
|
||||||
|
type PermissionModule,
|
||||||
|
permissionsByModule,
|
||||||
|
type PermissionTree,
|
||||||
|
SOD_PAIRS,
|
||||||
|
SPECIAL_PERMISSIONS,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../../infra/tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限点注册与同步(A2-BE-02)。
|
||||||
|
*
|
||||||
|
* 唯一事实源是 `@lingxi/shared` 的 `PERMISSION_CATALOG`:启动时把目录幂等 upsert 进
|
||||||
|
* `permissions` 表(只增不删),供管理端检索 / 审计 / 后续统计 join;权限树本身仍从
|
||||||
|
* 代码目录生成(避免「DB 里有、代码里没有」的幽灵权限点)。
|
||||||
|
*
|
||||||
|
* 启动同步失败只告警不阻断(OpenAPI 导出、无 DB 的本地命令不应因此失败);
|
||||||
|
* 生产健康检查与告警另行覆盖。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionCatalogService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger('PermissionCatalog');
|
||||||
|
|
||||||
|
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const count = await this.sync();
|
||||||
|
this.logger.log(`权限点目录已同步:${String(count)} 个`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`权限点目录同步失败(不阻断启动):${error instanceof Error ? error.message : '未知错误'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 幂等 upsert:更新元数据、补新增,不删除目录之外的历史行(只增不删) */
|
||||||
|
async sync(): Promise<number> {
|
||||||
|
for (const item of PERMISSION_CATALOG) {
|
||||||
|
await this.prisma.permission.upsert({
|
||||||
|
where: { code: item.code },
|
||||||
|
update: {
|
||||||
|
module: item.module,
|
||||||
|
moduleLabel: item.moduleLabel,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
isSpecial: item.isSpecial,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
code: item.code,
|
||||||
|
module: item.module,
|
||||||
|
moduleLabel: item.moduleLabel,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
isSpecial: item.isSpecial,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return PERMISSION_CATALOG.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限树(10 模块 + 特殊权限汇总 + SoD 对):直接由代码目录生成 */
|
||||||
|
buildTree(): PermissionTree {
|
||||||
|
const modules: {
|
||||||
|
module: PermissionModule;
|
||||||
|
moduleLabel: string;
|
||||||
|
sortOrder: number;
|
||||||
|
permissions: {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
isSpecial: boolean;
|
||||||
|
}[];
|
||||||
|
}[] = permissionsByModule().map((group) => ({
|
||||||
|
module: group.module,
|
||||||
|
moduleLabel: group.moduleLabel,
|
||||||
|
sortOrder: group.sortOrder,
|
||||||
|
permissions: group.permissions.map((item) => ({
|
||||||
|
code: item.code,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
isSpecial: item.isSpecial,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
const specialCodeSet = new Set<string>(SPECIAL_PERMISSIONS);
|
||||||
|
const specialPermissions = PERMISSION_CATALOG.filter((item) =>
|
||||||
|
specialCodeSet.has(item.code),
|
||||||
|
).map((item) => ({
|
||||||
|
code: item.code,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
isSpecial: true,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
modules,
|
||||||
|
specialPermissions,
|
||||||
|
sodPairs: SOD_PAIRS.map((pair) => ({
|
||||||
|
id: pair.id,
|
||||||
|
label: pair.label,
|
||||||
|
description: pair.description,
|
||||||
|
permissions: [pair.permissions[0], pair.permissions[1]],
|
||||||
|
})),
|
||||||
|
totalPermissions: PERMISSION_CATALOG.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createParamDecorator, type ExecutionContext, SetMetadata } from '@nestjs/common';
|
import { createParamDecorator, type ExecutionContext, SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
import type { AuthContext, AuthenticatedRequest } from './auth.types';
|
import type { AuthContext, AuthenticatedRequest } from './auth.types';
|
||||||
|
import type { ScopeContext } from './scope/scope-context';
|
||||||
|
|
||||||
export const IS_PUBLIC_KEY = 'auth:public';
|
export const IS_PUBLIC_KEY = 'auth:public';
|
||||||
/** 标记公开接口(登录、刷新、健康检查);其余接口默认必须登录 */
|
/** 标记公开接口(登录、刷新、健康检查);其余接口默认必须登录 */
|
||||||
@@ -23,3 +24,11 @@ export const CurrentUser = createParamDecorator(
|
|||||||
return request.auth ?? null;
|
return request.auth ?? null;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 取 ScopeGuard 解析后的数据范围上下文;未登录 / 未解析返回 null */
|
||||||
|
export const CurrentScope = createParamDecorator(
|
||||||
|
(_data: unknown, context: ExecutionContext): ScopeContext | null => {
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
return request.scope ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -7,6 +7,7 @@ import type { AuthContext, AuthenticatedRequest } from './auth.types';
|
|||||||
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
|
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
|
||||||
import { PermissionService } from './permission.service';
|
import { PermissionService } from './permission.service';
|
||||||
import { RequestContextService } from './request-context';
|
import { RequestContextService } from './request-context';
|
||||||
|
import { ScopeResolver } from './scope/scope-resolver.service';
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
|
|
||||||
const MUST_CHANGE_ALLOWED_PATHS: readonly string[] = [
|
const MUST_CHANGE_ALLOWED_PATHS: readonly string[] = [
|
||||||
@@ -123,9 +124,9 @@ export class PermissionGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const auth = requireAuth(context);
|
const auth = requireAuth(context);
|
||||||
const missing = normal.filter((code) => !auth.permissions.includes(code));
|
// 多个权限点之间为 OR(任一满足即通过,见 @RequirePermissions 注释)
|
||||||
if (missing.length > 0) {
|
if (!normal.some((code) => auth.permissions.includes(code))) {
|
||||||
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { missing } });
|
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { required: normal } });
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -147,11 +148,10 @@ export class SpecialPermissionGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const auth = requireAuth(context);
|
const auth = requireAuth(context);
|
||||||
const missing = special.filter((code) => !auth.permissions.includes(code));
|
if (!special.some((code) => auth.permissions.includes(code))) {
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new AppException({
|
throw new AppException({
|
||||||
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
|
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
|
||||||
details: { missing },
|
details: { required: special },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -159,16 +159,32 @@ export class SpecialPermissionGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 4) 数据范围守卫骨架:A2 按 dataScope 注入 ScopeContext(部门 / 客户集合)。
|
* 4) 数据范围守卫:解析 `ScopeContext`(六类范围 → 可见客户集合),注入 `request.scope`
|
||||||
* A1 identity 接口不返回客户数据,非全局范围暂直接放行。
|
* 与请求上下文;Repository 必须通过 `customerScopeWhere(ctx)` 强制客户维度过滤。
|
||||||
|
*
|
||||||
|
* 口径(技术架构 §5.5 / §7.1「失败处理」):解析失败一律 403(40302),不降级为「全部客户」;
|
||||||
|
* 公开接口(登录 / 健康检查)不解析。
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ScopeGuard implements CanActivate {
|
export class ScopeGuard implements CanActivate {
|
||||||
canActivate(context: ExecutionContext): boolean {
|
constructor(
|
||||||
const auth = authOf(context);
|
@Inject(Reflector) private readonly reflector: Reflector,
|
||||||
if (auth !== null && auth.dataScope.type !== 'all') {
|
@Inject(ScopeResolver) private readonly resolver: ScopeResolver,
|
||||||
// TODO(A2-BE-04):按 dataScope 注入 ScopeContext,并在 Repository 强制带 customer_id 过滤
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
if (isPublic(this.reflector, context)) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
const auth = authOf(context);
|
||||||
|
if (auth === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const scope = await this.resolver.resolve(auth);
|
||||||
|
request.scope = scope;
|
||||||
|
this.requestContext.patch({ scope });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
|
import {
|
||||||
|
DATA_SCOPE_TYPES,
|
||||||
|
type DataScope,
|
||||||
|
type DataScopeType,
|
||||||
|
isPermissionCode,
|
||||||
|
PERMISSION_CODES,
|
||||||
|
type RoleSummary,
|
||||||
|
type UserStatus,
|
||||||
|
} from '@lingxi/shared';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@@ -6,17 +14,19 @@ import type { PrismaService } from '../../infra/prisma/prisma.service';
|
|||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
import type { AuthContext } from './auth.types';
|
import type { AuthContext } from './auth.types';
|
||||||
|
|
||||||
/** 鉴权解析需要的用户关联:部门(含父级)、角色(含权限点) */
|
/** 鉴权解析需要的用户关联:部门(父级 + 下级)、角色(含权限点与数据范围声明) */
|
||||||
export type UserAuthRecord = Prisma.UserGetPayload<{
|
export type UserAuthRecord = Prisma.UserGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
department: { include: { parent: true } };
|
department: { include: { parent: true; children: true } };
|
||||||
roles: { include: { role: { include: { permissions: true } } } };
|
roles: {
|
||||||
|
include: { role: { include: { permissions: true; dataScopes: true } } };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const ALL_DATA_SCOPE_ROLES: readonly string[] = ['super_admin', 'platform_admin'];
|
type RoleEntity = UserAuthRecord['roles'][number]['role'];
|
||||||
|
|
||||||
function toRoleSummary(role: UserAuthRecord['roles'][number]['role']): RoleSummary {
|
function toRoleSummary(role: RoleEntity): RoleSummary {
|
||||||
return {
|
return {
|
||||||
id: role.id,
|
id: role.id,
|
||||||
code: role.code,
|
code: role.code,
|
||||||
@@ -26,32 +36,59 @@ function toRoleSummary(role: UserAuthRecord['roles'][number]['role']): RoleSumma
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 数据范围骨架:A1 只分「全部 / 本部门及下级」,A2 再落六类范围与客户集合解析 */
|
|
||||||
export function resolveDataScope(roleCodes: readonly string[]): DataScope {
|
|
||||||
const isGlobal = roleCodes.some((code) => ALL_DATA_SCOPE_ROLES.includes(code));
|
|
||||||
return isGlobal ? { type: 'all' } : { type: 'department_and_children' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activeRolesOf(user: UserAuthRecord): UserAuthRecord['roles'] {
|
export function activeRolesOf(user: UserAuthRecord): UserAuthRecord['roles'] {
|
||||||
return user.roles.filter((item) => item.role.status === 'ACTIVE');
|
return user.roles.filter((item) => item.role.status === 'ACTIVE');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并多角色的数据范围声明:同一类型取并集(targetIds 去重排序),按六类固定顺序输出。
|
||||||
|
* 一个角色只允许一种类型(A2 契约),但历史数据可能有多行(customer_groups 多客户组),
|
||||||
|
* 因此按 scope_type 聚合后再跨角色合并。
|
||||||
|
*/
|
||||||
|
export function mergeRoleDataScopes(roles: readonly RoleEntity[]): DataScope[] {
|
||||||
|
const byType = new Map<DataScopeType, Set<string>>();
|
||||||
|
for (const role of roles) {
|
||||||
|
for (const scope of role.dataScopes) {
|
||||||
|
const type = scope.scopeType as DataScopeType;
|
||||||
|
const targets = byType.get(type) ?? new Set<string>();
|
||||||
|
if (scope.targetId !== null) {
|
||||||
|
targets.add(scope.targetId);
|
||||||
|
}
|
||||||
|
byType.set(type, targets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ordered: DataScope[] = [];
|
||||||
|
for (const type of DATA_SCOPE_TYPES) {
|
||||||
|
const targets = byType.get(type);
|
||||||
|
if (targets === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ordered.push({
|
||||||
|
type,
|
||||||
|
...(type === 'customer_groups' ? { targetIds: [...targets].sort() } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户部门子树 id(自身在前,最多两级):scope 解析需要「本部门 / 本部门及下级」 */
|
||||||
|
export function departmentSubtreeIds(user: UserAuthRecord): string[] {
|
||||||
|
return [user.department.id, ...user.department.children.map((child) => child.id)];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 权限解析:**每个请求实时读 DB**,不缓存角色/权限点,
|
* 权限解析:**每个请求实时读 DB**,不缓存角色/权限点,
|
||||||
* 保证角色变更在下一次请求立即生效(A1-BE-04 验收项)。
|
* 保证角色变更(含停用)在下一次请求立即生效(A1-BE-04 / A2-BE-05 验收项)。
|
||||||
|
*
|
||||||
|
* 超管(`is_system = true`)隐式拥有全部权限点与特殊权限,`role_permissions` 不参与解析;
|
||||||
|
* 其余角色取启用角色权限点并集,未登记编码被丢弃(防「幽灵权限点」)。
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PermissionService {
|
export class PermissionService {
|
||||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async resolveAuth(userId: string): Promise<AuthContext | null> {
|
async resolveAuth(userId: string): Promise<AuthContext | null> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.findAuthRecord(userId);
|
||||||
where: { id: userId },
|
|
||||||
include: {
|
|
||||||
department: { include: { parent: true } },
|
|
||||||
roles: { include: { role: { include: { permissions: true } } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -62,25 +99,35 @@ export class PermissionService {
|
|||||||
return this.prisma.user.findUnique({
|
return this.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
include: {
|
include: {
|
||||||
department: { include: { parent: true } },
|
department: { include: { parent: true, children: true } },
|
||||||
roles: { include: { role: { include: { permissions: true } } } },
|
roles: { include: { role: { include: { permissions: true, dataScopes: true } } } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toAuthContext(user: UserAuthRecord): AuthContext {
|
toAuthContext(user: UserAuthRecord): AuthContext {
|
||||||
const roles = activeRolesOf(user);
|
const roles = activeRolesOf(user);
|
||||||
const permissions = [
|
const roleEntities = roles.map((item) => item.role);
|
||||||
...new Set(roles.flatMap((item) => item.role.permissions.map((p) => p.permissionCode))),
|
const isSuperAdmin = roleEntities.some((role) => role.isSystem);
|
||||||
|
const permissions = isSuperAdmin
|
||||||
|
? [...PERMISSION_CODES]
|
||||||
|
: [
|
||||||
|
...new Set(
|
||||||
|
roleEntities
|
||||||
|
.flatMap((role) => role.permissions.map((item) => item.permissionCode))
|
||||||
|
.filter((code) => isPermissionCode(code)),
|
||||||
|
),
|
||||||
].sort();
|
].sort();
|
||||||
const status: UserStatus = user.status === 'ACTIVE' ? 'active' : 'disabled';
|
const status: UserStatus = user.status === 'ACTIVE' ? 'active' : 'disabled';
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
account: user.account,
|
account: user.account,
|
||||||
status,
|
status,
|
||||||
roles: roles.map((item) => toRoleSummary(item.role)),
|
roles: roleEntities.map((role) => toRoleSummary(role)),
|
||||||
permissions,
|
permissions,
|
||||||
dataScope: resolveDataScope(roles.map((item) => item.role.code)),
|
dataScopes: isSuperAdmin ? [{ type: 'all' }] : mergeRoleDataScopes(roleEntities),
|
||||||
|
departmentIds: departmentSubtreeIds(user),
|
||||||
|
isSuperAdmin,
|
||||||
mustChangePassword: user.mustChangePassword,
|
mustChangePassword: user.mustChangePassword,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,35 @@ import { ulid } from '@lingxi/shared';
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import type { AuthenticatedRequest } from './auth.types';
|
import type { AuthenticatedRequest } from './auth.types';
|
||||||
|
import type { ScopeContext } from './scope/scope-context';
|
||||||
|
|
||||||
/** 单次请求上下文:trace_id / 当前用户 / 权限快照(@Audit 与业务日志复用) */
|
/**
|
||||||
|
* 变更审计明细(A2-BE-05):由业务 Service 通过 `RequestContextService.patch({ audit })` 写入,
|
||||||
|
* AuditInterceptor 在请求结束时统一输出结构化 Pino 日志(A5 再落审计表)。
|
||||||
|
*
|
||||||
|
* 权限 / 角色类变更必须带:操作人(context.userId)、IP(context.ip)、
|
||||||
|
* 变更前后值(before / after)、影响用户数(affectedUserCount / affectedUserIds)。
|
||||||
|
*/
|
||||||
|
export interface AuditDetail {
|
||||||
|
targetType?: string;
|
||||||
|
targetId?: string;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
affectedUserCount?: number;
|
||||||
|
affectedUserIds?: string[];
|
||||||
|
reason?: string;
|
||||||
|
/** 追加的变更摘要(如权限点 diff),与 before / after 并存 */
|
||||||
|
changes?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单次请求上下文:trace_id / 当前用户 / 权限快照 / 数据范围 / 审计明细 */
|
||||||
export interface RequestContext {
|
export interface RequestContext {
|
||||||
traceId: string;
|
traceId: string;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
account: string | null;
|
account: string | null;
|
||||||
permissions: string[] | null;
|
permissions: string[] | null;
|
||||||
|
scope: ScopeContext | null;
|
||||||
|
audit: AuditDetail | null;
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
userAgent: string | null;
|
userAgent: string | null;
|
||||||
method: string;
|
method: string;
|
||||||
@@ -32,6 +54,8 @@ export function buildRequestContext(request: RequestWithTrace): RequestContext {
|
|||||||
userId: auth?.userId ?? null,
|
userId: auth?.userId ?? null,
|
||||||
account: auth?.account ?? null,
|
account: auth?.account ?? null,
|
||||||
permissions: auth?.permissions ?? null,
|
permissions: auth?.permissions ?? null,
|
||||||
|
scope: request.scope ?? null,
|
||||||
|
audit: null,
|
||||||
ip: request.ip.length > 0 ? request.ip : null,
|
ip: request.ip.length > 0 ? request.ip : null,
|
||||||
userAgent: request.headers['user-agent'] ?? null,
|
userAgent: request.headers['user-agent'] ?? null,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
@@ -42,7 +66,7 @@ export function buildRequestContext(request: RequestWithTrace): RequestContext {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* AsyncLocalStorage 请求上下文:拦截器在进入 handler 前建立,
|
* AsyncLocalStorage 请求上下文:拦截器在进入 handler 前建立,
|
||||||
* 业务 Service 可 `get()` 读取 trace_id / 当前用户,无需逐层透传。
|
* 业务 Service 可 `get()` 读取 trace_id / 当前用户 / ScopeContext,无需逐层透传。
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RequestContextService {
|
export class RequestContextService {
|
||||||
@@ -63,4 +87,13 @@ export class RequestContextService {
|
|||||||
}
|
}
|
||||||
Object.assign(store, partial);
|
Object.assign(store, partial);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 追加审计明细(多次 patch 时浅合并,便于业务 Service 逐步补充) */
|
||||||
|
patchAudit(detail: Partial<AuditDetail>): void {
|
||||||
|
const store = this.storage.getStore();
|
||||||
|
if (store === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.audit = { ...(store.audit ?? {}), ...detail };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户目录端口(A2-BE-04 / 技术架构 §5.5)。
|
||||||
|
*
|
||||||
|
* 数据范围的可见客户集合由「客户目录」解析;A2 只有角色与范围声明,客户 / 客户组 /
|
||||||
|
* 客户负责人属 A3(client 模块)。为避免 A2 依赖未建表的客户模块,这里定义可替换端口:
|
||||||
|
* - A2 注册 `EmptyCustomerDirectory`:所有方法返回空集(不越权,不需要降级);
|
||||||
|
* - A3 落地后用真实实现替换 provider(注入 token 不变),ScopeResolver 无需修改。
|
||||||
|
*
|
||||||
|
* 约定:端口只读;返回客户 id 列表,由 ScopeResolver 做去重与并集。
|
||||||
|
*/
|
||||||
|
export const CUSTOMER_DIRECTORY = Symbol('CUSTOMER_DIRECTORY');
|
||||||
|
|
||||||
|
export interface CustomerDirectory {
|
||||||
|
/** 「负责客户」范围:客户负责人 = 本人 */
|
||||||
|
findOwnedCustomerIds(userId: string): Promise<string[]>;
|
||||||
|
/** 「指定客户组」范围:客户组下全部客户 */
|
||||||
|
findCustomerGroupCustomerIds(customerGroupIds: readonly string[]): Promise<string[]>;
|
||||||
|
/** 「本部门 / 本部门及下级」范围:部门子树内负责人所属客户 */
|
||||||
|
findDepartmentCustomerIds(departmentIds: readonly string[]): Promise<string[]>;
|
||||||
|
/** 「本人创建」范围:创建人 = 本人 */
|
||||||
|
findCreatedCustomerIds(userId: string): Promise<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2 占位实现:A3 前没有任何客户数据,所有范围解析为空集。
|
||||||
|
* 空集意味着「看不到任何客户」,是安全的默认值(解析失败宁可不可用也不越权)。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EmptyCustomerDirectory implements CustomerDirectory {
|
||||||
|
findOwnedCustomerIds(_userId: string): Promise<string[]> {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
findCustomerGroupCustomerIds(_customerGroupIds: readonly string[]): Promise<string[]> {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
findDepartmentCustomerIds(_departmentIds: readonly string[]): Promise<string[]> {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
findCreatedCustomerIds(_userId: string): Promise<string[]> {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { type DataScope, ErrorCode } from '@lingxi/shared';
|
||||||
|
|
||||||
|
import { AppException } from '../../errors/app-exception';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求作用域上下文(技术架构 §5.5):登录用户 + 多角色并集后的可见客户集合。
|
||||||
|
*
|
||||||
|
* 解析顺序(技术架构 §7.1):
|
||||||
|
* 1. JWT / 账号状态由 JwtAuthGuard 校验;
|
||||||
|
* 2. 角色数据范围 → 客户集合(六类,CustomerDirectory 端口解析);
|
||||||
|
* 3. 多角色取并集;任一角色为「全部客户」即全部;
|
||||||
|
* 4. 单个资源请求显式指定客户时,必须落在集合内。
|
||||||
|
*
|
||||||
|
* 失败处理:解析失败一律 403(40302),**不降级为全部客户**。
|
||||||
|
*/
|
||||||
|
export interface ScopeContext {
|
||||||
|
userId: string;
|
||||||
|
/** 是否拥有 is_system 超管角色(隐式全部数据范围) */
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
/** 解析后的数据范围(每个元素带 customerIds;超管为 [{ type: 'all' }]) */
|
||||||
|
dataScopes: DataScope[];
|
||||||
|
/** null = 全部客户可见;否则为可见客户 id 集合(已排序去重;A3 前为空数组) */
|
||||||
|
customerIds: readonly string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository 客户维度过滤参数(Prisma where 片段)。
|
||||||
|
* 使用方必须把返回值展开进 where:`where: { ...customerScopeWhere(ctx), ... }`,
|
||||||
|
* 禁止「先查全量再内存过滤」(AGENTS §3.5)。
|
||||||
|
*/
|
||||||
|
export interface CustomerScopeWhere {
|
||||||
|
customerId?: { in: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否可见全部客户 */
|
||||||
|
export function isAllCustomers(ctx: ScopeContext): boolean {
|
||||||
|
return ctx.customerIds === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造客户维度强制过滤片段:全部客户返回空对象,受限范围返回 `{ customerId: { in } }` */
|
||||||
|
export function customerScopeWhere(ctx: ScopeContext): CustomerScopeWhere {
|
||||||
|
return ctx.customerIds === null ? {} : { customerId: { in: [...ctx.customerIds] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断单个客户是否在范围内(用于单条资源的 404 / 403 语义选择) */
|
||||||
|
export function isCustomerVisible(ctx: ScopeContext, customerId: string): boolean {
|
||||||
|
return ctx.customerIds === null || ctx.customerIds.includes(customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断言对指定客户有写权限;范围外抛 40302(不泄露资源是否存在)。
|
||||||
|
* 单条**读**接口按 §10.3 应返回 404(避免探测存在性),调用方可先用 isCustomerVisible 判定。
|
||||||
|
*/
|
||||||
|
export function assertCustomerAccess(ctx: ScopeContext, customerId: string): void {
|
||||||
|
if (!isCustomerVisible(ctx, customerId)) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.OUT_OF_SCOPE,
|
||||||
|
message: '客户不在当前数据范围之内',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 断言存在客户维度上下文(Repository 方法入口守卫,防止漏注入) */
|
||||||
|
export function requireScopeContext(ctx: ScopeContext | null | undefined): ScopeContext {
|
||||||
|
if (ctx === null || ctx === undefined) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.OUT_OF_SCOPE,
|
||||||
|
message: '缺少数据范围上下文,已拒绝访问',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { DATA_SCOPE_TYPES, type DataScope, type DataScopeType, ErrorCode } from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppException } from '../../errors/app-exception';
|
||||||
|
import type { AuthContext } from '../auth.types';
|
||||||
|
import { CUSTOMER_DIRECTORY, type CustomerDirectory } from './customer-directory';
|
||||||
|
import type { ScopeContext } from './scope-context';
|
||||||
|
|
||||||
|
/** 范围声明去重后的内部结构:一个角色一种类型,但多角色可能带来同类型的多个 targetIds */
|
||||||
|
interface ScopeDeclaration {
|
||||||
|
type: DataScopeType;
|
||||||
|
targetIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeDeclarations(scopes: readonly DataScope[]): ScopeDeclaration[] {
|
||||||
|
const byType = new Map<DataScopeType, Set<string>>();
|
||||||
|
for (const scope of scopes) {
|
||||||
|
const existing = byType.get(scope.type) ?? new Set<string>();
|
||||||
|
for (const targetId of scope.targetIds ?? []) {
|
||||||
|
existing.add(targetId);
|
||||||
|
}
|
||||||
|
byType.set(scope.type, existing);
|
||||||
|
}
|
||||||
|
return [...byType.entries()]
|
||||||
|
.map(([type, targetIdSet]) => ({
|
||||||
|
type,
|
||||||
|
targetIds: [...targetIdSet].sort(),
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(left, right) => DATA_SCOPE_TYPES.indexOf(left.type) - DATA_SCOPE_TYPES.indexOf(right.type),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueSorted(ids: readonly string[]): string[] {
|
||||||
|
return [...new Set(ids)].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据范围解析器(A2-BE-04):把角色声明的六类范围解析为 `ScopeContext`。
|
||||||
|
*
|
||||||
|
* - 超管(is_system 角色)隐式全集:`[{ type: 'all' }]`;
|
||||||
|
* - 任一角色为「全部客户」→ 并集为全部客户;
|
||||||
|
* - 多个角色取并集(customerIds 去重排序);
|
||||||
|
* - 没有任何范围声明时按最小权限「本人创建」解析,不放行全部;
|
||||||
|
* - 端口调用失败 → 40302(不降级)。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ScopeResolver {
|
||||||
|
constructor(@Inject(CUSTOMER_DIRECTORY) private readonly customers: CustomerDirectory) {}
|
||||||
|
|
||||||
|
async resolve(
|
||||||
|
auth: Pick<AuthContext, 'userId' | 'isSuperAdmin' | 'dataScopes' | 'departmentIds'>,
|
||||||
|
): Promise<ScopeContext> {
|
||||||
|
if (auth.isSuperAdmin) {
|
||||||
|
return {
|
||||||
|
userId: auth.userId,
|
||||||
|
isSuperAdmin: true,
|
||||||
|
dataScopes: [{ type: 'all' }],
|
||||||
|
customerIds: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const declarations =
|
||||||
|
mergeDeclarations(auth.dataScopes).length > 0
|
||||||
|
? mergeDeclarations(auth.dataScopes)
|
||||||
|
: [{ type: 'created_by_me' as const, targetIds: [] }];
|
||||||
|
if (declarations.some((declaration) => declaration.type === 'all')) {
|
||||||
|
return {
|
||||||
|
userId: auth.userId,
|
||||||
|
isSuperAdmin: false,
|
||||||
|
dataScopes: [{ type: 'all' }],
|
||||||
|
customerIds: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await this.buildScopedContext(auth, declarations);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.OUT_OF_SCOPE,
|
||||||
|
message: '数据范围解析失败,已拒绝访问',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildScopedContext(
|
||||||
|
auth: Pick<AuthContext, 'userId' | 'departmentIds'>,
|
||||||
|
declarations: readonly ScopeDeclaration[],
|
||||||
|
): Promise<ScopeContext> {
|
||||||
|
const union = new Set<string>();
|
||||||
|
const resolved: DataScope[] = [];
|
||||||
|
for (const declaration of declarations) {
|
||||||
|
const ids = uniqueSorted(await this.customerIdsFor(declaration, auth));
|
||||||
|
for (const id of ids) {
|
||||||
|
union.add(id);
|
||||||
|
}
|
||||||
|
resolved.push({
|
||||||
|
type: declaration.type,
|
||||||
|
...(declaration.targetIds.length > 0 ? { targetIds: declaration.targetIds } : {}),
|
||||||
|
customerIds: ids,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: auth.userId,
|
||||||
|
isSuperAdmin: false,
|
||||||
|
dataScopes: resolved,
|
||||||
|
customerIds: [...union].sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async customerIdsFor(
|
||||||
|
declaration: ScopeDeclaration,
|
||||||
|
auth: Pick<AuthContext, 'userId' | 'departmentIds'>,
|
||||||
|
): Promise<string[]> {
|
||||||
|
switch (declaration.type) {
|
||||||
|
case 'all':
|
||||||
|
return [];
|
||||||
|
case 'customer_groups':
|
||||||
|
return this.customers.findCustomerGroupCustomerIds(declaration.targetIds);
|
||||||
|
case 'owned_customers':
|
||||||
|
return this.customers.findOwnedCustomerIds(auth.userId);
|
||||||
|
case 'created_by_me':
|
||||||
|
return this.customers.findCreatedCustomerIds(auth.userId);
|
||||||
|
case 'department': {
|
||||||
|
const ownDepartmentId = auth.departmentIds[0];
|
||||||
|
return ownDepartmentId === undefined
|
||||||
|
? []
|
||||||
|
: this.customers.findDepartmentCustomerIds([ownDepartmentId]);
|
||||||
|
}
|
||||||
|
case 'department_and_children':
|
||||||
|
return this.customers.findDepartmentCustomerIds(auth.departmentIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AppException } from '../../errors/app-exception';
|
||||||
|
import { CUSTOMER_DIRECTORY, type CustomerDirectory } from './customer-directory';
|
||||||
|
import {
|
||||||
|
assertCustomerAccess,
|
||||||
|
customerScopeWhere,
|
||||||
|
isAllCustomers,
|
||||||
|
isCustomerVisible,
|
||||||
|
requireScopeContext,
|
||||||
|
type ScopeContext,
|
||||||
|
} from './scope-context';
|
||||||
|
import { ScopeResolver } from './scope-resolver.service';
|
||||||
|
|
||||||
|
function directoryMock(): {
|
||||||
|
directory: CustomerDirectory;
|
||||||
|
owned: ReturnType<typeof vi.fn>;
|
||||||
|
groups: ReturnType<typeof vi.fn>;
|
||||||
|
departments: ReturnType<typeof vi.fn>;
|
||||||
|
created: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
const owned = vi.fn().mockResolvedValue(['c-owned']);
|
||||||
|
const groups = vi.fn().mockResolvedValue(['c-group-1']);
|
||||||
|
const departments = vi.fn().mockResolvedValue(['c-dept']);
|
||||||
|
const created = vi.fn().mockResolvedValue(['c-created']);
|
||||||
|
return {
|
||||||
|
directory: {
|
||||||
|
findOwnedCustomerIds: owned,
|
||||||
|
findCustomerGroupCustomerIds: groups,
|
||||||
|
findDepartmentCustomerIds: departments,
|
||||||
|
findCreatedCustomerIds: created,
|
||||||
|
},
|
||||||
|
owned,
|
||||||
|
groups,
|
||||||
|
departments,
|
||||||
|
created,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextOf(overrides: Partial<ScopeContext> = {}): ScopeContext {
|
||||||
|
return {
|
||||||
|
userId: 'u1',
|
||||||
|
isSuperAdmin: false,
|
||||||
|
dataScopes: [],
|
||||||
|
customerIds: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||||
|
return promise.then(
|
||||||
|
() => expect.unreachable('应抛出异常'),
|
||||||
|
(error: unknown) => {
|
||||||
|
expect(error).toBeInstanceOf(AppException);
|
||||||
|
expect((error as AppException).code).toBe(code);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScopeResolver', () => {
|
||||||
|
const baseAuth = {
|
||||||
|
userId: 'u1',
|
||||||
|
isSuperAdmin: false,
|
||||||
|
dataScopes: [],
|
||||||
|
departmentIds: ['d1', 'd1-child'],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('超管隐式全集:[{ type: all }],不调用客户目录', async () => {
|
||||||
|
const { directory, owned } = directoryMock();
|
||||||
|
const resolver = new ScopeResolver(directory);
|
||||||
|
const scope = await resolver.resolve({ ...baseAuth, isSuperAdmin: true });
|
||||||
|
expect(scope.customerIds).toBeNull();
|
||||||
|
expect(scope.dataScopes).toEqual([{ type: 'all' }]);
|
||||||
|
expect(owned).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('任一角色为全部客户 → 并集为全部客户', async () => {
|
||||||
|
const { directory } = directoryMock();
|
||||||
|
const resolver = new ScopeResolver(directory);
|
||||||
|
const scope = await resolver.resolve({
|
||||||
|
...baseAuth,
|
||||||
|
dataScopes: [{ type: 'owned_customers' }, { type: 'all' }],
|
||||||
|
});
|
||||||
|
expect(scope.customerIds).toBeNull();
|
||||||
|
expect(scope.dataScopes).toEqual([{ type: 'all' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('多角色并集:同一类型 targetIds 合并、customerIds 去重排序、按六类固定顺序输出', async () => {
|
||||||
|
const { directory, groups, departments } = directoryMock();
|
||||||
|
departments.mockResolvedValue(['c-dept', 'c-dept-2']);
|
||||||
|
groups.mockResolvedValue(['c-group-1', 'c-group-2']);
|
||||||
|
const resolver = new ScopeResolver(directory);
|
||||||
|
const scope = await resolver.resolve({
|
||||||
|
...baseAuth,
|
||||||
|
dataScopes: [
|
||||||
|
{ type: 'customer_groups', targetIds: ['g2'] },
|
||||||
|
{ type: 'department_and_children' },
|
||||||
|
{ type: 'customer_groups', targetIds: ['g1'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(scope.customerIds).toEqual(['c-dept', 'c-dept-2', 'c-group-1', 'c-group-2']);
|
||||||
|
expect(scope.dataScopes).toEqual([
|
||||||
|
{ type: 'department_and_children', customerIds: ['c-dept', 'c-dept-2'] },
|
||||||
|
{
|
||||||
|
type: 'customer_groups',
|
||||||
|
targetIds: ['g1', 'g2'],
|
||||||
|
customerIds: ['c-group-1', 'c-group-2'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(departments).toHaveBeenCalledWith(['d1', 'd1-child']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('「本部门」只用用户本部门;「指定客户组」透传 targetIds;「负责客户」「本人创建」按用户解析', async () => {
|
||||||
|
const { directory, groups, departments, owned, created } = directoryMock();
|
||||||
|
const resolver = new ScopeResolver(directory);
|
||||||
|
const scope = await resolver.resolve({
|
||||||
|
...baseAuth,
|
||||||
|
dataScopes: [{ type: 'department' }, { type: 'customer_groups', targetIds: ['g1'] }],
|
||||||
|
});
|
||||||
|
expect(departments).toHaveBeenCalledWith(['d1']);
|
||||||
|
expect(groups).toHaveBeenCalledWith(['g1']);
|
||||||
|
expect(scope.dataScopes[0]?.type).toBe('department');
|
||||||
|
|
||||||
|
await resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'owned_customers' }] });
|
||||||
|
expect(owned).toHaveBeenCalledWith('u1');
|
||||||
|
created.mockResolvedValue([]);
|
||||||
|
await resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'created_by_me' }] });
|
||||||
|
expect(created).toHaveBeenCalledWith('u1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没有任何范围声明 → 按最小权限「本人创建」解析,不分全量', async () => {
|
||||||
|
const { directory } = directoryMock();
|
||||||
|
const resolver = new ScopeResolver(directory);
|
||||||
|
const scope = await resolver.resolve({ ...baseAuth, dataScopes: [] });
|
||||||
|
expect(scope.customerIds).toEqual(['c-created']);
|
||||||
|
expect(scope.dataScopes[0]?.type).toBe('created_by_me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('客户目录端口异常 → 40302,不降级为全部客户', async () => {
|
||||||
|
const { directory, owned } = directoryMock();
|
||||||
|
owned.mockRejectedValueOnce(new Error('db down'));
|
||||||
|
const resolver = new ScopeResolver(directory);
|
||||||
|
await expectRejected(
|
||||||
|
resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'owned_customers' }] }),
|
||||||
|
ErrorCode.OUT_OF_SCOPE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scope-context 帮助函数', () => {
|
||||||
|
it('customerScopeWhere:全部客户返回空片段,受限范围返回 customerId IN', () => {
|
||||||
|
expect(customerScopeWhere(contextOf({ customerIds: null }))).toEqual({});
|
||||||
|
expect(customerScopeWhere(contextOf({ customerIds: ['c1', 'c2'] }))).toEqual({
|
||||||
|
customerId: { in: ['c1', 'c2'] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isAllCustomers / isCustomerVisible', () => {
|
||||||
|
expect(isAllCustomers(contextOf({ customerIds: null }))).toBe(true);
|
||||||
|
expect(isAllCustomers(contextOf())).toBe(false);
|
||||||
|
expect(isCustomerVisible(contextOf({ customerIds: null }), 'any')).toBe(true);
|
||||||
|
expect(isCustomerVisible(contextOf({ customerIds: ['c1'] }), 'c1')).toBe(true);
|
||||||
|
expect(isCustomerVisible(contextOf({ customerIds: ['c1'] }), 'c2')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assertCustomerAccess:范围外抛 40302;requireScopeContext 缺上下文抛 40302', () => {
|
||||||
|
expect(() => {
|
||||||
|
assertCustomerAccess(contextOf({ customerIds: null }), 'c1');
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(() => {
|
||||||
|
assertCustomerAccess(contextOf({ customerIds: ['c1'] }), 'c1');
|
||||||
|
}).not.toThrow();
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
assertCustomerAccess(contextOf({ customerIds: ['c1'] }), 'c2');
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
expect(thrown).toBeInstanceOf(AppException);
|
||||||
|
expect((thrown as AppException).code).toBe(ErrorCode.OUT_OF_SCOPE);
|
||||||
|
expect(() => requireScopeContext(null)).toThrow(AppException);
|
||||||
|
expect(requireScopeContext(contextOf()).userId).toBe('u1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CUSTOMER_DIRECTORY 端口 token 唯一', () => {
|
||||||
|
expect(typeof CUSTOMER_DIRECTORY).toBe('symbol');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,14 +3,19 @@ import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
|||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
import { AuditInterceptor } from './audit.interceptor';
|
import { AuditInterceptor } from './audit.interceptor';
|
||||||
|
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 { ScopeResolver } from './scope/scope-resolver.service';
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、JWT 与守卫链。
|
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、数据范围解析、JWT 与守卫链。
|
||||||
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
|
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
|
||||||
|
*
|
||||||
|
* `CUSTOMER_DIRECTORY` 由全局 ClientModule 提供真实实现(A3);
|
||||||
|
* ScopeResolver / 守卫链只依赖端口,不感知客户模块。
|
||||||
*/
|
*/
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -18,10 +23,12 @@ import { TokenService } from './token.service';
|
|||||||
providers: [
|
providers: [
|
||||||
RequestContextService,
|
RequestContextService,
|
||||||
PermissionService,
|
PermissionService,
|
||||||
|
PermissionCatalogService,
|
||||||
|
PermissionGuard,
|
||||||
|
ScopeResolver,
|
||||||
TokenService,
|
TokenService,
|
||||||
AuditInterceptor,
|
AuditInterceptor,
|
||||||
JwtAuthGuard,
|
JwtAuthGuard,
|
||||||
PermissionGuard,
|
|
||||||
SpecialPermissionGuard,
|
SpecialPermissionGuard,
|
||||||
ScopeGuard,
|
ScopeGuard,
|
||||||
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
|
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
|
||||||
@@ -30,6 +37,12 @@ import { TokenService } from './token.service';
|
|||||||
{ provide: APP_GUARD, useExisting: ScopeGuard },
|
{ provide: APP_GUARD, useExisting: ScopeGuard },
|
||||||
{ provide: APP_INTERCEPTOR, useExisting: AuditInterceptor },
|
{ provide: APP_INTERCEPTOR, useExisting: AuditInterceptor },
|
||||||
],
|
],
|
||||||
exports: [RequestContextService, PermissionService, TokenService],
|
exports: [
|
||||||
|
RequestContextService,
|
||||||
|
PermissionService,
|
||||||
|
PermissionCatalogService,
|
||||||
|
ScopeResolver,
|
||||||
|
TokenService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class SecurityModule {}
|
export class SecurityModule {}
|
||||||
@@ -11,8 +11,10 @@ import type { AuthContext, AuthenticatedRequest } from './auth.types';
|
|||||||
import { AUDIT_ACTION_KEY, IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
|
import { AUDIT_ACTION_KEY, IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
|
||||||
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
|
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
|
||||||
import type { UserAuthRecord } from './permission.service';
|
import type { UserAuthRecord } from './permission.service';
|
||||||
import { PermissionService, resolveDataScope } from './permission.service';
|
import { PermissionService } from './permission.service';
|
||||||
import type { RequestContextService } from './request-context';
|
import type { RequestContextService } from './request-context';
|
||||||
|
import type { ScopeContext } from './scope/scope-context';
|
||||||
|
import type { ScopeResolver } from './scope/scope-resolver.service';
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
|
|
||||||
const jwt = new JwtService({});
|
const jwt = new JwtService({});
|
||||||
@@ -41,7 +43,9 @@ function authFixture(overrides: Partial<AuthContext> = {}): AuthContext {
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
roles: [],
|
roles: [],
|
||||||
permissions: ['user:view'],
|
permissions: ['user:view'],
|
||||||
dataScope: { type: 'department_and_children' },
|
dataScopes: [{ type: 'department_and_children' }],
|
||||||
|
departmentIds: ['d1'],
|
||||||
|
isSuperAdmin: false,
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -110,6 +114,7 @@ function authRecord(
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
children: [],
|
||||||
},
|
},
|
||||||
roles: roleCodes.map((code, index) => ({
|
roles: roleCodes.map((code, index) => ({
|
||||||
userId: 'u1',
|
userId: 'u1',
|
||||||
@@ -128,6 +133,7 @@ function authRecord(
|
|||||||
{ roleId: `r${String(index)}`, permissionCode: 'user:view', createdAt: new Date() },
|
{ roleId: `r${String(index)}`, permissionCode: 'user:view', createdAt: new Date() },
|
||||||
{ roleId: `r${String(index)}`, permissionCode: 'user:manage', createdAt: new Date() },
|
{ roleId: `r${String(index)}`, permissionCode: 'user:manage', createdAt: new Date() },
|
||||||
],
|
],
|
||||||
|
dataScopes: [],
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -170,19 +176,31 @@ describe('TokenService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PermissionService / resolveDataScope', () => {
|
describe('PermissionService', () => {
|
||||||
it('数据范围:平台 / 超管为 all,其余为本部门及下级', () => {
|
|
||||||
expect(resolveDataScope(['super_admin']).type).toBe('all');
|
|
||||||
expect(resolveDataScope(['designer']).type).toBe('department_and_children');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('鉴权上下文:过滤停用角色、权限去重排序、状态转小写', () => {
|
it('鉴权上下文:过滤停用角色、权限去重排序、状态转小写', () => {
|
||||||
const service = new PermissionService({} as never);
|
const service = new PermissionService({} as never);
|
||||||
const context = service.toAuthContext(authRecord());
|
const context = service.toAuthContext(authRecord());
|
||||||
expect(context.permissions).toEqual(['user:manage', 'user:view']);
|
expect(context.permissions).toEqual(['user:manage', 'user:view']);
|
||||||
expect(context.status).toBe('active');
|
expect(context.status).toBe('active');
|
||||||
expect(context.roles).toHaveLength(1);
|
expect(context.roles).toHaveLength(1);
|
||||||
expect(context.dataScope.type).toBe('all');
|
expect(context.isSuperAdmin).toBe(false);
|
||||||
|
expect(context.dataScopes).toEqual([]);
|
||||||
|
expect(context.departmentIds).toEqual(['d1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超管隐式全集:权限点 / 特殊权限不依赖 role_permissions', () => {
|
||||||
|
const record = authRecord('ACTIVE', ['super_admin']);
|
||||||
|
const role = record.roles[0];
|
||||||
|
if (role === undefined) {
|
||||||
|
throw new Error('fixture 缺少角色');
|
||||||
|
}
|
||||||
|
role.role.permissions = [];
|
||||||
|
const service = new PermissionService({} as never);
|
||||||
|
const context = service.toAuthContext(record);
|
||||||
|
expect(context.isSuperAdmin).toBe(true);
|
||||||
|
expect(context.permissions).toHaveLength(30);
|
||||||
|
expect(context.permissions).toContain('audit:export');
|
||||||
|
expect(context.dataScopes).toEqual([{ type: 'all' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolveAuth 用户不存在返回 null', async () => {
|
it('resolveAuth 用户不存在返回 null', async () => {
|
||||||
@@ -198,7 +216,7 @@ describe('PermissionService / resolveDataScope', () => {
|
|||||||
const service = new PermissionService(prisma as never);
|
const service = new PermissionService(prisma as never);
|
||||||
const context = await service.resolveAuth('u1');
|
const context = await service.resolveAuth('u1');
|
||||||
expect(context?.userId).toBe('u1');
|
expect(context?.userId).toBe('u1');
|
||||||
expect(context?.dataScope.type).toBe('department_and_children');
|
expect(context?.isSuperAdmin).toBe(false);
|
||||||
expect(context?.roles[0]?.code).toBe('designer');
|
expect(context?.roles[0]?.code).toBe('designer');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -299,11 +317,26 @@ describe('PermissionGuard / SpecialPermissionGuard / ScopeGuard', () => {
|
|||||||
expect(granted.canActivate(contextOf(request))).toBe(true);
|
expect(granted.canActivate(contextOf(request))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('未登录时普通守卫返回 40101;ScopeGuard 骨架放行', () => {
|
it('未登录时普通守卫返回 40101;ScopeGuard 解析并注入 scope', async () => {
|
||||||
const guard = new PermissionGuard(reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never);
|
const guard = new PermissionGuard(reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never);
|
||||||
expectCode(() => guard.canActivate(contextOf({})), ErrorCode.UNAUTHENTICATED);
|
expectCode(() => guard.canActivate(contextOf({})), ErrorCode.UNAUTHENTICATED);
|
||||||
expect(new ScopeGuard().canActivate(contextOf(request))).toBe(true);
|
|
||||||
expect(new ScopeGuard().canActivate(contextOf({}))).toBe(true);
|
const scope: ScopeContext = {
|
||||||
|
userId: 'u1',
|
||||||
|
isSuperAdmin: false,
|
||||||
|
dataScopes: [{ type: 'owned_customers', customerIds: [] }],
|
||||||
|
customerIds: [],
|
||||||
|
};
|
||||||
|
const resolver = { resolve: vi.fn().mockResolvedValue(scope) } as unknown as ScopeResolver;
|
||||||
|
const scopeGuard = new ScopeGuard(reflectorFor({}) as never, resolver, {
|
||||||
|
patch: vi.fn(),
|
||||||
|
} as unknown as RequestContextService);
|
||||||
|
const scopedRequest = { auth } as AuthenticatedRequest;
|
||||||
|
await expect(scopeGuard.canActivate(contextOf(scopedRequest))).resolves.toBe(true);
|
||||||
|
expect(scopedRequest.scope).toEqual(scope);
|
||||||
|
|
||||||
|
// 未登录(无 auth)直接放行,由 JwtAuthGuard 负责 401
|
||||||
|
await expect(scopeGuard.canActivate(contextOf({}))).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('@Audit 元数据可读取', () => {
|
it('@Audit 元数据可读取', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { describe, expect, it, type Mock, vi } from 'vitest';
|
|||||||
|
|
||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
import type { PermissionService } from '../../common/security/permission.service';
|
import type { PermissionService } from '../../common/security/permission.service';
|
||||||
|
import type { ScopeResolver } from '../../common/security/scope/scope-resolver.service';
|
||||||
import { TokenService } from '../../common/security/token.service';
|
import { TokenService } from '../../common/security/token.service';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { testConfig } from '../../testing/test-app';
|
import { testConfig } from '../../testing/test-app';
|
||||||
@@ -102,16 +103,19 @@ function harness(): Harness {
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
roles: [],
|
roles: [],
|
||||||
permissions: ['user:manage'],
|
permissions: ['user:manage'],
|
||||||
dataScope: { type: 'all' },
|
dataScopes: [{ type: 'all' }],
|
||||||
|
departmentIds: ['d1'],
|
||||||
|
isSuperAdmin: true,
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
const scopes = { resolve: vi.fn().mockResolvedValue({ dataScopes: [{ type: 'all' }] }) };
|
||||||
const me = {
|
const me = {
|
||||||
getMe: vi.fn().mockResolvedValue({
|
getMe: vi.fn().mockResolvedValue({
|
||||||
user: { id: 'u1' },
|
user: { id: 'u1' },
|
||||||
roles: [],
|
roles: [],
|
||||||
permissions: [],
|
permissions: [],
|
||||||
dataScope: { type: 'all' },
|
dataScopes: [{ type: 'all' }],
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -122,6 +126,7 @@ function harness(): Harness {
|
|||||||
passwords,
|
passwords,
|
||||||
failures as unknown as LoginFailureService,
|
failures as unknown as LoginFailureService,
|
||||||
permissions as unknown as PermissionService,
|
permissions as unknown as PermissionService,
|
||||||
|
scopes as unknown as ScopeResolver,
|
||||||
me as unknown as MeService,
|
me as unknown as MeService,
|
||||||
);
|
);
|
||||||
return { service, prisma, failures, passwords, permissions, me };
|
return { service, prisma, failures, passwords, permissions, me };
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { RefreshToken, User } from '@prisma/client';
|
|||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
import type { AuthRequestMeta } from '../../common/security/auth.types';
|
import type { AuthRequestMeta } from '../../common/security/auth.types';
|
||||||
import { PermissionService } from '../../common/security/permission.service';
|
import { PermissionService } from '../../common/security/permission.service';
|
||||||
|
import { ScopeResolver } from '../../common/security/scope/scope-resolver.service';
|
||||||
import { TokenService } from '../../common/security/token.service';
|
import { TokenService } from '../../common/security/token.service';
|
||||||
import { APP_CONFIG } from '../../config/config.module';
|
import { APP_CONFIG } from '../../config/config.module';
|
||||||
import type { AppConfig } from '../../config/env';
|
import type { AppConfig } from '../../config/env';
|
||||||
@@ -33,6 +34,7 @@ export class AuthService {
|
|||||||
@Inject(PasswordService) private readonly passwords: PasswordService,
|
@Inject(PasswordService) private readonly passwords: PasswordService,
|
||||||
@Inject(LoginFailureService) private readonly failures: LoginFailureService,
|
@Inject(LoginFailureService) private readonly failures: LoginFailureService,
|
||||||
@Inject(PermissionService) private readonly permissions: PermissionService,
|
@Inject(PermissionService) private readonly permissions: PermissionService,
|
||||||
|
@Inject(ScopeResolver) private readonly scopes: ScopeResolver,
|
||||||
@Inject(MeService) private readonly me: MeService,
|
@Inject(MeService) private readonly me: MeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -61,8 +63,12 @@ export class AuthService {
|
|||||||
data: { lastLoginAt: new Date(), lastLoginIp: meta.ip },
|
data: { lastLoginAt: new Date(), lastLoginIp: meta.ip },
|
||||||
});
|
});
|
||||||
const auth = await this.permissions.resolveAuth(user.id);
|
const auth = await this.permissions.resolveAuth(user.id);
|
||||||
|
if (auth === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' });
|
||||||
|
}
|
||||||
|
const scope = await this.scopes.resolve(auth);
|
||||||
const { pair } = await this.createTokenPair(user.id, null, meta);
|
const { pair } = await this.createTokenPair(user.id, null, meta);
|
||||||
const me = await this.me.getMe(user.id, auth?.permissions ?? []);
|
const me = await this.me.getMe(user.id, auth.permissions, scope);
|
||||||
return { ...pair, me };
|
return { ...pair, me };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: '上级部门已停用,请先启用上级部门',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -19,11 +19,18 @@ interface FixtureUser {
|
|||||||
account: string;
|
account: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MeBody {
|
||||||
|
permissions: string[];
|
||||||
|
dataScopes: { type: string; customerIds?: string[] }[];
|
||||||
|
user: { id: string; phone?: string };
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface TokenBody {
|
interface TokenBody {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
expiresIn: number;
|
expiresIn: number;
|
||||||
me: { permissions: string[]; user: { id: string; phone?: string }; mustChangePassword: boolean };
|
me: MeBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorBody {
|
interface ErrorBody {
|
||||||
@@ -88,6 +95,87 @@ async function setRole(userId: string, roleCode: string): Promise<void> {
|
|||||||
await prisma.userRole.create({ data: { userId, roleId: role.id } });
|
await prisma.userRole.create({ data: { userId, roleId: role.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface JsonRequestResult<T> {
|
||||||
|
status: number;
|
||||||
|
body: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callJson<T>(
|
||||||
|
app: CreatedApiApp['app'],
|
||||||
|
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
payload?: unknown,
|
||||||
|
): Promise<JsonRequestResult<T>> {
|
||||||
|
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<T>() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_NAME_PREFIX = 'IT-角色-';
|
||||||
|
|
||||||
|
/** 集成测试创建的角色 id:afterAll 清理 outbox 占位事件,避免污染本地库 */
|
||||||
|
const createdRoleIds: string[] = [];
|
||||||
|
|
||||||
|
/** 集成测试专用角色(名称前缀隔离,afterAll 清理;删除 cascades user_roles / role_permissions) */
|
||||||
|
async function createTestRole(input: {
|
||||||
|
name: string;
|
||||||
|
permissions?: string[];
|
||||||
|
isSystem?: boolean;
|
||||||
|
dataScope?: { type: string; targetIds?: string[] };
|
||||||
|
status?: 'ACTIVE' | 'DISABLED';
|
||||||
|
}): Promise<{ id: string; name: string }> {
|
||||||
|
const id = ulid();
|
||||||
|
const role = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
code: `it_role_${id.toLowerCase()}`,
|
||||||
|
name: `${ROLE_NAME_PREFIX}${input.name}`,
|
||||||
|
description: '集成测试角色',
|
||||||
|
isSystem: input.isSystem ?? false,
|
||||||
|
status: input.status ?? 'ACTIVE',
|
||||||
|
permissions: {
|
||||||
|
create: (input.permissions ?? []).map((permissionCode) => ({ permissionCode })),
|
||||||
|
},
|
||||||
|
dataScopes: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
id: ulid(),
|
||||||
|
scopeType: input.dataScope?.type ?? 'created_by_me',
|
||||||
|
targetId: input.dataScope?.targetIds?.[0] ?? null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdRoleIds.push(role.id);
|
||||||
|
return { id: role.id, name: role.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFixtureUserWithRoleId(suffix: string, roleId: string): Promise<FixtureUser> {
|
||||||
|
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||||||
|
const account = `${ACCOUNT_PREFIX}${suffix}`;
|
||||||
|
const id = ulid();
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
name: `集成测试${suffix}`,
|
||||||
|
account,
|
||||||
|
email: `${account}@lingxi.cn`,
|
||||||
|
phone: `138${String(Date.now()).slice(-8)}`,
|
||||||
|
passwordHash: PASSWORD_HASH,
|
||||||
|
departmentId: department.id,
|
||||||
|
mustChangePassword: false,
|
||||||
|
roles: { create: [{ roleId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id, account };
|
||||||
|
}
|
||||||
|
|
||||||
describe('identity 集成(真实 PG / Redis)', () => {
|
describe('identity 集成(真实 PG / Redis)', () => {
|
||||||
let created: CreatedApiApp;
|
let created: CreatedApiApp;
|
||||||
let manager: FixtureUser;
|
let manager: FixtureUser;
|
||||||
@@ -98,12 +186,17 @@ describe('identity 集成(真实 PG / Redis)', () => {
|
|||||||
created = await createApp();
|
created = await createApp();
|
||||||
await created.app.init();
|
await created.app.init();
|
||||||
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
||||||
|
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
|
||||||
manager = await createFixtureUser('manager', 'platform_admin');
|
manager = await createFixtureUser('manager', 'platform_admin');
|
||||||
viewer = await createFixtureUser('viewer', 'designer');
|
viewer = await createFixtureUser('viewer', 'designer');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
||||||
|
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
|
||||||
|
if (createdRoleIds.length > 0) {
|
||||||
|
await prisma.outboxEvent.deleteMany({ where: { aggregateId: { in: createdRoleIds } } });
|
||||||
|
}
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await created.app.close();
|
await created.app.close();
|
||||||
});
|
});
|
||||||
@@ -225,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',
|
||||||
@@ -235,4 +330,318 @@ describe('identity 集成(真实 PG / Redis)', () => {
|
|||||||
expect(roles.statusCode).toBe(200);
|
expect(roles.statusCode).toBe(200);
|
||||||
expect(roles.json<{ items: unknown[] }>().items.length).toBeGreaterThanOrEqual(8);
|
expect(roles.json<{ items: unknown[] }>().items.length).toBeGreaterThanOrEqual(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// A2:角色权限、权限树、SoD、数据范围、停用即时生效
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD;无 role:view 返回 40301', async () => {
|
||||||
|
const { body } = await login(created.app, manager.account);
|
||||||
|
const response = await callJson<{
|
||||||
|
modules: { module: string; moduleLabel: string }[];
|
||||||
|
specialPermissions: { code: string }[];
|
||||||
|
sodPairs: { id: string }[];
|
||||||
|
totalPermissions: number;
|
||||||
|
}>(created.app, 'GET', '/permissions', body.accessToken);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.totalPermissions).toBe(30);
|
||||||
|
expect(response.body.modules.map((module) => module.module)).toEqual([
|
||||||
|
'script',
|
||||||
|
'material',
|
||||||
|
'asset',
|
||||||
|
'delivery',
|
||||||
|
'customer',
|
||||||
|
'customer_group',
|
||||||
|
'identity',
|
||||||
|
'industry',
|
||||||
|
'platform',
|
||||||
|
'audit',
|
||||||
|
]);
|
||||||
|
expect(response.body.modules.map((module) => module.moduleLabel)).toContain('客户组');
|
||||||
|
expect(response.body.specialPermissions).toHaveLength(6);
|
||||||
|
expect(response.body.sodPairs).toHaveLength(3);
|
||||||
|
|
||||||
|
// 三态之一:无 role:view(设计师)→ 40301(用独立 fixture,避免受前面停用用例影响)
|
||||||
|
const designerRole = await prisma.role.findUniqueOrThrow({ where: { code: 'designer' } });
|
||||||
|
const designerOnly = await createFixtureUserWithRoleId('permissions-denied', designerRole.id);
|
||||||
|
const designerLogin = await login(created.app, designerOnly.account);
|
||||||
|
const denied = await callJson<{ code: number }>(
|
||||||
|
created.app,
|
||||||
|
'GET',
|
||||||
|
'/permissions',
|
||||||
|
designerLogin.body.accessToken,
|
||||||
|
);
|
||||||
|
expect(denied.status).toBe(403);
|
||||||
|
expect(denied.body.code).toBe(ErrorCode.PERMISSION_DENIED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /roles:默认只返回启用角色(A1 多选兼容),字段含统计与 dataScope', async () => {
|
||||||
|
const { body } = await login(created.app, manager.account);
|
||||||
|
const response = await callJson<{
|
||||||
|
items: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
isSystem: boolean;
|
||||||
|
permissionCount: number;
|
||||||
|
specialPermissionCount: number;
|
||||||
|
userCount: number;
|
||||||
|
dataScope: { type: string };
|
||||||
|
}[];
|
||||||
|
}>(created.app, 'GET', '/roles', body.accessToken);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const codes = response.body.items.map((item) => item.code);
|
||||||
|
expect(codes).not.toContain('review_lead');
|
||||||
|
expect(codes).toContain('material_operator');
|
||||||
|
const superAdmin = response.body.items.find((item) => item.code === 'super_admin');
|
||||||
|
expect(superAdmin?.isSystem).toBe(true);
|
||||||
|
expect(superAdmin?.permissionCount).toBe(24);
|
||||||
|
expect(superAdmin?.specialPermissionCount).toBe(6);
|
||||||
|
expect(superAdmin?.dataScope).toEqual({ type: 'all' });
|
||||||
|
|
||||||
|
const all = await callJson<{ items: { code: string }[] }>(
|
||||||
|
created.app,
|
||||||
|
'GET',
|
||||||
|
'/roles?status=all',
|
||||||
|
body.accessToken,
|
||||||
|
);
|
||||||
|
expect(all.body.items.map((item) => item.code)).toContain('review_lead');
|
||||||
|
|
||||||
|
// A1 兼容:只有 user:view 的角色也能读取角色列表(用于用户管理的角色多选)
|
||||||
|
const onlyUserView = await createTestRole({ name: '用户查看', permissions: ['user:view'] });
|
||||||
|
const selector = await createFixtureUserWithRoleId('role-selector', onlyUserView.id);
|
||||||
|
const selectorLogin = await login(created.app, selector.account);
|
||||||
|
const selectorRoles = await callJson<{ items: { code: string }[] }>(
|
||||||
|
created.app,
|
||||||
|
'GET',
|
||||||
|
'/roles',
|
||||||
|
selectorLogin.body.accessToken,
|
||||||
|
);
|
||||||
|
expect(selectorRoles.status).toBe(200);
|
||||||
|
expect(selectorRoles.body.items.length).toBeGreaterThan(0);
|
||||||
|
const selectorPermissions = await callJson<{ code: number }>(
|
||||||
|
created.app,
|
||||||
|
'GET',
|
||||||
|
'/permissions',
|
||||||
|
selectorLogin.body.accessToken,
|
||||||
|
);
|
||||||
|
expect(selectorPermissions.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /roles:权限默认全空、code 自动生成、数据范围单选;SoD 命中 40304', async () => {
|
||||||
|
const { body } = await login(created.app, manager.account);
|
||||||
|
const createdRole = await callJson<{
|
||||||
|
role: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
permissions: string[];
|
||||||
|
specialPermissions: string[];
|
||||||
|
dataScope: { type: string };
|
||||||
|
boundUsers: unknown[];
|
||||||
|
};
|
||||||
|
affectedUserCount: number;
|
||||||
|
warnings: string[];
|
||||||
|
}>(created.app, 'POST', '/roles', body.accessToken, {
|
||||||
|
name: `${ROLE_NAME_PREFIX}内容运营`,
|
||||||
|
permissions: ['script:read'],
|
||||||
|
dataScope: { type: 'department' },
|
||||||
|
});
|
||||||
|
expect(createdRole.status).toBe(201);
|
||||||
|
createdRoleIds.push(createdRole.body.role.id);
|
||||||
|
expect(createdRole.body.role.code).toMatch(/^custom_[0-9a-z]{26}$/);
|
||||||
|
expect(createdRole.body.role.permissions).toEqual(['script:read']);
|
||||||
|
expect(createdRole.body.role.specialPermissions).toEqual([]);
|
||||||
|
expect(createdRole.body.role.dataScope).toEqual({ type: 'department' });
|
||||||
|
expect(createdRole.body.affectedUserCount).toBe(0);
|
||||||
|
|
||||||
|
const sod = await callJson<{ code: number; details: { conflicts: { pairId: string }[] } }>(
|
||||||
|
created.app,
|
||||||
|
'POST',
|
||||||
|
'/roles',
|
||||||
|
body.accessToken,
|
||||||
|
{
|
||||||
|
name: `${ROLE_NAME_PREFIX}互斥冲突`,
|
||||||
|
permissions: ['script:write', 'script:review'],
|
||||||
|
dataScope: { type: 'all' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(sod.status).toBe(403);
|
||||||
|
expect(sod.body.code).toBe(ErrorCode.SOD_VIOLATION);
|
||||||
|
expect(sod.body.details.conflicts[0]?.pairId).toBe('script-write-review');
|
||||||
|
|
||||||
|
const specialInNormal = await callJson<{ code: number }>(
|
||||||
|
created.app,
|
||||||
|
'POST',
|
||||||
|
'/roles',
|
||||||
|
body.accessToken,
|
||||||
|
{
|
||||||
|
name: `${ROLE_NAME_PREFIX}特殊权限错位`,
|
||||||
|
permissions: ['delivery:export'],
|
||||||
|
dataScope: { type: 'all' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(specialInNormal.status).toBe(400);
|
||||||
|
expect(specialInNormal.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH /roles:保存权限返回影响用户数、写 outbox 审计事件;系统角色仅显示名可改', async () => {
|
||||||
|
const { body } = await login(created.app, manager.account);
|
||||||
|
const role = await createTestRole({
|
||||||
|
name: '待编辑',
|
||||||
|
permissions: ['script:read'],
|
||||||
|
dataScope: { type: 'department' },
|
||||||
|
});
|
||||||
|
const boundUser = await createFixtureUserWithRoleId('role-edit', role.id);
|
||||||
|
|
||||||
|
const patched = await callJson<{
|
||||||
|
role: {
|
||||||
|
permissions: string[];
|
||||||
|
specialPermissions: string[];
|
||||||
|
dataScope: { type: string };
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
affectedUserCount: number;
|
||||||
|
warnings: string[];
|
||||||
|
}>(created.app, 'PATCH', `/roles/${role.id}`, body.accessToken, {
|
||||||
|
permissions: ['script:read', 'user:view'],
|
||||||
|
specialPermissions: ['delivery:export'],
|
||||||
|
dataScope: { type: 'owned_customers' },
|
||||||
|
});
|
||||||
|
expect(patched.status).toBe(200);
|
||||||
|
expect(patched.body.role.permissions).toEqual(['script:read', 'user:view']);
|
||||||
|
expect(patched.body.role.specialPermissions).toEqual(['delivery:export']);
|
||||||
|
expect(patched.body.role.dataScope).toEqual({ type: 'owned_customers' });
|
||||||
|
expect(patched.body.affectedUserCount).toBe(1);
|
||||||
|
|
||||||
|
const outbox = await prisma.outboxEvent.findFirst({
|
||||||
|
where: { aggregateId: role.id, eventType: 'role.permissions.changed' },
|
||||||
|
orderBy: { occurredAt: 'desc' },
|
||||||
|
});
|
||||||
|
expect(outbox).not.toBeNull();
|
||||||
|
const payload = outbox?.payloadJson as {
|
||||||
|
roleId: string;
|
||||||
|
affectedUserIds: string[];
|
||||||
|
changes: { permissions: { added: string[]; removed: string[] } };
|
||||||
|
};
|
||||||
|
expect(payload.roleId).toBe(role.id);
|
||||||
|
expect(payload.affectedUserIds).toEqual([boundUser.id]);
|
||||||
|
expect(payload.changes.permissions.added).toEqual(['user:view']);
|
||||||
|
|
||||||
|
// 系统角色保护:改权限 40903,仅显示名可改
|
||||||
|
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
|
||||||
|
const protectedUpdate = await callJson<{ code: number }>(
|
||||||
|
created.app,
|
||||||
|
'PATCH',
|
||||||
|
`/roles/${superAdminRole.id}`,
|
||||||
|
body.accessToken,
|
||||||
|
{ permissions: ['user:view'] },
|
||||||
|
);
|
||||||
|
expect(protectedUpdate.status).toBe(409);
|
||||||
|
expect(protectedUpdate.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||||
|
const renamed = await callJson<{ role: { name: string } }>(
|
||||||
|
created.app,
|
||||||
|
'PATCH',
|
||||||
|
`/roles/${superAdminRole.id}`,
|
||||||
|
body.accessToken,
|
||||||
|
{ name: '超级管理员' },
|
||||||
|
);
|
||||||
|
expect(renamed.status).toBe(200);
|
||||||
|
expect(renamed.body.role.name).toBe('超级管理员');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /roles:系统角色 / 有绑定用户拒绝(40903),无绑定用户删除成功', async () => {
|
||||||
|
const { body } = await login(created.app, manager.account);
|
||||||
|
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
|
||||||
|
const systemDelete = await callJson<{ code: number }>(
|
||||||
|
created.app,
|
||||||
|
'DELETE',
|
||||||
|
`/roles/${superAdminRole.id}`,
|
||||||
|
body.accessToken,
|
||||||
|
);
|
||||||
|
expect(systemDelete.status).toBe(409);
|
||||||
|
expect(systemDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||||
|
|
||||||
|
const bound = await createTestRole({ name: '有绑定' });
|
||||||
|
await createFixtureUserWithRoleId('role-bound', bound.id);
|
||||||
|
const boundDelete = await callJson<{ code: number; details: { userCount: number } }>(
|
||||||
|
created.app,
|
||||||
|
'DELETE',
|
||||||
|
`/roles/${bound.id}`,
|
||||||
|
body.accessToken,
|
||||||
|
);
|
||||||
|
expect(boundDelete.status).toBe(409);
|
||||||
|
expect(boundDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||||
|
expect(boundDelete.body.details.userCount).toBe(1);
|
||||||
|
|
||||||
|
const free = await createTestRole({ name: '无绑定' });
|
||||||
|
const freeDelete = await created.app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${API}/roles/${free.id}`,
|
||||||
|
headers: { authorization: `Bearer ${body.accessToken}` },
|
||||||
|
});
|
||||||
|
expect(freeDelete.statusCode).toBe(204);
|
||||||
|
const gone = await prisma.role.findUnique({ where: { id: free.id } });
|
||||||
|
expect(gone).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('角色停用后,绑定用户下一次请求立即失效(40301)', async () => {
|
||||||
|
const { body } = await login(created.app, manager.account);
|
||||||
|
const role = await createTestRole({ name: '待停用', permissions: ['user:view'] });
|
||||||
|
const user = await createFixtureUserWithRoleId('role-disable', role.id);
|
||||||
|
const userLogin = await login(created.app, user.account);
|
||||||
|
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
|
||||||
|
|
||||||
|
const disabled = await callJson<{ role: { status: string }; affectedUserCount: number }>(
|
||||||
|
created.app,
|
||||||
|
'PATCH',
|
||||||
|
`/roles/${role.id}`,
|
||||||
|
body.accessToken,
|
||||||
|
{ status: 'disabled', reason: '集成测试停用' },
|
||||||
|
);
|
||||||
|
expect(disabled.status).toBe(200);
|
||||||
|
expect(disabled.body.role.status).toBe('disabled');
|
||||||
|
expect(disabled.body.affectedUserCount).toBe(1);
|
||||||
|
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(403);
|
||||||
|
|
||||||
|
const enabled = await callJson<{ role: { status: string } }>(
|
||||||
|
created.app,
|
||||||
|
'PATCH',
|
||||||
|
`/roles/${role.id}`,
|
||||||
|
body.accessToken,
|
||||||
|
{ status: 'enabled' },
|
||||||
|
);
|
||||||
|
expect(enabled.body.role.status).toBe('active');
|
||||||
|
// 再启用后同一 access token 恢复权限(每请求解析,无缓存)
|
||||||
|
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/me:dataScopes 多角色并集;超管固定 all;A3 接入真实客户目录', async () => {
|
||||||
|
const adminLogin = await login(created.app, manager.account);
|
||||||
|
expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]);
|
||||||
|
const adminMe = await callJson<MeBody>(created.app, 'GET', '/me', adminLogin.body.accessToken);
|
||||||
|
expect(adminMe.body.dataScopes).toEqual([{ type: 'all' }]);
|
||||||
|
|
||||||
|
const ownedRole = await createTestRole({
|
||||||
|
name: '负责客户范围',
|
||||||
|
permissions: ['user:view'],
|
||||||
|
dataScope: { type: 'owned_customers' },
|
||||||
|
});
|
||||||
|
const deptRole = await createTestRole({
|
||||||
|
name: '部门范围',
|
||||||
|
permissions: ['customer:read'],
|
||||||
|
dataScope: { type: 'department' },
|
||||||
|
});
|
||||||
|
const user = await createFixtureUserWithRoleId('me-scopes', ownedRole.id);
|
||||||
|
await prisma.userRole.create({ data: { userId: user.id, roleId: deptRole.id } });
|
||||||
|
const userLogin = await login(created.app, user.account);
|
||||||
|
expect(userLogin.status).toBe(200);
|
||||||
|
const scopes = userLogin.body.me.dataScopes;
|
||||||
|
expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']);
|
||||||
|
for (const scope of scopes) {
|
||||||
|
expect(Array.isArray(scope.customerIds)).toBe(true);
|
||||||
|
}
|
||||||
|
// A3 起 CustomerDirectory 返回真实客户;本用例用户没有负责 / 创建的客户
|
||||||
|
expect(scopes.find((scope) => scope.type === 'owned_customers')?.customerIds).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@ import { LoginFailureService } from './login-failure.service';
|
|||||||
import { MeController } from './me.controller';
|
import { MeController } from './me.controller';
|
||||||
import { MeService } from './me.service';
|
import { MeService } from './me.service';
|
||||||
import { PasswordService } from './password.service';
|
import { PasswordService } from './password.service';
|
||||||
|
import { PermissionsController } from './permissions.controller';
|
||||||
import { RolesController } from './roles.controller';
|
import { RolesController } from './roles.controller';
|
||||||
import { RolesService } from './roles.service';
|
import { RolesService } from './roles.service';
|
||||||
import { UsersController } from './users.controller';
|
import { UsersController } from './users.controller';
|
||||||
@@ -24,6 +25,7 @@ import { UsersService } from './users.service';
|
|||||||
UsersController,
|
UsersController,
|
||||||
DepartmentsController,
|
DepartmentsController,
|
||||||
RolesController,
|
RolesController,
|
||||||
|
PermissionsController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ import { ErrorCode } from '@lingxi/shared';
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import type { UserAuthRecord } from '../../common/security/permission.service';
|
||||||
|
import type { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { DepartmentsService } from './departments.service';
|
import { DepartmentsService } from './departments.service';
|
||||||
import { LoginFailureService } from './login-failure.service';
|
import { LoginFailureService } from './login-failure.service';
|
||||||
import { MeService } from './me.service';
|
import { MeService } from './me.service';
|
||||||
import { PasswordService } from './password.service';
|
import { PasswordService } from './password.service';
|
||||||
import { RolesService } from './roles.service';
|
import { RolesService } from './roles.service';
|
||||||
import { maskPhone, toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
|
import { maskPhone, toUserDetail, toUserSummary } from './user.mapper';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelations {
|
function userFixture(overrides: Partial<UserAuthRecord> = {}): UserAuthRecord {
|
||||||
const base: UserWithRelations = {
|
const base: UserAuthRecord = {
|
||||||
id: 'u1',
|
id: 'u1',
|
||||||
account: 'chen.mo',
|
account: 'chen.mo',
|
||||||
email: 'chen.mo@lingxi.cn',
|
email: 'chen.mo@lingxi.cn',
|
||||||
@@ -51,6 +53,7 @@ function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelati
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
children: [],
|
||||||
},
|
},
|
||||||
roles: [
|
roles: [
|
||||||
{
|
{
|
||||||
@@ -66,6 +69,8 @@ function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelati
|
|||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
permissions: [],
|
||||||
|
dataScopes: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,6 +86,8 @@ function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelati
|
|||||||
status: 'DISABLED',
|
status: 'DISABLED',
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
permissions: [],
|
||||||
|
dataScopes: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -252,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 () => {
|
||||||
@@ -286,114 +291,330 @@ 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' } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MeService / RolesService', () => {
|
describe('MeService / RolesService', () => {
|
||||||
it('/me:手机号按 user:manage 返回,角色与数据范围来自 DB', async () => {
|
const scope = {
|
||||||
|
userId: 'u1',
|
||||||
|
isSuperAdmin: false,
|
||||||
|
dataScopes: [{ type: 'department_and_children' as const, customerIds: [] }],
|
||||||
|
customerIds: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('/me:手机号按 user:manage 返回,dataScopes 来自 ScopeResolver', async () => {
|
||||||
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
|
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
|
||||||
const service = new MeService(prisma as unknown as PrismaService);
|
const service = new MeService(prisma as unknown as PrismaService);
|
||||||
const me = await service.getMe('u1', ['user:manage']);
|
const me = await service.getMe('u1', ['user:manage'], scope);
|
||||||
expect(me.user.phone).toBe('13866202046');
|
expect(me.user.phone).toBe('13866202046');
|
||||||
expect(me.permissions).toEqual(['user:manage']);
|
expect(me.permissions).toEqual(['user:manage']);
|
||||||
expect(me.roles).toHaveLength(1);
|
expect(me.roles).toHaveLength(1);
|
||||||
const meWithout = await service.getMe('u1', []);
|
expect(me.dataScopes).toEqual([{ type: 'department_and_children', customerIds: [] }]);
|
||||||
|
const meWithout = await service.getMe('u1', [], scope);
|
||||||
expect(meWithout.user.phone).toBeUndefined();
|
expect(meWithout.user.phone).toBeUndefined();
|
||||||
await expectRejected(
|
await expectRejected(
|
||||||
new MeService({
|
new MeService({
|
||||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||||
} as unknown as PrismaService).getMe('u1', []),
|
} as unknown as PrismaService).getMe('u1', [], scope),
|
||||||
ErrorCode.UNAUTHENTICATED,
|
ErrorCode.UNAUTHENTICATED,
|
||||||
);
|
);
|
||||||
|
await expectRejected(
|
||||||
|
new MeService(prisma as unknown as PrismaService).getMe('u1', [], null),
|
||||||
|
ErrorCode.OUT_OF_SCOPE,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('角色列表只返回启用角色', async () => {
|
it('角色列表:聚合权限点统计、数据范围与用户数(默认只查启用角色)', async () => {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
role: {
|
role: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 'r1',
|
id: 'r1',
|
||||||
code: 'super_admin',
|
code: 'reviewer',
|
||||||
name: '超级管理员',
|
name: '审核员',
|
||||||
description: '内置',
|
description: '审核',
|
||||||
isSystem: true,
|
isSystem: false,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
createdAt: new Date(),
|
createdAt: new Date('2026-11-01T00:00:00.000Z'),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date('2026-11-02T00:00:00.000Z'),
|
||||||
|
permissions: [
|
||||||
|
{ permissionCode: 'script:read' },
|
||||||
|
{ permissionCode: 'script:review' },
|
||||||
|
{ permissionCode: 'delivery:export' },
|
||||||
|
],
|
||||||
|
dataScopes: [{ scopeType: 'department_and_children', targetId: null }],
|
||||||
|
_count: { users: 3 },
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const roles = await new RolesService(prisma as unknown as PrismaService).list();
|
const service = new RolesService(
|
||||||
expect(roles).toEqual([
|
prisma as unknown as PrismaService,
|
||||||
{ id: 'r1', code: 'super_admin', name: '超级管理员', isSystem: true, description: '内置' },
|
{ get: () => null, patchAudit: vi.fn() } as unknown as RequestContextService,
|
||||||
]);
|
);
|
||||||
|
const result = await service.list({
|
||||||
|
status: 'active',
|
||||||
|
sort: 'created_at',
|
||||||
|
order: 'desc',
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
expect(result.next_cursor).toBeNull();
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
id: 'r1',
|
||||||
|
code: 'reviewer',
|
||||||
|
status: 'active',
|
||||||
|
permissionCount: 2,
|
||||||
|
specialPermissionCount: 1,
|
||||||
|
userCount: 3,
|
||||||
|
dataScope: { type: 'department_and_children' },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -4,10 +4,11 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|||||||
|
|
||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
import type { AuthContext } from '../../common/security/auth.types';
|
import type { AuthContext } from '../../common/security/auth.types';
|
||||||
import { CurrentUser } from '../../common/security/decorators';
|
import { CurrentScope, CurrentUser } from '../../common/security/decorators';
|
||||||
|
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||||
import { MeService } from './me.service';
|
import { MeService } from './me.service';
|
||||||
|
|
||||||
/** 当前用户上下文:登录后前端据此做路由 / 按钮级权限控制 */
|
/** 当前用户上下文:登录后前端据此做路由 / 按钮级权限控制与数据范围展示 */
|
||||||
@ApiTags('identity-me')
|
@ApiTags('identity-me')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('me')
|
@Controller('me')
|
||||||
@@ -15,11 +16,14 @@ export class MeController {
|
|||||||
constructor(@Inject(MeService) private readonly me: MeService) {}
|
constructor(@Inject(MeService) private readonly me: MeService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '当前用户、角色、权限点、数据范围' })
|
@ApiOperation({ summary: '当前用户、角色、权限点、数据范围(多角色并集)' })
|
||||||
async current(@CurrentUser() auth: AuthContext | null): Promise<MeResponse> {
|
async current(
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
@CurrentScope() scope: ScopeContext | null,
|
||||||
|
): Promise<MeResponse> {
|
||||||
if (auth === null) {
|
if (auth === null) {
|
||||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||||
}
|
}
|
||||||
return this.me.getMe(auth.userId, auth.permissions);
|
return this.me.getMe(auth.userId, auth.permissions, scope);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,23 +2,35 @@ import { ErrorCode, type MeResponse, Permission } from '@lingxi/shared';
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
import type { UserAuthRecord } from '../../common/security/permission.service';
|
import { activeRolesOf, type UserAuthRecord } from '../../common/security/permission.service';
|
||||||
import { resolveDataScope } from '../../common/security/permission.service';
|
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||||
import { activeRolesOf } from '../../common/security/permission.service';
|
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
import { toRoleSummary, toUserSummary } from './user.mapper';
|
import { toRoleSummary, toUserSummary } from './user.mapper';
|
||||||
|
|
||||||
/** /me 与登录响应里的上下文组装:用户摘要 + 角色 + 权限点 + 数据范围 */
|
/**
|
||||||
|
* /me 与登录响应里的上下文组装:用户摘要 + 角色 + 权限点 + 数据范围(多角色并集)。
|
||||||
|
* `dataScopes` 由 ScopeResolver 解析(每个元素带 customerIds;超管为 [{ type: 'all' }])。
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MeService {
|
export class MeService {
|
||||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async getMe(userId: string, permissions: string[]): Promise<MeResponse> {
|
async getMe(
|
||||||
|
userId: string,
|
||||||
|
permissions: string[],
|
||||||
|
scope: ScopeContext | null,
|
||||||
|
): Promise<MeResponse> {
|
||||||
const user = await this.findUser(userId);
|
const user = await this.findUser(userId);
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' });
|
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' });
|
||||||
}
|
}
|
||||||
|
if (scope === null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.OUT_OF_SCOPE,
|
||||||
|
message: '数据范围解析失败,已拒绝访问',
|
||||||
|
});
|
||||||
|
}
|
||||||
const roles = activeRolesOf(user);
|
const roles = activeRolesOf(user);
|
||||||
const canSeePhone = permissions.includes(Permission.USER_MANAGE);
|
const canSeePhone = permissions.includes(Permission.USER_MANAGE);
|
||||||
return {
|
return {
|
||||||
@@ -28,7 +40,7 @@ export class MeService {
|
|||||||
},
|
},
|
||||||
roles: roles.map((item) => toRoleSummary(item.role)),
|
roles: roles.map((item) => toRoleSummary(item.role)),
|
||||||
permissions,
|
permissions,
|
||||||
dataScope: resolveDataScope(roles.map((item) => item.role.code)),
|
dataScopes: scope.dataScopes,
|
||||||
mustChangePassword: user.mustChangePassword,
|
mustChangePassword: user.mustChangePassword,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -38,8 +50,8 @@ export class MeService {
|
|||||||
return this.prisma.user.findUnique({
|
return this.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
include: {
|
include: {
|
||||||
department: { include: { parent: true } },
|
department: { include: { parent: true, children: true } },
|
||||||
roles: { include: { role: { include: { permissions: true } } } },
|
roles: { include: { role: { include: { permissions: true, dataScopes: true } } } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Permission, type PermissionTree } from '@lingxi/shared';
|
||||||
|
import { Controller, Get, Inject } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { PermissionCatalogService } from '../../common/security/catalog/permission-catalog.service';
|
||||||
|
import { RequirePermissions } from '../../common/security/decorators';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限点目录(A2-BE-02):前端角色编辑器据此渲染「模块 × 操作」勾选树、
|
||||||
|
* 特殊权限区域与 SoD 互斥对高亮。目录由 `@lingxi/shared` 代码注册,不读业务 DB。
|
||||||
|
*/
|
||||||
|
@ApiTags('identity-permissions')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('permissions')
|
||||||
|
export class PermissionsController {
|
||||||
|
constructor(
|
||||||
|
@Inject(PermissionCatalogService) private readonly catalog: PermissionCatalogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permission.ROLE_VIEW)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '权限点目录(10 模块树 + 特殊权限 + SoD 对)',
|
||||||
|
description: '模块顺序与 design/a2-ui-v1/brief.md 对齐;特殊权限默认关闭、单独授予',
|
||||||
|
})
|
||||||
|
getTree(): PermissionTree {
|
||||||
|
return this.catalog.buildTree();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,49 @@
|
|||||||
import { type ListResponse, Permission, type RoleSummary } from '@lingxi/shared';
|
import {
|
||||||
import { Controller, Get, Inject } from '@nestjs/common';
|
type CreateRoleInput,
|
||||||
|
createRoleInputSchema,
|
||||||
|
ErrorCode,
|
||||||
|
Permission,
|
||||||
|
type RoleDetail,
|
||||||
|
type RoleListQuery,
|
||||||
|
roleListQuerySchema,
|
||||||
|
type RoleListResponse,
|
||||||
|
type RoleSaveResult,
|
||||||
|
type UpdateRoleInput,
|
||||||
|
updateRoleInputSchema,
|
||||||
|
} 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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { RequirePermissions } from '../../common/security/decorators';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||||
|
import type { AuthContext } from '../../common/security/auth.types';
|
||||||
|
import { Audit, CurrentUser, RequirePermissions } from '../../common/security/decorators';
|
||||||
import { RolesService } from './roles.service';
|
import { RolesService } from './roles.service';
|
||||||
|
|
||||||
/** 角色列表:供用户管理页做角色多选绑定 */
|
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||||
|
if (auth === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色管理(A2-BE-01 / 03 / 05)。
|
||||||
|
*
|
||||||
|
* 兼容 A1:`GET /roles` 同时接受 `role:view` 与 `user:view`(用户管理页的角色多选),
|
||||||
|
* 默认只返回启用角色,返回项包含 `RoleSummary` 的 id / code / name / isSystem / description。
|
||||||
|
*/
|
||||||
@ApiTags('identity-roles')
|
@ApiTags('identity-roles')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('roles')
|
@Controller('roles')
|
||||||
@@ -13,9 +51,59 @@ export class RolesController {
|
|||||||
constructor(@Inject(RolesService) private readonly roles: RolesService) {}
|
constructor(@Inject(RolesService) private readonly roles: RolesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermissions(Permission.USER_VIEW)
|
@RequirePermissions(Permission.ROLE_VIEW, Permission.USER_VIEW)
|
||||||
@ApiOperation({ summary: '角色列表(绑定用)' })
|
@ApiOperation({
|
||||||
async list(): Promise<ListResponse<RoleSummary>> {
|
summary: '角色列表(游标分页 + 筛选 + 排序)',
|
||||||
return { items: await this.roles.list(), next_cursor: null };
|
description: '默认 status=active(兼容 A1 用户管理的角色多选);含权限点统计、用户数与数据范围',
|
||||||
|
})
|
||||||
|
async list(
|
||||||
|
@Query(new ZodValidationPipe(roleListQuerySchema)) query: RoleListQuery,
|
||||||
|
): Promise<RoleListResponse> {
|
||||||
|
return this.roles.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permission.ROLE_VIEW)
|
||||||
|
@ApiOperation({ summary: '角色详情(权限点、特殊权限、数据范围、绑定用户)' })
|
||||||
|
async detail(@Param('id') id: string): Promise<RoleDetail> {
|
||||||
|
return this.roles.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions(Permission.ROLE_MANAGE)
|
||||||
|
@Audit('identity.role.create')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '新建角色',
|
||||||
|
description: '权限点默认全空、code 自动生成;保存即做 SoD 校验(40304);特殊权限单独授予',
|
||||||
|
})
|
||||||
|
async create(
|
||||||
|
@Body(new ZodValidationPipe(createRoleInputSchema)) input: CreateRoleInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<RoleSaveResult> {
|
||||||
|
return this.roles.create(input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermissions(Permission.ROLE_MANAGE)
|
||||||
|
@Audit('identity.role.update')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '更新角色 / 启停',
|
||||||
|
description: '系统角色仅显示名可改;status=enabled/disabled;返回 affectedUserCount 与软提示',
|
||||||
|
})
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body(new ZodValidationPipe(updateRoleInputSchema)) input: UpdateRoleInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<RoleSaveResult> {
|
||||||
|
return this.roles.update(id, input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
@RequirePermissions(Permission.ROLE_MANAGE)
|
||||||
|
@Audit('identity.role.delete')
|
||||||
|
@ApiOperation({ summary: '删除角色', description: '系统角色拒绝;有绑定用户(40903)只能停用' })
|
||||||
|
async remove(@Param('id') id: string, @CurrentUser() auth: AuthContext | null): Promise<void> {
|
||||||
|
await this.roles.remove(id, requireAuth(auth).userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,328 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import type { RequestContextService } from '../../common/security/request-context';
|
||||||
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import { RolesService } from './roles.service';
|
||||||
|
|
||||||
|
const NOW = new Date('2026-11-03T02:00:00.000Z');
|
||||||
|
|
||||||
|
function roleFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: 'r1',
|
||||||
|
code: 'copywriter',
|
||||||
|
name: '文案策划',
|
||||||
|
description: '脚本撰写',
|
||||||
|
isSystem: false,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
createdAt: NOW,
|
||||||
|
updatedAt: NOW,
|
||||||
|
permissions: [{ permissionCode: 'script:read' }, { permissionCode: 'script:write' }],
|
||||||
|
dataScopes: [{ scopeType: 'department', targetId: null }],
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
createdAt: NOW,
|
||||||
|
user: {
|
||||||
|
id: 'u1',
|
||||||
|
name: '周岸',
|
||||||
|
account: 'zhou.an',
|
||||||
|
phone: '13755205520',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
department: { name: '素材二组' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createdAt: NOW,
|
||||||
|
user: {
|
||||||
|
id: 'u2',
|
||||||
|
name: '林清',
|
||||||
|
account: 'lin.qing',
|
||||||
|
phone: '13912347712',
|
||||||
|
status: 'DISABLED',
|
||||||
|
department: { name: '设计组' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
_count: { users: 2 },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
service: RolesService;
|
||||||
|
prisma: {
|
||||||
|
role: {
|
||||||
|
findFirst: ReturnType<typeof vi.fn>;
|
||||||
|
findUnique: ReturnType<typeof vi.fn>;
|
||||||
|
count: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
user: { count: ReturnType<typeof vi.fn> };
|
||||||
|
$transaction: ReturnType<typeof vi.fn>;
|
||||||
|
tx: {
|
||||||
|
role: {
|
||||||
|
create: ReturnType<typeof vi.fn>;
|
||||||
|
update: ReturnType<typeof vi.fn>;
|
||||||
|
delete: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
rolePermission: {
|
||||||
|
deleteMany: ReturnType<typeof vi.fn>;
|
||||||
|
createMany: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
dataScope: { deleteMany: ReturnType<typeof vi.fn>; createMany: ReturnType<typeof vi.fn> };
|
||||||
|
outboxEvent: { create: ReturnType<typeof vi.fn> };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
requestContext: { get: ReturnType<typeof vi.fn>; patchAudit: ReturnType<typeof vi.fn> };
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(): Harness {
|
||||||
|
const tx = {
|
||||||
|
role: { create: vi.fn().mockResolvedValue({}), update: vi.fn(), delete: vi.fn() },
|
||||||
|
rolePermission: { deleteMany: vi.fn(), createMany: vi.fn() },
|
||||||
|
dataScope: { deleteMany: vi.fn(), createMany: vi.fn() },
|
||||||
|
outboxEvent: { create: vi.fn().mockResolvedValue({}) },
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
role: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
count: vi.fn().mockResolvedValue(9),
|
||||||
|
},
|
||||||
|
user: { count: vi.fn().mockResolvedValue(2) },
|
||||||
|
$transaction: vi.fn(async (callback: unknown) => {
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
return (callback as (client: typeof tx) => Promise<unknown>)(tx);
|
||||||
|
}
|
||||||
|
return Promise.all(callback as Promise<unknown>[]);
|
||||||
|
}),
|
||||||
|
tx,
|
||||||
|
};
|
||||||
|
const requestContext = {
|
||||||
|
get: vi.fn().mockReturnValue({ traceId: 'trace-1' }),
|
||||||
|
patchAudit: vi.fn(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
service: new RolesService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
requestContext as unknown as RequestContextService,
|
||||||
|
),
|
||||||
|
prisma,
|
||||||
|
requestContext,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||||
|
return promise.then(
|
||||||
|
() => expect.unreachable('应抛出异常'),
|
||||||
|
(error: unknown) => {
|
||||||
|
expect(error).toBeInstanceOf(AppException);
|
||||||
|
expect((error as AppException).code).toBe(code);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RolesService.create', () => {
|
||||||
|
it('默认全空:code 自动生成、权限点为空、写审计与 outbox', async () => {
|
||||||
|
const { service, prisma, requestContext } = harness();
|
||||||
|
prisma.role.findUnique.mockImplementation((args: { where: { id: string } }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
roleFixture({ id: args.where.id, code: 'custom_x', permissions: [], users: [] }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result = await service.create(
|
||||||
|
{ name: '新运营组', dataScope: { type: 'created_by_me' } },
|
||||||
|
'actor-1',
|
||||||
|
);
|
||||||
|
const createdArgs = prisma.tx.role.create.mock.calls[0]?.[0] as { data: { code: string } };
|
||||||
|
expect(createdArgs.data.code).toMatch(/^custom_[0-9a-z]{26}$/);
|
||||||
|
expect(result.role.permissions).toEqual([]);
|
||||||
|
expect(result.affectedUserCount).toBe(0);
|
||||||
|
expect(result.warnings).toEqual([]);
|
||||||
|
|
||||||
|
const createArgs = prisma.tx.role.create.mock.calls[0]?.[0] as {
|
||||||
|
data: { code: string; permissions: { create: unknown[] } };
|
||||||
|
};
|
||||||
|
expect(createArgs.data.permissions.create).toEqual([]);
|
||||||
|
const outboxArgs = prisma.tx.outboxEvent.create.mock.calls[0]?.[0] as {
|
||||||
|
data: { eventType: string; payloadJson: { action: string; roleId: string } };
|
||||||
|
};
|
||||||
|
expect(outboxArgs.data.eventType).toBe('role.permissions.changed');
|
||||||
|
expect(outboxArgs.data.payloadJson.action).toBe('created');
|
||||||
|
expect(requestContext.patchAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ targetType: 'role', affectedUserCount: 0 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('角色名称重复返回 40902', async () => {
|
||||||
|
const { service, prisma } = harness();
|
||||||
|
prisma.role.findFirst.mockResolvedValueOnce({ id: 'other' });
|
||||||
|
await expectRejected(
|
||||||
|
service.create({ name: '文案策划', dataScope: { type: 'all' } }, 'actor-1'),
|
||||||
|
ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SoD 命中返回 40304 并给出冲突对', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await service.create(
|
||||||
|
{
|
||||||
|
name: '冲突角色',
|
||||||
|
permissions: ['script:write', 'script:review'],
|
||||||
|
dataScope: { type: 'department' },
|
||||||
|
},
|
||||||
|
'actor-1',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
expect(thrown).toBeInstanceOf(AppException);
|
||||||
|
expect((thrown as AppException).code).toBe(ErrorCode.SOD_VIOLATION);
|
||||||
|
expect((thrown as AppException).details).toMatchObject({
|
||||||
|
conflicts: [{ pairId: 'script-write-review' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('特殊权限混入 permissions 返回 40001', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expectRejected(
|
||||||
|
service.create(
|
||||||
|
{
|
||||||
|
name: '导出角色',
|
||||||
|
permissions: ['delivery:export'],
|
||||||
|
dataScope: { type: 'all' },
|
||||||
|
},
|
||||||
|
'actor-1',
|
||||||
|
),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RolesService.update', () => {
|
||||||
|
it('系统角色仅允许改显示名:改权限 / 状态返回 40903,改名放行', async () => {
|
||||||
|
const { service, prisma } = harness();
|
||||||
|
const systemRole = roleFixture({ code: 'super_admin', isSystem: true });
|
||||||
|
prisma.role.findUnique.mockResolvedValueOnce(systemRole);
|
||||||
|
await expectRejected(
|
||||||
|
service.update('r1', { permissions: ['user:view'] }, 'actor-1'),
|
||||||
|
ErrorCode.STATE_NOT_ALLOWED,
|
||||||
|
);
|
||||||
|
prisma.role.findUnique.mockResolvedValueOnce(systemRole);
|
||||||
|
await expectRejected(
|
||||||
|
service.update('r1', { status: 'disabled' }, 'actor-1'),
|
||||||
|
ErrorCode.STATE_NOT_ALLOWED,
|
||||||
|
);
|
||||||
|
|
||||||
|
prisma.role.findUnique
|
||||||
|
.mockResolvedValueOnce(systemRole)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
roleFixture({ code: 'super_admin', isSystem: true, name: '新超管名' }),
|
||||||
|
);
|
||||||
|
const result = await service.update('r1', { name: '新超管名' }, 'actor-1');
|
||||||
|
expect(result.role.name).toBe('新超管名');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('更新权限:返回受影响用户数、outbox 携带 diff、审计含前后值', async () => {
|
||||||
|
const { service, prisma, requestContext } = harness();
|
||||||
|
prisma.role.findUnique.mockResolvedValueOnce(roleFixture()).mockResolvedValueOnce(
|
||||||
|
roleFixture({
|
||||||
|
permissions: [{ permissionCode: 'script:read' }, { permissionCode: 'user:view' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const result = await service.update(
|
||||||
|
'r1',
|
||||||
|
{ permissions: ['script:read', 'user:view'], status: 'disabled', reason: '岗位合并' },
|
||||||
|
'actor-1',
|
||||||
|
);
|
||||||
|
expect(result.affectedUserCount).toBe(2);
|
||||||
|
expect(result.role.specialPermissions).toEqual([]);
|
||||||
|
|
||||||
|
const updateArgs = prisma.tx.role.update.mock.calls[0]?.[0] as { data: { status: string } };
|
||||||
|
expect(updateArgs.data.status).toBe('DISABLED');
|
||||||
|
const outboxArgs = prisma.tx.outboxEvent.create.mock.calls[0]?.[0] as {
|
||||||
|
data: {
|
||||||
|
payloadJson: {
|
||||||
|
changes: { permissions: { added: string[]; removed: string[] } };
|
||||||
|
affectedUserIds: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(outboxArgs.data.payloadJson.changes.permissions.added).toEqual(['user:view']);
|
||||||
|
expect(outboxArgs.data.payloadJson.changes.permissions.removed).toEqual(['script:write']);
|
||||||
|
expect(outboxArgs.data.payloadJson.affectedUserIds).toEqual(['u1', 'u2']);
|
||||||
|
expect(requestContext.patchAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
affectedUserCount: 2,
|
||||||
|
affectedUserIds: ['u1', 'u2'],
|
||||||
|
reason: '岗位合并',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('更新数据范围:替换 data_scopes 行;customer_groups 多个客户组写多行', async () => {
|
||||||
|
const { service, prisma } = harness();
|
||||||
|
prisma.role.findUnique.mockResolvedValueOnce(roleFixture()).mockResolvedValueOnce(
|
||||||
|
roleFixture({
|
||||||
|
dataScopes: [
|
||||||
|
{ scopeType: 'customer_groups', targetId: 'g1' },
|
||||||
|
{ scopeType: 'customer_groups', targetId: 'g2' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const groups = ['01HZZZZZZZZZZZZZZZZZZZZZZ1', '01HZZZZZZZZZZZZZZZZZZZZZZ2'];
|
||||||
|
const result = await service.update(
|
||||||
|
'r1',
|
||||||
|
{ dataScope: { type: 'customer_groups', targetIds: groups } },
|
||||||
|
'actor-1',
|
||||||
|
);
|
||||||
|
expect(result.role.dataScope).toEqual({ type: 'customer_groups', targetIds: ['g1', 'g2'] });
|
||||||
|
const createManyArgs = prisma.tx.dataScope.createMany.mock.calls[0]?.[0] as {
|
||||||
|
data: { roleId: string; scopeType: string; targetId: string }[];
|
||||||
|
};
|
||||||
|
expect(createManyArgs.data).toHaveLength(2);
|
||||||
|
expect(createManyArgs.data[0]?.scopeType).toBe('customer_groups');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RolesService.remove', () => {
|
||||||
|
it('系统角色不可删除(40903)', async () => {
|
||||||
|
const { service, prisma } = harness();
|
||||||
|
prisma.role.findUnique.mockResolvedValueOnce(roleFixture({ isSystem: true }));
|
||||||
|
await expectRejected(service.remove('r1', 'actor-1'), ErrorCode.STATE_NOT_ALLOWED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有绑定用户只能停用(40903,附 userCount)', async () => {
|
||||||
|
const { service, prisma } = harness();
|
||||||
|
prisma.role.findUnique.mockResolvedValueOnce(roleFixture());
|
||||||
|
await expectRejected(service.remove('r1', 'actor-1'), ErrorCode.STATE_NOT_ALLOWED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无绑定用户删除成功并写 outbox(先事件后删除)', async () => {
|
||||||
|
const { service, prisma } = harness();
|
||||||
|
prisma.role.findUnique.mockResolvedValueOnce(roleFixture({ users: [], _count: { users: 0 } }));
|
||||||
|
await service.remove('r1', 'actor-1');
|
||||||
|
expect(prisma.tx.role.delete).toHaveBeenCalledWith({ where: { id: 'r1' } });
|
||||||
|
expect(prisma.tx.outboxEvent.create).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RolesService.detail', () => {
|
||||||
|
it('角色不存在返回 40401', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expectRejected(service.detail('missing'), ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超管详情隐式展示全部权限点(30)与 all 范围', async () => {
|
||||||
|
const { service, prisma } = harness();
|
||||||
|
prisma.role.findUnique.mockResolvedValueOnce(
|
||||||
|
roleFixture({ code: 'super_admin', isSystem: true, permissions: [], dataScopes: [] }),
|
||||||
|
);
|
||||||
|
const detail = await service.detail('r1');
|
||||||
|
expect(detail.permissions).toHaveLength(24);
|
||||||
|
expect(detail.specialPermissions).toHaveLength(6);
|
||||||
|
expect(detail.dataScope).toEqual({ type: 'all' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,749 @@
|
|||||||
import { type RoleSummary } from '@lingxi/shared';
|
import {
|
||||||
|
type CreateRoleInput,
|
||||||
|
DATA_SCOPE_TYPES,
|
||||||
|
type DataScope,
|
||||||
|
type DataScopeType,
|
||||||
|
ErrorCode,
|
||||||
|
findSodConflicts,
|
||||||
|
isPermissionCode,
|
||||||
|
isSpecialPermission,
|
||||||
|
type ListResponse,
|
||||||
|
PERMISSION_CODES,
|
||||||
|
type PermissionCode,
|
||||||
|
ROLE_SOFT_LIMIT,
|
||||||
|
type RoleBoundUser,
|
||||||
|
type RoleDetail,
|
||||||
|
type RoleListItem,
|
||||||
|
type RoleListQuery,
|
||||||
|
type RoleSaveResult,
|
||||||
|
splitPermissions,
|
||||||
|
SUPER_ADMIN_SOFT_LIMIT,
|
||||||
|
ulid,
|
||||||
|
type UpdateRoleInput,
|
||||||
|
} from '@lingxi/shared';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
import { toRoleSummary } from './user.mapper';
|
import { iso, maskPhone } from './user.mapper';
|
||||||
|
|
||||||
/** 角色列表:A1 只做绑定多选,完整角色管理属 A2 */
|
const ROLE_LIST_INCLUDE = {
|
||||||
|
permissions: { select: { permissionCode: true } },
|
||||||
|
dataScopes: { select: { scopeType: true, targetId: true } },
|
||||||
|
_count: { select: { users: true } },
|
||||||
|
} satisfies Prisma.RoleInclude;
|
||||||
|
|
||||||
|
const ROLE_DETAIL_INCLUDE = {
|
||||||
|
permissions: { select: { permissionCode: true } },
|
||||||
|
dataScopes: { select: { scopeType: true, targetId: true } },
|
||||||
|
users: {
|
||||||
|
select: {
|
||||||
|
createdAt: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
account: true,
|
||||||
|
phone: true,
|
||||||
|
status: true,
|
||||||
|
department: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
},
|
||||||
|
_count: { select: { users: true } },
|
||||||
|
} satisfies Prisma.RoleInclude;
|
||||||
|
|
||||||
|
type RoleWithList = Prisma.RoleGetPayload<{ include: typeof ROLE_LIST_INCLUDE }>;
|
||||||
|
type RoleWithDetail = Prisma.RoleGetPayload<{ include: typeof ROLE_DETAIL_INCLUDE }>;
|
||||||
|
|
||||||
|
interface RolePermissionSnapshot {
|
||||||
|
permissions: PermissionCode[];
|
||||||
|
specialPermissions: PermissionCode[];
|
||||||
|
dataScope: DataScope;
|
||||||
|
status: 'active' | 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionDiff {
|
||||||
|
added: PermissionCode[];
|
||||||
|
removed: PermissionCode[];
|
||||||
|
specialAdded: PermissionCode[];
|
||||||
|
specialRemoved: PermissionCode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatePersistArgs {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
input: CreateRoleInput;
|
||||||
|
permissions: PermissionCode[];
|
||||||
|
specialPermissions: PermissionCode[];
|
||||||
|
diff: PermissionDiff;
|
||||||
|
actorId: string;
|
||||||
|
traceId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdatePersistArgs {
|
||||||
|
id: string;
|
||||||
|
existing: RoleWithDetail;
|
||||||
|
input: UpdateRoleInput;
|
||||||
|
after: RolePermissionSnapshot;
|
||||||
|
diff: PermissionDiff;
|
||||||
|
affectedUserIds: string[];
|
||||||
|
nextName: string;
|
||||||
|
nextDescription: string;
|
||||||
|
beforeDataScopeType: string;
|
||||||
|
actorId: string;
|
||||||
|
traceId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OutboxEventArgs {
|
||||||
|
action: 'created' | 'updated' | 'deleted';
|
||||||
|
roleId: string;
|
||||||
|
roleCode: string;
|
||||||
|
roleName: string;
|
||||||
|
actorId: string;
|
||||||
|
traceId: string | null;
|
||||||
|
diff: PermissionDiff;
|
||||||
|
affectedUserIds: string[];
|
||||||
|
dataScopeBefore: string | null;
|
||||||
|
dataScopeAfter: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitCodes(codes: readonly PermissionCode[]): {
|
||||||
|
normal: PermissionCode[];
|
||||||
|
special: PermissionCode[];
|
||||||
|
} {
|
||||||
|
const normal: PermissionCode[] = [];
|
||||||
|
const special: PermissionCode[] = [];
|
||||||
|
for (const code of codes) {
|
||||||
|
if (isSpecialPermission(code)) {
|
||||||
|
special.push(code);
|
||||||
|
} else {
|
||||||
|
normal.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { normal: normal.sort(), special: special.sort() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveCodes(role: {
|
||||||
|
isSystem: boolean;
|
||||||
|
permissions: { permissionCode: string }[];
|
||||||
|
}): PermissionCode[] {
|
||||||
|
if (role.isSystem) {
|
||||||
|
return [...PERMISSION_CODES];
|
||||||
|
}
|
||||||
|
return role.permissions
|
||||||
|
.map((item) => item.permissionCode)
|
||||||
|
.filter(isPermissionCode)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一个角色只允许一种范围类型(A2 契约):多行(customer_groups 多客户组)聚合为单值 */
|
||||||
|
function aggregateDataScope(
|
||||||
|
rows: readonly { scopeType: string; targetId: string | null }[],
|
||||||
|
isSystem: boolean,
|
||||||
|
): DataScope {
|
||||||
|
if (isSystem) {
|
||||||
|
return { type: 'all' };
|
||||||
|
}
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return { type: 'created_by_me' };
|
||||||
|
}
|
||||||
|
const ordered = [...rows].sort(
|
||||||
|
(left, right) =>
|
||||||
|
DATA_SCOPE_TYPES.indexOf(left.scopeType as DataScopeType) -
|
||||||
|
DATA_SCOPE_TYPES.indexOf(right.scopeType as DataScopeType),
|
||||||
|
);
|
||||||
|
const type = (ordered[0]?.scopeType ?? 'created_by_me') as DataScopeType;
|
||||||
|
if (type !== 'customer_groups') {
|
||||||
|
return { type };
|
||||||
|
}
|
||||||
|
const targetIds = [
|
||||||
|
...new Set(
|
||||||
|
rows
|
||||||
|
.filter((row) => row.scopeType === type)
|
||||||
|
.flatMap((row) => (row.targetId === null ? [] : [row.targetId])),
|
||||||
|
),
|
||||||
|
].sort();
|
||||||
|
return targetIds.length > 0 ? { type, targetIds } : { type };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBoundUser(row: RoleWithDetail['users'][number]): RoleBoundUser {
|
||||||
|
return {
|
||||||
|
id: row.user.id,
|
||||||
|
name: row.user.name,
|
||||||
|
account: row.user.account,
|
||||||
|
phoneMasked: maskPhone(row.user.phone),
|
||||||
|
departmentName: row.user.department.name,
|
||||||
|
status: row.user.status === 'ACTIVE' ? 'active' : 'disabled',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toListItem(role: RoleWithList): RoleListItem {
|
||||||
|
const codes = splitCodes(effectiveCodes(role));
|
||||||
|
return {
|
||||||
|
id: role.id,
|
||||||
|
code: role.code,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description,
|
||||||
|
isSystem: role.isSystem,
|
||||||
|
status: role.status === 'ACTIVE' ? 'active' : 'disabled',
|
||||||
|
permissionCount: codes.normal.length,
|
||||||
|
specialPermissionCount: codes.special.length,
|
||||||
|
userCount: role._count.users,
|
||||||
|
dataScope: aggregateDataScope(role.dataScopes, role.isSystem),
|
||||||
|
createdAt: iso(role.createdAt),
|
||||||
|
updatedAt: iso(role.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDetail(role: RoleWithDetail): RoleDetail {
|
||||||
|
const listItem = toListItem(role);
|
||||||
|
const codes = splitCodes(effectiveCodes(role));
|
||||||
|
return {
|
||||||
|
...listItem,
|
||||||
|
permissions: codes.normal,
|
||||||
|
specialPermissions: codes.special,
|
||||||
|
boundUsers: role.users.map((row) => toBoundUser(row)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffCodes(before: RolePermissionSnapshot, after: RolePermissionSnapshot): PermissionDiff {
|
||||||
|
const beforeNormal = new Set(before.permissions);
|
||||||
|
const afterNormal = new Set(after.permissions);
|
||||||
|
const beforeSpecial = new Set(before.specialPermissions);
|
||||||
|
const afterSpecial = new Set(after.specialPermissions);
|
||||||
|
return {
|
||||||
|
added: after.permissions.filter((code) => !beforeNormal.has(code)).sort(),
|
||||||
|
removed: before.permissions.filter((code) => !afterNormal.has(code)).sort(),
|
||||||
|
specialAdded: after.specialPermissions.filter((code) => !beforeSpecial.has(code)).sort(),
|
||||||
|
specialRemoved: before.specialPermissions.filter((code) => !afterSpecial.has(code)).sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色管理(A2-BE-01 / 03 / 05):
|
||||||
|
* - 列表 / 详情(目录统计 + 数据范围 + 绑定用户);
|
||||||
|
* - 新建(权限默认全空、code 自动生成、SoD 保存即校验);
|
||||||
|
* - 更新(含启用 / 停用;系统角色仅显示名可改);
|
||||||
|
* - 删除(系统角色拒绝;有绑定用户 40903);
|
||||||
|
* - 变更审计(前后值 diff + 影响用户数)与 outbox `role.permissions.changed` 占位通知。
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RolesService {
|
export class RolesService {
|
||||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async list(): Promise<RoleSummary[]> {
|
async list(query: RoleListQuery): Promise<ListResponse<RoleListItem>> {
|
||||||
const roles = await this.prisma.role.findMany({
|
const where: Prisma.RoleWhereInput = {};
|
||||||
where: { status: 'ACTIVE' },
|
if (query.status !== 'all') {
|
||||||
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
|
where.status = query.status === 'active' ? 'ACTIVE' : 'DISABLED';
|
||||||
|
}
|
||||||
|
if (query.keyword !== undefined && query.keyword.length > 0) {
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: query.keyword, mode: 'insensitive' } },
|
||||||
|
{ code: { contains: query.keyword, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const direction: Prisma.SortOrder = query.order === 'asc' ? 'asc' : 'desc';
|
||||||
|
const orderBy: Prisma.RoleOrderByWithRelationInput[] =
|
||||||
|
query.sort === 'name'
|
||||||
|
? [{ name: direction }, { id: 'asc' }]
|
||||||
|
: [{ createdAt: direction }, { id: 'asc' }];
|
||||||
|
const rows = await this.prisma.role.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
take: query.limit + 1,
|
||||||
|
include: ROLE_LIST_INCLUDE,
|
||||||
|
...(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) => toListItem(row)), next_cursor: next };
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: string): Promise<RoleDetail> {
|
||||||
|
const role = await this.prisma.role.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: ROLE_DETAIL_INCLUDE,
|
||||||
|
});
|
||||||
|
if (role === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' });
|
||||||
|
}
|
||||||
|
return toDetail(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateRoleInput, actorId: string): Promise<RoleSaveResult> {
|
||||||
|
await this.ensureNameAvailable(input.name);
|
||||||
|
const { permissions, specialPermissions } = this.validatePermissions(
|
||||||
|
input.permissions ?? [],
|
||||||
|
input.specialPermissions ?? [],
|
||||||
|
);
|
||||||
|
this.assertSod({
|
||||||
|
permissions,
|
||||||
|
specialPermissions,
|
||||||
|
dataScope: input.dataScope,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
const id = ulid();
|
||||||
|
const traceId = this.requestContext.get()?.traceId ?? null;
|
||||||
|
const diff: PermissionDiff = {
|
||||||
|
added: permissions,
|
||||||
|
removed: [],
|
||||||
|
specialAdded: specialPermissions,
|
||||||
|
specialRemoved: [],
|
||||||
|
};
|
||||||
|
const code = `custom_${ulid().toLowerCase()}`;
|
||||||
|
await this.prisma.$transaction((tx) =>
|
||||||
|
this.persistCreate(tx, {
|
||||||
|
id,
|
||||||
|
code,
|
||||||
|
input,
|
||||||
|
permissions,
|
||||||
|
specialPermissions,
|
||||||
|
diff,
|
||||||
|
actorId,
|
||||||
|
traceId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const role = await this.detail(id);
|
||||||
|
this.recordCreateAudit(role, diff);
|
||||||
|
return { role, affectedUserCount: 0, warnings: await this.buildWarnings(role.code) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, input: UpdateRoleInput, actorId: string): Promise<RoleSaveResult> {
|
||||||
|
const existing = await this.prisma.role.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: ROLE_DETAIL_INCLUDE,
|
||||||
|
});
|
||||||
|
if (existing === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' });
|
||||||
|
}
|
||||||
|
this.assertSystemRoleMutable(existing, input);
|
||||||
|
if (input.name !== undefined && input.name !== existing.name) {
|
||||||
|
await this.ensureNameAvailable(input.name, id);
|
||||||
|
}
|
||||||
|
const before = this.snapshotOf(existing);
|
||||||
|
const after = this.mergeSnapshot(before, input);
|
||||||
|
// 超管豁免 SoD(系统角色不可改权限,隐式全集必然「违反」互斥对)
|
||||||
|
if (!existing.isSystem) {
|
||||||
|
this.assertSod(after);
|
||||||
|
}
|
||||||
|
const diff = diffCodes(before, after);
|
||||||
|
const affectedUserIds = existing.users.map((row) => row.user.id);
|
||||||
|
await this.prisma.$transaction((tx) =>
|
||||||
|
this.persistUpdate(tx, {
|
||||||
|
id,
|
||||||
|
existing,
|
||||||
|
input,
|
||||||
|
after,
|
||||||
|
diff,
|
||||||
|
affectedUserIds,
|
||||||
|
nextName: input.name ?? existing.name,
|
||||||
|
nextDescription: input.description ?? existing.description,
|
||||||
|
beforeDataScopeType: before.dataScope.type,
|
||||||
|
actorId,
|
||||||
|
traceId: this.requestContext.get()?.traceId ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const role = await this.detail(id);
|
||||||
|
this.recordUpdateAudit({ existing, before, role, affectedUserIds, diff, input });
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
affectedUserCount: affectedUserIds.length,
|
||||||
|
warnings: await this.buildWarnings(role.code),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string, actorId: string): Promise<void> {
|
||||||
|
const existing = await this.prisma.role.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { users: true } } },
|
||||||
|
});
|
||||||
|
if (existing === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' });
|
||||||
|
}
|
||||||
|
if (existing.isSystem) {
|
||||||
|
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '系统角色不可删除' });
|
||||||
|
}
|
||||||
|
if (existing._count.users > 0) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||||
|
message: `角色已绑定 ${String(existing._count.users)} 名用户,只能停用`,
|
||||||
|
details: { userCount: existing._count.users },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.prisma.$transaction((tx) =>
|
||||||
|
this.persistRemove(tx, {
|
||||||
|
id,
|
||||||
|
existing,
|
||||||
|
actorId,
|
||||||
|
traceId: this.requestContext.get()?.traceId ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'role',
|
||||||
|
targetId: id,
|
||||||
|
before: { name: existing.name, code: existing.code, status: existing.status },
|
||||||
|
after: null,
|
||||||
|
affectedUserCount: 0,
|
||||||
|
affectedUserIds: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistCreate(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
args: CreatePersistArgs,
|
||||||
|
): Promise<void> {
|
||||||
|
await tx.role.create({
|
||||||
|
data: {
|
||||||
|
id: args.id,
|
||||||
|
code: args.code,
|
||||||
|
name: args.input.name,
|
||||||
|
description: args.input.description ?? '',
|
||||||
|
isSystem: false,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
permissions: {
|
||||||
|
create: [...args.permissions, ...args.specialPermissions].map((permissionCode) => ({
|
||||||
|
permissionCode,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
dataScopes: {
|
||||||
|
create: this.dataScopeRows(
|
||||||
|
args.input.dataScope.type,
|
||||||
|
args.input.dataScope.targetIds ?? [],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.writeOutboxEvent(tx, {
|
||||||
|
action: 'created',
|
||||||
|
roleId: args.id,
|
||||||
|
roleCode: args.code,
|
||||||
|
roleName: args.input.name,
|
||||||
|
actorId: args.actorId,
|
||||||
|
traceId: args.traceId,
|
||||||
|
diff: args.diff,
|
||||||
|
affectedUserIds: [],
|
||||||
|
dataScopeBefore: null,
|
||||||
|
dataScopeAfter: args.input.dataScope.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordCreateAudit(role: RoleDetail, diff: PermissionDiff): void {
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'role',
|
||||||
|
targetId: role.id,
|
||||||
|
before: null,
|
||||||
|
after: this.auditSnapshot(role),
|
||||||
|
affectedUserCount: 0,
|
||||||
|
affectedUserIds: [],
|
||||||
|
changes: diff,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistUpdate(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
args: UpdatePersistArgs,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.applyRoleUpdate(tx, args);
|
||||||
|
await this.writeOutboxEvent(tx, {
|
||||||
|
action: 'updated',
|
||||||
|
roleId: args.id,
|
||||||
|
roleCode: args.existing.code,
|
||||||
|
roleName: args.nextName,
|
||||||
|
actorId: args.actorId,
|
||||||
|
traceId: args.traceId,
|
||||||
|
diff: args.diff,
|
||||||
|
affectedUserIds: args.affectedUserIds,
|
||||||
|
dataScopeBefore:
|
||||||
|
args.existing.dataScopes.length > 0
|
||||||
|
? (args.existing.dataScopes[0]?.scopeType ?? null)
|
||||||
|
: null,
|
||||||
|
dataScopeAfter: args.after.dataScope.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyRoleUpdate(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
args: UpdatePersistArgs,
|
||||||
|
): Promise<void> {
|
||||||
|
await tx.role.update({
|
||||||
|
where: { id: args.id },
|
||||||
|
data: {
|
||||||
|
name: args.nextName,
|
||||||
|
description: args.nextDescription,
|
||||||
|
status: args.after.status === 'active' ? 'ACTIVE' : 'DISABLED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (args.input.permissions !== undefined || args.input.specialPermissions !== undefined) {
|
||||||
|
await tx.rolePermission.deleteMany({ where: { roleId: args.id } });
|
||||||
|
await tx.rolePermission.createMany({
|
||||||
|
data: [...args.after.permissions, ...args.after.specialPermissions].map(
|
||||||
|
(permissionCode) => ({ roleId: args.id, permissionCode }),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (args.input.dataScope !== undefined) {
|
||||||
|
await tx.dataScope.deleteMany({ where: { roleId: args.id } });
|
||||||
|
await tx.dataScope.createMany({
|
||||||
|
data: this.dataScopeRows(
|
||||||
|
args.input.dataScope.type,
|
||||||
|
args.input.dataScope.targetIds ?? [],
|
||||||
|
).map((row) => ({ ...row, roleId: args.id })),
|
||||||
});
|
});
|
||||||
return roles.map((role) => toRoleSummary(role));
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordUpdateAudit(args: {
|
||||||
|
existing: RoleWithDetail;
|
||||||
|
before: RolePermissionSnapshot;
|
||||||
|
role: RoleDetail;
|
||||||
|
affectedUserIds: string[];
|
||||||
|
diff: PermissionDiff;
|
||||||
|
input: UpdateRoleInput;
|
||||||
|
}): void {
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'role',
|
||||||
|
targetId: args.role.id,
|
||||||
|
before: {
|
||||||
|
name: args.existing.name,
|
||||||
|
description: args.existing.description,
|
||||||
|
permissions: args.before.permissions,
|
||||||
|
specialPermissions: args.before.specialPermissions,
|
||||||
|
dataScope: args.before.dataScope,
|
||||||
|
status: args.before.status,
|
||||||
|
},
|
||||||
|
after: this.auditSnapshot(args.role),
|
||||||
|
affectedUserCount: args.affectedUserIds.length,
|
||||||
|
affectedUserIds: args.affectedUserIds,
|
||||||
|
changes: args.diff,
|
||||||
|
...(args.input.reason === undefined ? {} : { reason: args.input.reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistRemove(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
args: {
|
||||||
|
id: string;
|
||||||
|
existing: { code: string; name: string };
|
||||||
|
actorId: string;
|
||||||
|
traceId: string | null;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await this.writeOutboxEvent(tx, {
|
||||||
|
action: 'deleted',
|
||||||
|
roleId: args.id,
|
||||||
|
roleCode: args.existing.code,
|
||||||
|
roleName: args.existing.name,
|
||||||
|
actorId: args.actorId,
|
||||||
|
traceId: args.traceId,
|
||||||
|
diff: { added: [], removed: [], specialAdded: [], specialRemoved: [] },
|
||||||
|
affectedUserIds: [],
|
||||||
|
dataScopeBefore: null,
|
||||||
|
dataScopeAfter: null,
|
||||||
|
});
|
||||||
|
await tx.role.delete({ where: { id: args.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeOutboxEvent(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
args: OutboxEventArgs,
|
||||||
|
): Promise<void> {
|
||||||
|
await tx.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
id: ulid(),
|
||||||
|
aggregateType: 'role',
|
||||||
|
aggregateId: args.roleId,
|
||||||
|
eventType: 'role.permissions.changed',
|
||||||
|
payloadJson: this.eventPayload(args),
|
||||||
|
...(args.traceId === null ? {} : { traceId: args.traceId }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeSnapshot(
|
||||||
|
before: RolePermissionSnapshot,
|
||||||
|
input: UpdateRoleInput,
|
||||||
|
): RolePermissionSnapshot {
|
||||||
|
return {
|
||||||
|
permissions:
|
||||||
|
input.permissions === undefined
|
||||||
|
? before.permissions
|
||||||
|
: this.validatePermissions(input.permissions, []).permissions,
|
||||||
|
specialPermissions:
|
||||||
|
input.specialPermissions === undefined
|
||||||
|
? before.specialPermissions
|
||||||
|
: this.validatePermissions([], input.specialPermissions).specialPermissions,
|
||||||
|
dataScope: input.dataScope ?? before.dataScope,
|
||||||
|
status:
|
||||||
|
input.status === undefined
|
||||||
|
? before.status
|
||||||
|
: input.status === 'enabled'
|
||||||
|
? 'active'
|
||||||
|
: 'disabled',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private auditSnapshot(role: RoleDetail): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
name: role.name,
|
||||||
|
permissions: role.permissions,
|
||||||
|
specialPermissions: role.specialPermissions,
|
||||||
|
dataScope: role.dataScope,
|
||||||
|
status: role.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshotOf(role: RoleWithDetail): RolePermissionSnapshot {
|
||||||
|
const codes = splitCodes(effectiveCodes(role));
|
||||||
|
return {
|
||||||
|
permissions: codes.normal,
|
||||||
|
specialPermissions: codes.special,
|
||||||
|
dataScope: aggregateDataScope(role.dataScopes, role.isSystem),
|
||||||
|
status: role.status === 'ACTIVE' ? 'active' : 'disabled',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSystemRoleMutable(role: { isSystem: boolean }, input: UpdateRoleInput): void {
|
||||||
|
if (!role.isSystem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const touchesProtected =
|
||||||
|
input.description !== undefined ||
|
||||||
|
input.permissions !== undefined ||
|
||||||
|
input.specialPermissions !== undefined ||
|
||||||
|
input.dataScope !== undefined ||
|
||||||
|
input.status !== undefined;
|
||||||
|
if (touchesProtected) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||||
|
message: '系统角色不可修改权限 / 数据范围 / 状态,仅显示名可改',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validatePermissions(
|
||||||
|
normalInput: readonly string[],
|
||||||
|
specialInput: readonly string[],
|
||||||
|
): { permissions: PermissionCode[]; specialPermissions: PermissionCode[] } {
|
||||||
|
let parsedNormal: PermissionCode[];
|
||||||
|
let parsedSpecial: PermissionCode[];
|
||||||
|
try {
|
||||||
|
const combined = splitPermissions([...normalInput, ...specialInput]);
|
||||||
|
parsedNormal = combined.permissions;
|
||||||
|
parsedSpecial = combined.specialPermissions;
|
||||||
|
} catch {
|
||||||
|
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '存在未登记的权限点编码' });
|
||||||
|
}
|
||||||
|
const normalSet = new Set<string>(parsedNormal);
|
||||||
|
for (const code of normalInput) {
|
||||||
|
if (!normalSet.has(code)) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: `特殊权限 ${code} 必须通过 specialPermissions 单独授予`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const specialSet = new Set<string>(parsedSpecial);
|
||||||
|
for (const code of specialInput) {
|
||||||
|
if (!specialSet.has(code)) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: `普通权限 ${code} 不能放入 specialPermissions`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { permissions: parsedNormal, specialPermissions: parsedSpecial };
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSod(snapshot: RolePermissionSnapshot): void {
|
||||||
|
const conflicts = findSodConflicts([...snapshot.permissions, ...snapshot.specialPermissions]);
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.SOD_VIOLATION,
|
||||||
|
message: `违反职责互斥:${conflicts.map((conflict) => conflict.label).join(';')}`,
|
||||||
|
details: { conflicts },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dataScopeRows(
|
||||||
|
type: DataScopeType,
|
||||||
|
targetIds: readonly string[],
|
||||||
|
): { id: string; scopeType: string; targetId: string | null }[] {
|
||||||
|
if (type !== 'customer_groups' || targetIds.length === 0) {
|
||||||
|
return [{ id: ulid(), scopeType: type, targetId: null }];
|
||||||
|
}
|
||||||
|
return targetIds.map((targetId) => ({ id: ulid(), scopeType: type, targetId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureNameAvailable(name: string, excludeId?: string): Promise<void> {
|
||||||
|
const existing = await this.prisma.role.findFirst({
|
||||||
|
where: { name, ...(excludeId === undefined ? {} : { NOT: { id: excludeId } }) },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing !== null) {
|
||||||
|
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '角色名称已存在' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 软提示(不阻断):角色数量 / 超管人数超过 PRD 建议值;A4 迁 system_configs */
|
||||||
|
private async buildWarnings(roleCode: string): Promise<string[]> {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const activeRoleCount = await this.prisma.role.count({ where: { status: 'ACTIVE' } });
|
||||||
|
if (activeRoleCount > ROLE_SOFT_LIMIT) {
|
||||||
|
warnings.push(
|
||||||
|
`启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(ROLE_SOFT_LIMIT)},建议合并角色`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (roleCode === 'super_admin') {
|
||||||
|
const superAdminCount = await this.prisma.user.count({
|
||||||
|
where: { status: 'ACTIVE', roles: { some: { role: { code: 'super_admin' } } } },
|
||||||
|
});
|
||||||
|
if (superAdminCount > SUPER_ADMIN_SOFT_LIMIT) {
|
||||||
|
warnings.push(
|
||||||
|
`超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private eventPayload(input: {
|
||||||
|
action: 'created' | 'updated' | 'deleted';
|
||||||
|
roleId: string;
|
||||||
|
roleCode: string;
|
||||||
|
roleName: string;
|
||||||
|
actorId: string;
|
||||||
|
traceId: string | null;
|
||||||
|
diff: PermissionDiff;
|
||||||
|
affectedUserIds: string[];
|
||||||
|
dataScopeBefore: string | null;
|
||||||
|
dataScopeAfter: string | null;
|
||||||
|
}): Prisma.InputJsonObject {
|
||||||
|
return {
|
||||||
|
action: input.action,
|
||||||
|
roleId: input.roleId,
|
||||||
|
roleCode: input.roleCode,
|
||||||
|
roleName: input.roleName,
|
||||||
|
actorId: input.actorId,
|
||||||
|
traceId: input.traceId,
|
||||||
|
changes: {
|
||||||
|
permissions: { added: input.diff.added, removed: input.diff.removed },
|
||||||
|
specialPermissions: {
|
||||||
|
added: input.diff.specialAdded,
|
||||||
|
removed: input.diff.specialRemoved,
|
||||||
|
},
|
||||||
|
dataScope: { before: input.dataScopeBefore, after: input.dataScopeAfter },
|
||||||
|
},
|
||||||
|
affectedUserIds: input.affectedUserIds,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -4,13 +4,14 @@ import {
|
|||||||
ErrorCode,
|
ErrorCode,
|
||||||
type ListResponse,
|
type ListResponse,
|
||||||
Permission,
|
Permission,
|
||||||
|
SUPER_ADMIN_SOFT_LIMIT,
|
||||||
ulid,
|
ulid,
|
||||||
type UpdateUserInput,
|
type UpdateUserInput,
|
||||||
type UserDetail,
|
type UserDetail,
|
||||||
type UserListQuery,
|
type UserListQuery,
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
} from '@lingxi/shared';
|
} from '@lingxi/shared';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
@@ -31,6 +32,8 @@ function hasPermission(permissions: string[], code: string): boolean {
|
|||||||
/** 用户管理:列表(游标 + 筛选 + 排序)、详情、新建、更新、停用 / 启用 */
|
/** 用户管理:列表(游标 + 筛选 + 排序)、详情、新建、更新、停用 / 启用 */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
|
private readonly logger = new Logger('UsersService');
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
@Inject(PasswordService) private readonly passwords: PasswordService,
|
@Inject(PasswordService) private readonly passwords: PasswordService,
|
||||||
@@ -90,6 +93,7 @@ export class UsersService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw this.mapUniqueError(error);
|
throw this.mapUniqueError(error);
|
||||||
}
|
}
|
||||||
|
await this.warnIfSuperAdminExceeded(input.roleIds);
|
||||||
return this.detail(id, permissions);
|
return this.detail(id, permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +129,9 @@ export class UsersService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw this.mapUniqueError(error);
|
throw this.mapUniqueError(error);
|
||||||
}
|
}
|
||||||
|
if (input.roleIds !== undefined) {
|
||||||
|
await this.warnIfSuperAdminExceeded(input.roleIds);
|
||||||
|
}
|
||||||
return this.detail(id, permissions);
|
return this.detail(id, permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,12 +180,26 @@ export class UsersService {
|
|||||||
return this.detail(id, permissions);
|
return this.detail(id, permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 客户转交占位:A3 客户模块落地前按契约返回 42201(不自造 5xx 错误码) */
|
/**
|
||||||
transferCustomers(): never {
|
* 超管人数软上限(PRD §5.4.1 建议 ≤ 2 人):只记录告警,不阻断保存;
|
||||||
throw new AppException({
|
* 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。
|
||||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
*/
|
||||||
message: '客户转交将在客户模块(A3)提供',
|
private async warnIfSuperAdminExceeded(roleIds: readonly string[]): Promise<void> {
|
||||||
|
const superAdminRole = await this.prisma.role.findUnique({
|
||||||
|
where: { code: 'super_admin' },
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
if (superAdminRole === null || !roleIds.includes(superAdminRole.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const count = await this.prisma.user.count({
|
||||||
|
where: { status: 'ACTIVE', roles: { some: { roleId: superAdminRole.id } } },
|
||||||
|
});
|
||||||
|
if (count > SUPER_ADMIN_SOFT_LIMIT) {
|
||||||
|
this.logger.warn(
|
||||||
|
`超级管理员 ${String(count)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人(仅提示,不阻断)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findOrThrow(id: string): Promise<UserWithRelations> {
|
private async findOrThrow(id: string): Promise<UserWithRelations> {
|
||||||
@@ -190,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> {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,5 +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)}`,
|
||||||
|
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;
|
||||||
+933
-45
File diff suppressed because it is too large.
Load diff
@@ -42,7 +42,7 @@ const me = {
|
|||||||
user: userSummary,
|
user: userSummary,
|
||||||
roles: userSummary.roles,
|
roles: userSummary.roles,
|
||||||
permissions: ['user:view'],
|
permissions: ['user:view'],
|
||||||
dataScope: { type: 'all' },
|
dataScopes: [{ type: 'all' }],
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { RoleListQueryInput } from './roles';
|
||||||
import type { UserListQueryInput } from './users';
|
import type { UserListQueryInput } from './users';
|
||||||
|
|
||||||
/** TanStack Query 缓存键(唯一出处,避免散落字符串) */
|
/** TanStack Query 缓存键(唯一出处,避免散落字符串) */
|
||||||
@@ -7,6 +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,
|
||||||
|
role: (roleId: string) => ['roles', 'detail', roleId] 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,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRole,
|
||||||
|
deleteRole,
|
||||||
|
fetchPermissionTree,
|
||||||
|
fetchRoleDetail,
|
||||||
|
fetchRoleList,
|
||||||
|
toRoleListSearchParams,
|
||||||
|
updateRole,
|
||||||
|
} from './roles';
|
||||||
|
|
||||||
|
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||||
|
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
|
||||||
|
|
||||||
|
const roleListItem = {
|
||||||
|
id: ROLE_ID,
|
||||||
|
code: 'reviewer',
|
||||||
|
name: '审核员',
|
||||||
|
description: '脚本与素材审核',
|
||||||
|
isSystem: false,
|
||||||
|
status: 'active',
|
||||||
|
permissionCount: 4,
|
||||||
|
specialPermissionCount: 0,
|
||||||
|
userCount: 3,
|
||||||
|
dataScope: { type: 'department_and_children' },
|
||||||
|
createdAt: '2026-10-27T02:40:00.000Z',
|
||||||
|
updatedAt: '2026-10-27T02:40:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleDetail = {
|
||||||
|
...roleListItem,
|
||||||
|
permissions: ['script:read', 'script:review', 'material:read', 'material:review'],
|
||||||
|
specialPermissions: [],
|
||||||
|
boundUsers: [
|
||||||
|
{
|
||||||
|
id: USER_ID,
|
||||||
|
name: '赵一诺',
|
||||||
|
account: 'zhao.yinuo',
|
||||||
|
phoneMasked: '136****3188',
|
||||||
|
departmentName: '内容生产中心 / 素材二组',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const permissionTree = {
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
module: 'script',
|
||||||
|
moduleLabel: '脚本',
|
||||||
|
sortOrder: 10,
|
||||||
|
permissions: [
|
||||||
|
{ code: 'script:read', name: '查看脚本', description: '脚本库', isSpecial: false },
|
||||||
|
{ code: 'script:review', name: '审核脚本', description: '入库审核', isSpecial: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: 'delivery',
|
||||||
|
moduleLabel: '导出交付',
|
||||||
|
sortOrder: 40,
|
||||||
|
permissions: [
|
||||||
|
{ code: 'delivery:read', name: '查看交付记录', description: '交付清单', isSpecial: false },
|
||||||
|
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
specialPermissions: [
|
||||||
|
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
|
||||||
|
],
|
||||||
|
sodPairs: [
|
||||||
|
{
|
||||||
|
id: 'script-write-review',
|
||||||
|
label: '脚本生成 × 脚本审核',
|
||||||
|
description: '同一角色不得同时拥有',
|
||||||
|
permissions: ['script:write', 'script:review'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalPermissions: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveResult = {
|
||||||
|
role: roleDetail,
|
||||||
|
affectedUserCount: 3,
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
function toUrl(input: string | URL | Request | undefined): string {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
if (input instanceof URL) {
|
||||||
|
return input.href;
|
||||||
|
}
|
||||||
|
return input?.url ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyOf(init: RequestInit | undefined): unknown {
|
||||||
|
return typeof init?.body === 'string' ? JSON.parse(init.body) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('角色列表请求参数', () => {
|
||||||
|
it('按后端 query 约定拼装 snake_case 参数,空关键词不传', () => {
|
||||||
|
const params = toRoleListSearchParams({
|
||||||
|
limit: 100,
|
||||||
|
status: 'all',
|
||||||
|
sort: 'created_at',
|
||||||
|
order: 'desc',
|
||||||
|
keyword: ' 审核 ',
|
||||||
|
});
|
||||||
|
expect(params.toString()).toBe(
|
||||||
|
'limit=100&keyword=%E5%AE%A1%E6%A0%B8&status=all&sort=created_at&order=desc',
|
||||||
|
);
|
||||||
|
const empty = toRoleListSearchParams({ limit: 20, status: 'all', keyword: '' });
|
||||||
|
expect(empty.has('keyword')).toBe(false);
|
||||||
|
expect(empty.has('cursor')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('带游标时完整输出', () => {
|
||||||
|
const params = toRoleListSearchParams({ limit: 20, status: 'disabled', cursor: 'cursor-2' });
|
||||||
|
expect(params.get('cursor')).toBe('cursor-2');
|
||||||
|
expect(params.get('status')).toBe('disabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('角色 API 客户端', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
useAuthStore.getState().setSession({
|
||||||
|
accessToken: 'token',
|
||||||
|
user: { id: ROLE_ID, name: '灵犀管理员', account: 'admin' },
|
||||||
|
permissions: ['role:view'],
|
||||||
|
});
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('解析角色列表信封与详情', async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ items: [roleListItem], next_cursor: 'cursor-2' }))
|
||||||
|
.mockResolvedValueOnce(jsonResponse(roleDetail));
|
||||||
|
|
||||||
|
const list = await fetchRoleList({ limit: 100, status: 'all' });
|
||||||
|
expect(list.items).toHaveLength(1);
|
||||||
|
expect(list.next_cursor).toBe('cursor-2');
|
||||||
|
expect(toUrl(fetchMock.mock.calls[0]?.[0])).toContain('/roles?limit=100&status=all');
|
||||||
|
|
||||||
|
const detail = await fetchRoleDetail(ROLE_ID);
|
||||||
|
expect(detail.permissions).toContain('script:review');
|
||||||
|
expect(detail.boundUsers[0]?.name).toBe('赵一诺');
|
||||||
|
expect(toUrl(fetchMock.mock.calls[1]?.[0])).toContain(`/roles/${ROLE_ID}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('解析权限点目录', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(permissionTree));
|
||||||
|
const tree = await fetchPermissionTree();
|
||||||
|
expect(tree.totalPermissions).toBe(4);
|
||||||
|
expect(tree.specialPermissions[0]?.code).toBe('delivery:export');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('新建角色以 camelCase body 提交并解析保存结果', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(saveResult));
|
||||||
|
const result = await createRole({
|
||||||
|
name: '审核员',
|
||||||
|
description: '脚本与素材审核',
|
||||||
|
permissions: ['script:read', 'script:review'],
|
||||||
|
dataScope: { type: 'department_and_children' },
|
||||||
|
});
|
||||||
|
expect(result.affectedUserCount).toBe(3);
|
||||||
|
const init = fetchMock.mock.calls[0]?.[1];
|
||||||
|
expect(init?.method).toBe('POST');
|
||||||
|
expect(bodyOf(init)).toEqual({
|
||||||
|
name: '审核员',
|
||||||
|
description: '脚本与素材审核',
|
||||||
|
permissions: ['script:read', 'script:review'],
|
||||||
|
dataScope: { type: 'department_and_children' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('更新角色状态与停用原因', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(saveResult));
|
||||||
|
await updateRole(ROLE_ID, { status: 'disabled', reason: '岗位调整' });
|
||||||
|
const init = fetchMock.mock.calls[0]?.[1];
|
||||||
|
expect(init?.method).toBe('PATCH');
|
||||||
|
expect(bodyOf(init)).toEqual({ status: 'disabled', reason: '岗位调整' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('删除角色返回 204', async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValue(new Response(null, { status: 204 }));
|
||||||
|
await expect(deleteRole(ROLE_ID)).resolves.toBeUndefined();
|
||||||
|
expect(fetchMock.mock.calls[0]?.[1]?.method).toBe('DELETE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('响应用共享 schema 校验:非法权限点编码直接报错', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
jsonResponse({ ...roleDetail, permissions: ['ghost:permission'] }),
|
||||||
|
);
|
||||||
|
await expect(fetchRoleDetail(ROLE_ID)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
type CreateRoleInput,
|
||||||
|
createRoleInputSchema,
|
||||||
|
type PermissionTree,
|
||||||
|
permissionTreeSchema,
|
||||||
|
type RoleDetail,
|
||||||
|
roleDetailSchema,
|
||||||
|
type RoleListQuery,
|
||||||
|
type RoleListResponse,
|
||||||
|
roleListResponseSchema,
|
||||||
|
type RoleSaveResult,
|
||||||
|
roleSaveResultSchema,
|
||||||
|
type UpdateRoleInput,
|
||||||
|
updateRoleInputSchema,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
|
||||||
|
import { request } from './client';
|
||||||
|
import { ENDPOINTS } from './endpoints';
|
||||||
|
|
||||||
|
/** 角色管理列表查询(query 参数 snake_case;角色管理页始终传全量状态) */
|
||||||
|
export interface RoleListQueryInput extends Partial<RoleListQuery> {
|
||||||
|
limit: number;
|
||||||
|
cursor?: string;
|
||||||
|
/** 排除指定角色 id(模板 / 复制来源场景由前端过滤,接口不感知) */
|
||||||
|
excludeRoleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组装 GET /roles 的 query 参数;空值不落 query,避免后端严格校验报错 */
|
||||||
|
export function toRoleListSearchParams(query: RoleListQueryInput): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('limit', String(query.limit));
|
||||||
|
if (query.cursor !== undefined && query.cursor !== '') {
|
||||||
|
params.set('cursor', query.cursor);
|
||||||
|
}
|
||||||
|
if (query.keyword !== undefined && query.keyword.trim() !== '') {
|
||||||
|
params.set('keyword', query.keyword.trim());
|
||||||
|
}
|
||||||
|
if (query.status !== undefined) {
|
||||||
|
params.set('status', query.status);
|
||||||
|
}
|
||||||
|
if (query.sort !== undefined) {
|
||||||
|
params.set('sort', query.sort);
|
||||||
|
}
|
||||||
|
if (query.order !== undefined) {
|
||||||
|
params.set('order', query.order);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRoleList(query: RoleListQueryInput): Promise<RoleListResponse> {
|
||||||
|
const search = toRoleListSearchParams(query).toString();
|
||||||
|
const path = search === '' ? ENDPOINTS.roles : `${ENDPOINTS.roles}?${search}`;
|
||||||
|
const payload: unknown = await request(path);
|
||||||
|
return roleListResponseSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRoleDetail(roleId: string): Promise<RoleDetail> {
|
||||||
|
const payload: unknown = await request(ENDPOINTS.role(roleId));
|
||||||
|
return roleDetailSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD(与后端同源) */
|
||||||
|
export async function fetchPermissionTree(): Promise<PermissionTree> {
|
||||||
|
const payload: unknown = await request(ENDPOINTS.permissions);
|
||||||
|
return permissionTreeSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRole(input: CreateRoleInput): Promise<RoleSaveResult> {
|
||||||
|
const body = createRoleInputSchema.parse(input);
|
||||||
|
const payload: unknown = await request(ENDPOINTS.roles, { method: 'POST', body });
|
||||||
|
return roleSaveResultSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSaveResult> {
|
||||||
|
const body = updateRoleInputSchema.parse(input);
|
||||||
|
const payload: unknown = await request(ENDPOINTS.role(roleId), { method: 'PATCH', body });
|
||||||
|
return roleSaveResultSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRole(roleId: string): Promise<void> {
|
||||||
|
await request<unknown>(ENDPOINTS.role(roleId), { method: 'DELETE' });
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type {
|
||||||
|
CreateRoleInput,
|
||||||
|
PermissionTree,
|
||||||
|
RoleDetail,
|
||||||
|
RoleListResponse,
|
||||||
|
RoleSaveResult,
|
||||||
|
UpdateRoleInput,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
useMutation,
|
||||||
|
type UseMutationResult,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
type UseQueryResult,
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { queryKeys } from '@/api/query-keys';
|
||||||
|
import {
|
||||||
|
createRole,
|
||||||
|
deleteRole,
|
||||||
|
fetchPermissionTree,
|
||||||
|
fetchRoleDetail,
|
||||||
|
fetchRoleList,
|
||||||
|
type RoleListQueryInput,
|
||||||
|
updateRole,
|
||||||
|
} from '@/api/roles';
|
||||||
|
|
||||||
|
export function useRoleListQuery(query: RoleListQueryInput): UseQueryResult<RoleListResponse> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.roleList(query),
|
||||||
|
queryFn: () => fetchRoleList(query),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoleDetailQuery(roleId: string | null): UseQueryResult<RoleDetail> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.role(roleId ?? ''),
|
||||||
|
queryFn: () => fetchRoleDetail(roleId ?? ''),
|
||||||
|
enabled: roleId !== null && roleId !== '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限点目录(10 模块 / 30 点 / 6 特殊 / 3 对 SoD):登录后长期复用 */
|
||||||
|
export function usePermissionTreeQuery(): UseQueryResult<PermissionTree> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.permissions,
|
||||||
|
queryFn: fetchPermissionTree,
|
||||||
|
staleTime: 30 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidateRoles(): () => Promise<void> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.roles });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRoleMutation(): UseMutationResult<RoleSaveResult, Error, CreateRoleInput> {
|
||||||
|
const invalidate = useInvalidateRoles();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateRoleInput) => createRole(input),
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
await invalidate();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRoleMutation(): UseMutationResult<
|
||||||
|
RoleSaveResult,
|
||||||
|
Error,
|
||||||
|
{ roleId: string; input: UpdateRoleInput }
|
||||||
|
> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ roleId, input }: { roleId: string; input: UpdateRoleInput }) =>
|
||||||
|
updateRole(roleId, input),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.setQueryData(queryKeys.role(result.role.id), result.role);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.roles });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteRoleMutation(): UseMutationResult<void, Error, string> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (roleId: string) => deleteRole(roleId),
|
||||||
|
onSuccess: (_result, roleId) => {
|
||||||
|
queryClient.removeQueries({ queryKey: queryKeys.role(roleId) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.roles });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { getUserSectionTrail } from '@/navigation/breadcrumbs';
|
import { getRoleSectionTrail, getUserSectionTrail } from '@/navigation/breadcrumbs';
|
||||||
import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
|
import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
|
||||||
|
|
||||||
export interface PageBreadcrumbsProps {
|
export interface PageBreadcrumbsProps {
|
||||||
@@ -9,7 +9,10 @@ export interface PageBreadcrumbsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
|
export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
|
||||||
const entries = getUserSectionTrail(pathname) ?? getBreadcrumbs(NAV_MODULES, pathname);
|
const entries =
|
||||||
|
getUserSectionTrail(pathname) ??
|
||||||
|
getRoleSectionTrail(pathname) ??
|
||||||
|
getBreadcrumbs(NAV_MODULES, pathname);
|
||||||
return (
|
return (
|
||||||
<nav className="lx-breadcrumbs" aria-label="面包屑">
|
<nav className="lx-breadcrumbs" aria-label="面包屑">
|
||||||
{entries.map((entry, index) => {
|
{entries.map((entry, index) => {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import '@/styles/global.css';
|
|||||||
import '@/styles/layout.css';
|
import '@/styles/layout.css';
|
||||||
import '@/styles/pages.css';
|
import '@/styles/pages.css';
|
||||||
import '@/styles/users.css';
|
import '@/styles/users.css';
|
||||||
|
import '@/styles/roles.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';
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { getRoleSectionTrail, getUserSectionTrail } from './breadcrumbs';
|
||||||
|
|
||||||
|
describe('面包屑子路由补全', () => {
|
||||||
|
it('角色新建页:工作台 / 管理中心 / 角色管理 / 新建角色', () => {
|
||||||
|
const trail = getRoleSectionTrail('/system/roles/new');
|
||||||
|
expect(trail?.map((entry) => entry.label)).toEqual([
|
||||||
|
'工作台',
|
||||||
|
'管理中心',
|
||||||
|
'角色管理',
|
||||||
|
'新建角色',
|
||||||
|
]);
|
||||||
|
expect(trail?.[2]?.path).toBe('/system/roles');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('角色编辑器:末级为角色编辑', () => {
|
||||||
|
const trail = getRoleSectionTrail('/system/roles/01HZZZZZZZZZZZZZZZZZZZZZZZ');
|
||||||
|
expect(trail?.map((entry) => entry.label)).toEqual([
|
||||||
|
'工作台',
|
||||||
|
'管理中心',
|
||||||
|
'角色管理',
|
||||||
|
'角色编辑',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('角色列表页由导航配置派生,不走子路由补全', () => {
|
||||||
|
expect(getRoleSectionTrail('/system/roles')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('用户子路由补全不受影响', () => {
|
||||||
|
expect(getUserSectionTrail('/system/users/new')?.map((entry) => entry.label)).toEqual([
|
||||||
|
'工作台',
|
||||||
|
'管理中心',
|
||||||
|
'用户管理',
|
||||||
|
'新建用户',
|
||||||
|
]);
|
||||||
|
expect(getUserSectionTrail('/system/users/01HZZZZZZZZZZZZZZZZZZZZZZZ')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,16 +2,37 @@ import { ROUTES } from '@/routes';
|
|||||||
|
|
||||||
import type { BreadcrumbEntry } from './nav-config';
|
import type { BreadcrumbEntry } from './nav-config';
|
||||||
|
|
||||||
|
/** 角色模块下的子路由(新建 / 编辑器)不在 nav-config 的菜单项里,这里补齐面包屑 */
|
||||||
|
export function getRoleSectionTrail(pathname: string): BreadcrumbEntry[] | null {
|
||||||
|
if (pathname === ROUTES.system.roleNew) {
|
||||||
|
return [
|
||||||
|
{ key: 'home', label: '工作台', path: ROUTES.home },
|
||||||
|
{ key: 'system', label: '管理中心' },
|
||||||
|
{ key: 'roles', label: '角色管理', path: ROUTES.system.roles },
|
||||||
|
{ key: 'role-new', label: '新建角色' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (/^\/system\/roles\/[^/]+$/.test(pathname)) {
|
||||||
|
return [
|
||||||
|
{ key: 'home', label: '工作台', path: ROUTES.home },
|
||||||
|
{ key: 'system', label: '管理中心' },
|
||||||
|
{ key: 'roles', label: '角色管理', path: ROUTES.system.roles },
|
||||||
|
{ key: 'role-detail', label: '角色编辑' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return 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: '新建用户' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -19,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: '成员详情' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +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,只读角色也能进入角色页', () => {
|
||||||
|
const rolesItem = NAV_MODULES.find((module) => module.key === 'system')?.items.find(
|
||||||
|
(item) => item.key === 'roles',
|
||||||
|
);
|
||||||
|
expect(rolesItem?.permission).toBe('role:view');
|
||||||
|
expect(findActiveNav(NAV_MODULES, '/system/roles/new')?.item?.label).toBe('角色管理');
|
||||||
|
expect(findActiveNav(NAV_MODULES, '/system/roles/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.module.key).toBe(
|
||||||
|
'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');
|
||||||
@@ -43,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(['工作台', '管理中心', '部门管理']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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,35 +249,51 @@ 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_MANAGE,
|
permission: Permission.ROLE_VIEW,
|
||||||
description: '角色、权限点与职责互斥',
|
description: '角色、权限点与职责互斥',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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: '客户级扩展字段与审批',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function meWith(mustChangePassword: boolean): MeResponse {
|
|||||||
},
|
},
|
||||||
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
|
roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }],
|
||||||
permissions: ['user:view', 'user:manage'],
|
permissions: ['user:view', 'user:manage'],
|
||||||
dataScope: { type: 'all' },
|
dataScopes: [{ type: 'all' }],
|
||||||
mustChangePassword,
|
mustChangePassword,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
|
||||||
|
import {
|
||||||
|
PERMISSION_CATALOG,
|
||||||
|
permissionsByModule,
|
||||||
|
permissionTreeSchema,
|
||||||
|
roleDetailSchema,
|
||||||
|
SOD_PAIRS,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { RoleDetailPanel } from './RoleDetailPanel';
|
||||||
|
|
||||||
|
vi.mock('@lingxi/ui', () => ({
|
||||||
|
PageEmpty: ({ title }: { title?: string }) => <div data-testid="page-empty">{title}</div>,
|
||||||
|
ErrorState: ({ title }: { title?: string }) => <div data-testid="error-state">{title}</div>,
|
||||||
|
PageSkeleton: () => <div data-testid="page-skeleton" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@douyinfe/semi-ui-19', () => ({
|
||||||
|
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
|
||||||
|
<button type="button" onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||||
|
|
||||||
|
function roleNode(permission: (typeof PERMISSION_CATALOG)[number]): {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
isSpecial: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
code: permission.code,
|
||||||
|
name: permission.name,
|
||||||
|
description: permission.description,
|
||||||
|
isSpecial: permission.isSpecial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree: PermissionTree = permissionTreeSchema.parse({
|
||||||
|
modules: permissionsByModule().map((module) => ({
|
||||||
|
module: module.module,
|
||||||
|
moduleLabel: module.moduleLabel,
|
||||||
|
sortOrder: module.sortOrder,
|
||||||
|
permissions: module.permissions.map(roleNode),
|
||||||
|
})),
|
||||||
|
specialPermissions: PERMISSION_CATALOG.filter((permission) => permission.isSpecial).map(roleNode),
|
||||||
|
sodPairs: SOD_PAIRS.map((pair) => ({
|
||||||
|
id: pair.id,
|
||||||
|
label: pair.label,
|
||||||
|
description: pair.description,
|
||||||
|
permissions: [pair.permissions[0], pair.permissions[1]],
|
||||||
|
})),
|
||||||
|
totalPermissions: PERMISSION_CATALOG.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const detail: RoleDetail = roleDetailSchema.parse({
|
||||||
|
id: ROLE_ID,
|
||||||
|
code: 'account_owner',
|
||||||
|
name: '客户负责人',
|
||||||
|
description: '客户资料、脚本审核与交付验收',
|
||||||
|
isSystem: false,
|
||||||
|
status: 'active',
|
||||||
|
permissionCount: 2,
|
||||||
|
specialPermissionCount: 1,
|
||||||
|
userCount: 4,
|
||||||
|
dataScope: { type: 'customer_groups', targetIds: [] },
|
||||||
|
permissions: ['script:read', 'script:review'],
|
||||||
|
specialPermissions: ['delivery:export'],
|
||||||
|
boundUsers: [
|
||||||
|
{
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZ3',
|
||||||
|
name: '陈默',
|
||||||
|
account: 'chen.mo',
|
||||||
|
phoneMasked: '138****2046',
|
||||||
|
departmentName: '内容生产中心 / 素材一组',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZ4',
|
||||||
|
name: '高岑',
|
||||||
|
account: 'gao.cen',
|
||||||
|
phoneMasked: '186****4402',
|
||||||
|
departmentName: '交付中心 / 客户成功组',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZ5',
|
||||||
|
name: '江岚',
|
||||||
|
account: 'jiang.lan',
|
||||||
|
phoneMasked: '159****3320',
|
||||||
|
departmentName: '平台技术部 / 平台运维组',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZ6',
|
||||||
|
name: '何岸',
|
||||||
|
account: 'he.an',
|
||||||
|
phoneMasked: '135****6641',
|
||||||
|
departmentName: '交付中心 / 客户成功组',
|
||||||
|
status: 'disabled',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: '2026-10-25T01:30:00.000Z',
|
||||||
|
updatedAt: '2026-10-25T01:30:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPanel(overrides: Partial<Parameters<typeof RoleDetailPanel>[0]> = {}): void {
|
||||||
|
render(
|
||||||
|
<RoleDetailPanel
|
||||||
|
roleId={ROLE_ID}
|
||||||
|
detail={detail}
|
||||||
|
tree={tree}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
canManage
|
||||||
|
onRetry={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onCopy={vi.fn()}
|
||||||
|
onRename={vi.fn()}
|
||||||
|
onDisable={vi.fn()}
|
||||||
|
onEnable={vi.fn()}
|
||||||
|
{...overrides}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('角色详情面板', () => {
|
||||||
|
it('渲染头部、权限摘要、数据范围与特殊权限', () => {
|
||||||
|
renderPanel();
|
||||||
|
expect(screen.getByText('客户负责人')).toBeTruthy();
|
||||||
|
expect(screen.getByText('启用')).toBeTruthy();
|
||||||
|
expect(screen.getByText('account_owner')).toBeTruthy();
|
||||||
|
expect(screen.getByText('指定客户组')).toBeTruthy();
|
||||||
|
expect(screen.getByText('发起导出')).toBeTruthy();
|
||||||
|
expect(document.querySelector('.lx-role-detail__module-count')?.textContent).toContain('2 / 3');
|
||||||
|
expect(document.querySelector('.lx-role-detail__meta')?.textContent).toContain('4 名');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('绑定用户默认展示前 3 名,可展开全部', () => {
|
||||||
|
renderPanel();
|
||||||
|
expect(screen.getByText('陈默')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('何岸')).toBeNull();
|
||||||
|
fireEvent.click(screen.getByText('查看全部 4 名'));
|
||||||
|
expect(screen.getByText('何岸')).toBeTruthy();
|
||||||
|
expect(screen.getByText('收起')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('查看全部 4 名')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无管理权限时不渲染复制 / 停用等写控件', () => {
|
||||||
|
renderPanel({ canManage: false });
|
||||||
|
expect(screen.queryByText('复制')).toBeNull();
|
||||||
|
expect(screen.queryByText('停用')).toBeNull();
|
||||||
|
expect(screen.queryByText('编辑角色')).toBeNull();
|
||||||
|
expect(screen.getByText('查看角色')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未选择角色时展示空态', () => {
|
||||||
|
renderPanel({ roleId: null, detail: undefined });
|
||||||
|
expect(screen.getByTestId('page-empty')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('详情加载失败时展示错误态并可重试', () => {
|
||||||
|
renderPanel({ detail: undefined, error: new Error('boom') });
|
||||||
|
expect(screen.getByTestId('error-state').textContent).toContain('角色详情加载失败');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 详情面板按头部/摘要/范围/特殊/绑定用户分区,集中便于对齐原型 */
|
||||||
|
import { Button } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
|
||||||
|
import { ErrorState, PageEmpty, PageSkeleton } from '@lingxi/ui';
|
||||||
|
import {
|
||||||
|
IconCopy,
|
||||||
|
IconEdit,
|
||||||
|
IconPause,
|
||||||
|
IconPlay,
|
||||||
|
IconShield,
|
||||||
|
IconStar,
|
||||||
|
IconView,
|
||||||
|
} from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildPermissionMetaMap,
|
||||||
|
formatRoleDateTime,
|
||||||
|
permissionNameOf,
|
||||||
|
scopeDescriptionOf,
|
||||||
|
scopeLabelOf,
|
||||||
|
summarizePermissionModules,
|
||||||
|
} from './role-display';
|
||||||
|
import { RoleStatusTag } from './RoleStatusTag';
|
||||||
|
|
||||||
|
export interface RoleDetailPanelProps {
|
||||||
|
roleId: string | null;
|
||||||
|
detail: RoleDetail | undefined;
|
||||||
|
tree: PermissionTree | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
canManage: boolean;
|
||||||
|
onRetry: () => void;
|
||||||
|
onEdit: (roleId: string) => void;
|
||||||
|
onCopy: (role: RoleDetail) => void;
|
||||||
|
onRename: (role: RoleDetail) => void;
|
||||||
|
onDisable: (role: RoleDetail) => void;
|
||||||
|
onEnable: (role: RoleDetail) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleAvatar({ status }: { status: 'active' | 'disabled' }): ReactNode {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
status === 'active' ? 'lx-role-detail__avatar' : 'lx-role-detail__avatar is-disabled'
|
||||||
|
}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<IconShield size={22} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailHead({
|
||||||
|
role,
|
||||||
|
canManage,
|
||||||
|
onEdit,
|
||||||
|
onCopy,
|
||||||
|
onRename,
|
||||||
|
onDisable,
|
||||||
|
onEnable,
|
||||||
|
}: Pick<
|
||||||
|
RoleDetailPanelProps,
|
||||||
|
'canManage' | 'onEdit' | 'onCopy' | 'onRename' | 'onDisable' | 'onEnable'
|
||||||
|
> & {
|
||||||
|
role: RoleDetail;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<header className="lx-role-detail__head">
|
||||||
|
<RoleAvatar status={role.status} />
|
||||||
|
<div className="lx-role-detail__ident">
|
||||||
|
<div className="lx-role-detail__name">
|
||||||
|
<h2>{role.name}</h2>
|
||||||
|
<RoleStatusTag status={role.status} />
|
||||||
|
{role.isSystem ? <span className="lx-role-tag lx-role-tag--builtin">内置</span> : null}
|
||||||
|
</div>
|
||||||
|
<p className="lx-role-detail__desc">{role.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-role-detail__actions">
|
||||||
|
{canManage ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
icon={<IconCopy size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
onCopy(role);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
{role.isSystem ? (
|
||||||
|
<Button
|
||||||
|
icon={<IconEdit size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
onRename(role);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
改名
|
||||||
|
</Button>
|
||||||
|
) : role.status === 'active' ? (
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
icon={<IconPause size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
onDisable(role);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
停用
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
icon={<IconPlay size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
onEnable(role);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
启用
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionSummary({
|
||||||
|
role,
|
||||||
|
tree,
|
||||||
|
}: {
|
||||||
|
role: RoleDetail;
|
||||||
|
tree: PermissionTree | undefined;
|
||||||
|
}): ReactNode {
|
||||||
|
const selected = [...role.permissions, ...role.specialPermissions];
|
||||||
|
const modules = tree === undefined ? [] : summarizePermissionModules(tree, selected);
|
||||||
|
return (
|
||||||
|
<section className="lx-role-detail__section">
|
||||||
|
<div className="lx-role-detail__section-head">
|
||||||
|
<h3>权限点摘要</h3>
|
||||||
|
<span className="lx-role-detail__count">
|
||||||
|
已选 {role.permissionCount + role.specialPermissionCount} /{' '}
|
||||||
|
{tree?.totalPermissions ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{modules.length === 0 ? (
|
||||||
|
<p className="lx-role-detail__muted">权限点目录加载中…</p>
|
||||||
|
) : (
|
||||||
|
<div className="lx-role-detail__modules">
|
||||||
|
{modules.map((module) => (
|
||||||
|
<div
|
||||||
|
key={module.module}
|
||||||
|
className={
|
||||||
|
module.selected === 0 ? 'lx-role-detail__module is-zero' : 'lx-role-detail__module'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="lx-role-detail__module-name">{module.moduleLabel}</span>
|
||||||
|
<span className="lx-role-detail__module-count">
|
||||||
|
<b>{module.selected}</b> / {module.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeAndSpecial({
|
||||||
|
role,
|
||||||
|
tree,
|
||||||
|
}: {
|
||||||
|
role: RoleDetail;
|
||||||
|
tree: PermissionTree | undefined;
|
||||||
|
}): ReactNode {
|
||||||
|
const meta = tree === undefined ? undefined : buildPermissionMetaMap(tree);
|
||||||
|
return (
|
||||||
|
<section className="lx-role-detail__section">
|
||||||
|
<div className="lx-role-detail__cols">
|
||||||
|
<div className="lx-role-detail__col">
|
||||||
|
<div className="lx-role-detail__section-head">
|
||||||
|
<h3>数据范围</h3>
|
||||||
|
</div>
|
||||||
|
<p className="lx-role-detail__scope">
|
||||||
|
<b>{scopeLabelOf(role.dataScope.type)}</b>
|
||||||
|
<span>{scopeDescriptionOf(role.dataScope.type)}</span>
|
||||||
|
</p>
|
||||||
|
{role.dataScope.type === 'customer_groups' ? (
|
||||||
|
<p className="lx-role-detail__muted">
|
||||||
|
{(role.dataScope.targetIds ?? []).length === 0
|
||||||
|
? '暂未指定客户组,客户组将在客户模块上线后可选择。'
|
||||||
|
: `已指定 ${String((role.dataScope.targetIds ?? []).length)} 个客户组,客户组名称将在客户模块上线后展示。`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="lx-role-detail__col">
|
||||||
|
<div className="lx-role-detail__section-head">
|
||||||
|
<h3>特殊权限</h3>
|
||||||
|
<span className="lx-role-detail__count">{role.specialPermissions.length} / 6</span>
|
||||||
|
</div>
|
||||||
|
{role.specialPermissions.length === 0 ? (
|
||||||
|
<p className="lx-role-detail__muted">
|
||||||
|
未授予特殊权限,6 项均默认关闭,需单独授予并记录审计。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="lx-role-detail__special-chips">
|
||||||
|
{role.specialPermissions.map((code) => (
|
||||||
|
<span key={code} className="lx-role-detail__special-chip" title={code}>
|
||||||
|
<IconStar size={12} />
|
||||||
|
{meta === undefined ? code : permissionNameOf(meta, code)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoundUsers({ role }: { role: RoleDetail }): ReactNode {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const shown = expanded ? role.boundUsers : role.boundUsers.slice(0, 3);
|
||||||
|
return (
|
||||||
|
<section className="lx-role-detail__section">
|
||||||
|
<div className="lx-role-detail__section-head">
|
||||||
|
<h3>绑定用户</h3>
|
||||||
|
{role.boundUsers.length > 3 ? (
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setExpanded((value) => !value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? '收起' : `查看全部 ${String(role.boundUsers.length)} 名`}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{role.boundUsers.length === 0 ? (
|
||||||
|
<p className="lx-role-detail__muted">暂未绑定用户,可停用或删除该角色。</p>
|
||||||
|
) : (
|
||||||
|
<ul className="lx-role-detail__users">
|
||||||
|
{shown.map((user) => (
|
||||||
|
<li key={user.id} className="lx-role-detail__user">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
user.status === 'disabled'
|
||||||
|
? 'lx-role-detail__user-avatar is-disabled'
|
||||||
|
: 'lx-role-detail__user-avatar'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.name.slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-detail__user-name">{user.name}</span>
|
||||||
|
<span className="lx-role-detail__user-account">{user.account}</span>
|
||||||
|
<span className="lx-role-detail__user-dept">{user.departmentName}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
user.status === 'active' ? 'lx-status-tag is-active' : 'lx-status-tag is-disabled'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.status === 'active' ? '在职' : '停用'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailState({
|
||||||
|
roleId,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: Pick<RoleDetailPanelProps, 'roleId' | 'loading' | 'error' | 'onRetry'>): ReactNode {
|
||||||
|
if (roleId === null) {
|
||||||
|
return (
|
||||||
|
<PageEmpty
|
||||||
|
title="请选择一个角色"
|
||||||
|
description="左侧列表支持搜索与状态筛选,选中角色后在此查看权限详情。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="lx-role-detail__state">
|
||||||
|
<PageSkeleton rows={8} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error !== null) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
className="lx-role-detail__state"
|
||||||
|
title="角色详情加载失败"
|
||||||
|
message={error.message}
|
||||||
|
onRetry={onRetry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右侧角色详情面板(只读):权限摘要、数据范围、特殊权限、绑定用户与编辑入口 */
|
||||||
|
export function RoleDetailPanel(props: RoleDetailPanelProps): ReactNode {
|
||||||
|
const ready = props.roleId !== null && props.detail !== undefined && props.error === null;
|
||||||
|
const detail = props.detail;
|
||||||
|
return (
|
||||||
|
<section className="lx-role-detail" aria-label="角色详情">
|
||||||
|
{ready && detail !== undefined ? (
|
||||||
|
<>
|
||||||
|
<DetailHead
|
||||||
|
role={detail}
|
||||||
|
canManage={props.canManage}
|
||||||
|
onEdit={props.onEdit}
|
||||||
|
onCopy={props.onCopy}
|
||||||
|
onRename={props.onRename}
|
||||||
|
onDisable={props.onDisable}
|
||||||
|
onEnable={props.onEnable}
|
||||||
|
/>
|
||||||
|
<div className="lx-role-detail__body">
|
||||||
|
<div className="lx-role-detail__meta">
|
||||||
|
<span>
|
||||||
|
<em>编码</em>
|
||||||
|
<code>{detail.code}</code>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<em>绑定用户</em>
|
||||||
|
{detail.userCount} 名
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<em>最近更新</em>
|
||||||
|
{formatRoleDateTime(detail.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<PermissionSummary role={detail} tree={props.tree} />
|
||||||
|
<ScopeAndSpecial role={detail} tree={props.tree} />
|
||||||
|
<BoundUsers role={detail} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="lx-role-detail__empty">
|
||||||
|
<DetailState
|
||||||
|
roleId={props.roleId}
|
||||||
|
loading={props.loading}
|
||||||
|
error={props.error}
|
||||||
|
onRetry={props.onRetry}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 编辑器页负责角色加载、草稿编辑、保存编排与离开拦截,集中便于追踪数据流 */
|
||||||
|
import { Button } from '@douyinfe/semi-ui-19';
|
||||||
|
import {
|
||||||
|
Permission,
|
||||||
|
type PermissionNode,
|
||||||
|
type PermissionTree,
|
||||||
|
type RoleDetail,
|
||||||
|
type RoleListItem,
|
||||||
|
type SodConflict,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { ErrorState, notifySuccess, notifyWarning, PageSkeleton } from '@lingxi/ui';
|
||||||
|
import { IconAlert, IconBack, IconCopy, IconPause, IconPlay, IconShield } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useBlocker, useNavigate, useParams } from 'react-router';
|
||||||
|
|
||||||
|
import { ApiError } from '@/api/client';
|
||||||
|
import { Can } from '@/components/Can';
|
||||||
|
import {
|
||||||
|
usePermissionTreeQuery,
|
||||||
|
useRoleDetailQuery,
|
||||||
|
useRoleListQuery,
|
||||||
|
useUpdateRoleMutation,
|
||||||
|
} from '@/hooks/queries/use-roles';
|
||||||
|
import { usePermission } from '@/hooks/use-permission';
|
||||||
|
import { roleDetailPath, roleNewPath, ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DisableRoleDialog,
|
||||||
|
LeaveRoleDialog,
|
||||||
|
SaveRoleDialog,
|
||||||
|
SpecialPermissionDialog,
|
||||||
|
} from './role-dialogs';
|
||||||
|
import { buildPermissionMetaMap, formatRoleDateTime, scopeLabelOf } from './role-display';
|
||||||
|
import {
|
||||||
|
conflictsFromErrorDetails,
|
||||||
|
type RoleDraftErrors,
|
||||||
|
saveResultMessage,
|
||||||
|
validateUpdateDraft,
|
||||||
|
} from './role-utils';
|
||||||
|
import { type RoleEditorTabKey, RoleEditorTabs } from './RoleEditorTabs';
|
||||||
|
import { RoleListPanel } from './RoleListPanel';
|
||||||
|
import { RoleStatusTag } from './RoleStatusTag';
|
||||||
|
import { useRoleEditor } from './use-role-editor';
|
||||||
|
|
||||||
|
/** 左侧角色切换列表:与列表页同一查询口径,全部状态一次拉取 */
|
||||||
|
const PEER_ROLE_QUERY = {
|
||||||
|
limit: 100,
|
||||||
|
status: 'all',
|
||||||
|
sort: 'created_at',
|
||||||
|
order: 'desc',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface EditorContentProps {
|
||||||
|
detail: RoleDetail;
|
||||||
|
tree: PermissionTree | undefined;
|
||||||
|
treeLoading: boolean;
|
||||||
|
treeError: Error | null;
|
||||||
|
onRetryTree: () => void;
|
||||||
|
peers: readonly RoleListItem[];
|
||||||
|
peersLoading: boolean;
|
||||||
|
peersError: Error | null;
|
||||||
|
onRetryPeers: () => void;
|
||||||
|
onSelectPeer: (roleId: string) => void;
|
||||||
|
canManage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConflicts(
|
||||||
|
local: readonly SodConflict[],
|
||||||
|
server: readonly SodConflict[],
|
||||||
|
): SodConflict[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: SodConflict[] = [];
|
||||||
|
for (const conflict of [...local, ...server]) {
|
||||||
|
if (seen.has(conflict.pairId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(conflict.pairId);
|
||||||
|
merged.push(conflict);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function conflictMessages(
|
||||||
|
conflicts: readonly SodConflict[],
|
||||||
|
meta: ReadonlyMap<string, { name: string }> | null,
|
||||||
|
): string {
|
||||||
|
return conflicts
|
||||||
|
.map((conflict) => {
|
||||||
|
const left = meta?.get(conflict.permissions[0])?.name ?? conflict.permissions[0];
|
||||||
|
const right = meta?.get(conflict.permissions[1])?.name ?? conflict.permissions[1];
|
||||||
|
return `命中职责互斥:${conflict.label}。请移除「${left}」或「${right}」后重试。`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorAlert({
|
||||||
|
readonly,
|
||||||
|
locked,
|
||||||
|
conflicts,
|
||||||
|
saveError,
|
||||||
|
meta,
|
||||||
|
}: {
|
||||||
|
readonly: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
conflicts: readonly SodConflict[];
|
||||||
|
saveError: string | null;
|
||||||
|
meta: ReadonlyMap<string, { name: string }> | null;
|
||||||
|
}): ReactNode {
|
||||||
|
if (readonly) {
|
||||||
|
return (
|
||||||
|
<div className="lx-role-editor__alert is-info">
|
||||||
|
<IconAlert size={15} />
|
||||||
|
<span>你没有角色管理权限(role:manage),当前为只读视图。如需调整请联系平台管理员。</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (locked) {
|
||||||
|
return (
|
||||||
|
<div className="lx-role-editor__alert is-info">
|
||||||
|
<IconAlert size={15} />
|
||||||
|
<span>超级管理员为系统内置角色,仅显示名可修改;权限、数据范围与特殊权限由系统保证。</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="lx-role-editor__alert is-danger">
|
||||||
|
<IconAlert size={15} />
|
||||||
|
<span>{conflictMessages(conflicts, meta)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (saveError !== null) {
|
||||||
|
return (
|
||||||
|
<div className="lx-role-editor__alert is-danger">
|
||||||
|
<IconAlert size={15} />
|
||||||
|
<span>{saveError}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorFoot({
|
||||||
|
readonly,
|
||||||
|
locked,
|
||||||
|
conflicts,
|
||||||
|
editor,
|
||||||
|
saving,
|
||||||
|
affectedUsers,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
readonly: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
conflicts: readonly SodConflict[];
|
||||||
|
editor: ReturnType<typeof useRoleEditor>;
|
||||||
|
saving: boolean;
|
||||||
|
affectedUsers: number;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
if (readonly) {
|
||||||
|
return (
|
||||||
|
<footer className="lx-role-editor__foot">
|
||||||
|
<span className="lx-role-editor__impact is-clean">
|
||||||
|
只读视图:可以查看权限配置,但没有修改权限
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const dirty = editor.isDirty;
|
||||||
|
return (
|
||||||
|
<footer className="lx-role-editor__foot">
|
||||||
|
<div className="lx-role-editor__impact">
|
||||||
|
{conflicts.length > 0 ? (
|
||||||
|
<span className="is-blocked">
|
||||||
|
<IconAlert size={14} />
|
||||||
|
存在职责互斥,保存不可用
|
||||||
|
</span>
|
||||||
|
) : !dirty ? (
|
||||||
|
<span className="is-clean">未做修改</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="is-dirty">未保存</span>
|
||||||
|
{editor.diff.totalAdded > 0 ? (
|
||||||
|
<span>
|
||||||
|
本次新增 <b>{editor.diff.totalAdded}</b>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{editor.diff.totalRemoved > 0 ? (
|
||||||
|
<span>
|
||||||
|
移除 <b>{editor.diff.totalRemoved}</b>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{editor.diff.nameChanged || editor.diff.descriptionChanged ? (
|
||||||
|
<span>基本信息已修改</span>
|
||||||
|
) : null}
|
||||||
|
{editor.diff.scopeChanged ? <span>数据范围已修改</span> : null}
|
||||||
|
<span>
|
||||||
|
影响 <b>{affectedUsers}</b> 名用户
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lx-role-editor__foot-actions">
|
||||||
|
<Button onClick={onCancel} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
loading={saving}
|
||||||
|
disabled={conflicts.length > 0 || !dirty}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
{locked ? '保存名称' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorContent({
|
||||||
|
detail,
|
||||||
|
tree,
|
||||||
|
treeLoading,
|
||||||
|
treeError,
|
||||||
|
onRetryTree,
|
||||||
|
peers,
|
||||||
|
peersLoading,
|
||||||
|
peersError,
|
||||||
|
onRetryPeers,
|
||||||
|
onSelectPeer,
|
||||||
|
canManage,
|
||||||
|
}: EditorContentProps): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const editor = useRoleEditor(detail);
|
||||||
|
const updateRole = useUpdateRoleMutation();
|
||||||
|
const locked = detail.isSystem;
|
||||||
|
const readonly = !canManage;
|
||||||
|
const [activeTab, setActiveTab] = useState<RoleEditorTabKey>('basic');
|
||||||
|
const [errors, setErrors] = useState<RoleDraftErrors>({});
|
||||||
|
const draftFingerprint = useMemo(() => JSON.stringify(editor.draft), [editor.draft]);
|
||||||
|
const [serverState, setServerState] = useState<{
|
||||||
|
fingerprint: string;
|
||||||
|
conflicts: readonly SodConflict[];
|
||||||
|
saveError: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
const serverConflicts = useMemo<readonly SodConflict[]>(
|
||||||
|
() =>
|
||||||
|
serverState !== null && serverState.fingerprint === draftFingerprint
|
||||||
|
? serverState.conflicts
|
||||||
|
: [],
|
||||||
|
[serverState, draftFingerprint],
|
||||||
|
);
|
||||||
|
const saveError =
|
||||||
|
serverState !== null && serverState.fingerprint === draftFingerprint
|
||||||
|
? serverState.saveError
|
||||||
|
: null;
|
||||||
|
const [saveOpen, setSaveOpen] = useState(false);
|
||||||
|
const [specialPrompt, setSpecialPrompt] = useState(false);
|
||||||
|
const [specialDialog, setSpecialDialog] = useState<{
|
||||||
|
permission: PermissionNode;
|
||||||
|
granted: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [disableOpen, setDisableOpen] = useState(false);
|
||||||
|
const blocker = useBlocker(editor.isDirty);
|
||||||
|
|
||||||
|
const conflicts = useMemo(
|
||||||
|
() => (detail.isSystem ? [] : mergeConflicts(editor.conflicts, serverConflicts)),
|
||||||
|
[detail.isSystem, editor.conflicts, serverConflicts],
|
||||||
|
);
|
||||||
|
const meta = useMemo(() => (tree === undefined ? null : buildPermissionMetaMap(tree)), [tree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor.isDirty) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const handler = (event: BeforeUnloadEvent): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-deprecated -- 浏览器离开提示仍依赖 returnValue
|
||||||
|
event.returnValue = '';
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', handler);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', handler);
|
||||||
|
};
|
||||||
|
}, [editor.isDirty]);
|
||||||
|
|
||||||
|
const specialChangedCodes = useMemo(
|
||||||
|
() => [...editor.diff.addedSpecial, ...editor.diff.removedSpecial],
|
||||||
|
[editor.diff.addedSpecial, editor.diff.removedSpecial],
|
||||||
|
);
|
||||||
|
|
||||||
|
const attemptSave = (): void => {
|
||||||
|
if (readonly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validation = validateUpdateDraft(detail, editor.draft);
|
||||||
|
setErrors(validation.errors);
|
||||||
|
if (validation.input === null) {
|
||||||
|
setActiveTab('basic');
|
||||||
|
notifyWarning(validation.errors.name ?? validation.errors.description ?? '请检查角色信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editor.isDirty) {
|
||||||
|
notifyWarning('未做修改');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editor.diff.hasSpecialChanges) {
|
||||||
|
setSpecialPrompt(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaveOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doSave = (): void => {
|
||||||
|
const validation = validateUpdateDraft(detail, editor.draft);
|
||||||
|
if (validation.input === null) {
|
||||||
|
setErrors(validation.errors);
|
||||||
|
setSaveOpen(false);
|
||||||
|
setActiveTab('basic');
|
||||||
|
notifyWarning(validation.errors.name ?? '请检查角色信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateRole.mutate(
|
||||||
|
{ roleId: detail.id, input: validation.input },
|
||||||
|
{
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setSaveOpen(false);
|
||||||
|
setSpecialPrompt(false);
|
||||||
|
setErrors({});
|
||||||
|
editor.markSaved();
|
||||||
|
if (detail.isSystem) {
|
||||||
|
notifySuccess('角色名称已更新');
|
||||||
|
} else {
|
||||||
|
notifySuccess(saveResultMessage(result));
|
||||||
|
}
|
||||||
|
if (result.warnings.length > 0) {
|
||||||
|
notifyWarning(result.warnings.join(';'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setSaveOpen(false);
|
||||||
|
setSpecialPrompt(false);
|
||||||
|
if (error instanceof ApiError && error.code === 40304) {
|
||||||
|
setServerState({
|
||||||
|
fingerprint: draftFingerprint,
|
||||||
|
conflicts: conflictsFromErrorDetails(error.details),
|
||||||
|
saveError: '保存时服务端检测到职责互斥,请调整冲突权限点后重试。',
|
||||||
|
});
|
||||||
|
setActiveTab('permissions');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
setServerState({
|
||||||
|
fingerprint: draftFingerprint,
|
||||||
|
conflicts: [],
|
||||||
|
saveError: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSpecial = (code: string, granted: boolean): void => {
|
||||||
|
const permission = tree?.specialPermissions.find((item) => item.code === code);
|
||||||
|
if (permission === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSpecialDialog({ permission, granted });
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerTitle = editor.draft.name.trim() === '' ? detail.name : editor.draft.name;
|
||||||
|
const impactUsers = detail.userCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="lx-roles__header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lx-roles__back"
|
||||||
|
aria-label="返回角色列表"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(ROUTES.system.roles);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconBack size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="lx-roles__head-txt">
|
||||||
|
<div className="lx-roles__title-line">
|
||||||
|
<h1>{headerTitle}</h1>
|
||||||
|
<RoleStatusTag status={detail.status} />
|
||||||
|
{detail.isSystem ? (
|
||||||
|
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
|
||||||
|
) : null}
|
||||||
|
{readonly ? <span className="lx-role-tag">只读</span> : null}
|
||||||
|
</div>
|
||||||
|
<p>调整角色权限、数据范围与特殊权限,保存后对绑定用户生效</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-roles__header-actions">
|
||||||
|
<Can permission={Permission.ROLE_MANAGE}>
|
||||||
|
<Button
|
||||||
|
icon={<IconCopy size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(roleNewPath(detail.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
{detail.isSystem ? null : detail.status === 'active' ? (
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
icon={<IconPause size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
setDisableOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
停用
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
icon={<IconPlay size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
updateRole.mutate(
|
||||||
|
{ roleId: detail.id, input: { status: 'enabled' } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
notifySuccess(`「${detail.name}」已启用`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
启用
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="lx-roles__split">
|
||||||
|
<RoleListPanel
|
||||||
|
roles={peers}
|
||||||
|
totalCount={peers.length}
|
||||||
|
loading={peersLoading}
|
||||||
|
error={peersError}
|
||||||
|
selectedRoleId={detail.id}
|
||||||
|
note={`当前:${detail.name}`}
|
||||||
|
footerNote="切换后继续编辑"
|
||||||
|
canManage={canManage}
|
||||||
|
showRowMenu={false}
|
||||||
|
isFiltered={false}
|
||||||
|
onSelect={onSelectPeer}
|
||||||
|
onRowAction={() => {
|
||||||
|
// 编辑器内左侧列表仅用于切换角色,不提供行菜单
|
||||||
|
}}
|
||||||
|
onCreate={() => {
|
||||||
|
void navigate(ROUTES.system.roleNew);
|
||||||
|
}}
|
||||||
|
onResetFilters={() => {
|
||||||
|
// 编辑器内左侧列表不展示筛选
|
||||||
|
}}
|
||||||
|
onRetry={onRetryPeers}
|
||||||
|
/>
|
||||||
|
<section className="lx-role-editor" aria-label="角色编辑器">
|
||||||
|
<header className="lx-role-editor__head">
|
||||||
|
<span className="lx-role-editor__avatar" aria-hidden="true">
|
||||||
|
<IconShield size={20} />
|
||||||
|
</span>
|
||||||
|
<div className="lx-role-editor__ident">
|
||||||
|
<div className="lx-role-editor__name">
|
||||||
|
<b>{headerTitle}</b>
|
||||||
|
<RoleStatusTag status={detail.status} />
|
||||||
|
{detail.isSystem ? (
|
||||||
|
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="lx-role-editor__meta">
|
||||||
|
<code>{detail.code}</code>
|
||||||
|
<i>·</i>
|
||||||
|
<span>{detail.userCount} 名用户</span>
|
||||||
|
<i>·</i>
|
||||||
|
<span>最近更新 {formatRoleDateTime(detail.updatedAt)}</span>
|
||||||
|
<i>·</i>
|
||||||
|
<span>{scopeLabelOf(detail.dataScope.type)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="lx-role-editor__alert-wrap">
|
||||||
|
<EditorAlert
|
||||||
|
readonly={readonly}
|
||||||
|
locked={locked}
|
||||||
|
conflicts={conflicts}
|
||||||
|
saveError={saveError}
|
||||||
|
meta={meta}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RoleEditorTabs
|
||||||
|
detail={detail}
|
||||||
|
tree={tree}
|
||||||
|
treeLoading={treeLoading}
|
||||||
|
treeError={treeError}
|
||||||
|
onRetryTree={onRetryTree}
|
||||||
|
editor={editor}
|
||||||
|
locked={locked}
|
||||||
|
readonly={readonly}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onChangeTab={setActiveTab}
|
||||||
|
errors={errors}
|
||||||
|
onSpecialRequest={toggleSpecial}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditorFoot
|
||||||
|
readonly={readonly}
|
||||||
|
locked={locked}
|
||||||
|
conflicts={conflicts}
|
||||||
|
editor={editor}
|
||||||
|
saving={updateRole.isPending}
|
||||||
|
affectedUsers={detail.userCount}
|
||||||
|
onCancel={() => {
|
||||||
|
void navigate(ROUTES.system.roles);
|
||||||
|
}}
|
||||||
|
onSave={attemptSave}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DisableRoleDialog
|
||||||
|
key={`disable-${disableOpen ? detail.id : 'closed'}`}
|
||||||
|
visible={disableOpen}
|
||||||
|
role={detail}
|
||||||
|
loading={updateRole.isPending}
|
||||||
|
onCancel={() => {
|
||||||
|
setDisableOpen(false);
|
||||||
|
}}
|
||||||
|
onConfirm={(reason) => {
|
||||||
|
updateRole.mutate(
|
||||||
|
{
|
||||||
|
roleId: detail.id,
|
||||||
|
input: { status: 'disabled', ...(reason === '' ? {} : { reason }) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setDisableOpen(false);
|
||||||
|
notifySuccess(`「${detail.name}」已停用,${impactUsers} 名用户下一次请求生效`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SpecialPermissionDialog
|
||||||
|
visible={specialDialog !== null}
|
||||||
|
permission={specialDialog?.permission ?? null}
|
||||||
|
granted={specialDialog?.granted ?? false}
|
||||||
|
holderCount={impactUsers}
|
||||||
|
onCancel={() => {
|
||||||
|
setSpecialDialog(null);
|
||||||
|
}}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (specialDialog === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.setSpecialPermission(specialDialog.permission.code, specialDialog.granted);
|
||||||
|
setSpecialDialog(null);
|
||||||
|
notifySuccess(
|
||||||
|
specialDialog.granted
|
||||||
|
? `已设置「${specialDialog.permission.name}」,保存后生效`
|
||||||
|
: `已取消「${specialDialog.permission.name}」,保存后生效`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{editor.diff.hasSpecialChanges ? (
|
||||||
|
<SpecialPermissionDialog
|
||||||
|
visible={specialPrompt}
|
||||||
|
permission={
|
||||||
|
tree?.specialPermissions.find((item) => item.code === specialChangedCodes[0]) ?? null
|
||||||
|
}
|
||||||
|
granted={editor.diff.addedSpecial.length > 0}
|
||||||
|
holderCount={impactUsers}
|
||||||
|
onCancel={() => {
|
||||||
|
setSpecialPrompt(false);
|
||||||
|
}}
|
||||||
|
onConfirm={() => {
|
||||||
|
setSpecialPrompt(false);
|
||||||
|
setSaveOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SaveRoleDialog
|
||||||
|
visible={saveOpen}
|
||||||
|
role={detail}
|
||||||
|
diff={editor.diff}
|
||||||
|
meta={meta}
|
||||||
|
baseName={editor.base.name}
|
||||||
|
draftName={editor.draft.name}
|
||||||
|
baseScope={scopeLabelOf(editor.base.dataScope.type)}
|
||||||
|
draftScope={scopeLabelOf(editor.draft.dataScope.type)}
|
||||||
|
loading={updateRole.isPending}
|
||||||
|
onCancel={() => {
|
||||||
|
setSaveOpen(false);
|
||||||
|
}}
|
||||||
|
onConfirm={doSave}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LeaveRoleDialog
|
||||||
|
visible={blocker.state === 'blocked'}
|
||||||
|
roleName={detail.name}
|
||||||
|
onCancel={() => {
|
||||||
|
if (blocker.state === 'blocked') {
|
||||||
|
blocker.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (blocker.state === 'blocked') {
|
||||||
|
blocker.proceed();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorLoading(): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="lx-roles__split">
|
||||||
|
<section className="lx-role-list lx-role-list--loading">
|
||||||
|
<PageSkeleton rows={9} />
|
||||||
|
</section>
|
||||||
|
<section className="lx-role-editor lx-role-editor--loading">
|
||||||
|
<PageSkeleton rows={10} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 角色编辑器页(/system/roles/:id):左列表常驻高亮 + 右 Tab 编辑器 + 底部保存条 */
|
||||||
|
export function RoleEditorPage(): ReactNode {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const roleId = params.id ?? null;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const canManage = usePermission(Permission.ROLE_MANAGE);
|
||||||
|
const tree = usePermissionTreeQuery();
|
||||||
|
const peers = useRoleListQuery(PEER_ROLE_QUERY);
|
||||||
|
const detail = useRoleDetailQuery(roleId);
|
||||||
|
const selectPeer = (nextRoleId: string): void => {
|
||||||
|
if (nextRoleId === roleId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void navigate(roleDetailPath(nextRoleId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const peerRoles: readonly RoleListItem[] = useMemo(() => peers.data?.items ?? [], [peers.data]);
|
||||||
|
|
||||||
|
if (roleId === null) {
|
||||||
|
return <ErrorState title="角色不存在" message="链接缺少角色标识,请返回角色列表重新选择。" />;
|
||||||
|
}
|
||||||
|
if (detail.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="lx-roles">
|
||||||
|
<EditorLoading />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!detail.isSuccess) {
|
||||||
|
return (
|
||||||
|
<div className="lx-roles">
|
||||||
|
<EditorLoading />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-roles">
|
||||||
|
<EditorContent
|
||||||
|
key={detail.data.id}
|
||||||
|
detail={detail.data}
|
||||||
|
tree={tree.data}
|
||||||
|
treeLoading={tree.isPending}
|
||||||
|
treeError={tree.error}
|
||||||
|
onRetryTree={() => {
|
||||||
|
void tree.refetch();
|
||||||
|
}}
|
||||||
|
peers={peerRoles}
|
||||||
|
peersLoading={peers.isPending}
|
||||||
|
peersError={peers.error}
|
||||||
|
onRetryPeers={() => {
|
||||||
|
void peers.refetch();
|
||||||
|
}}
|
||||||
|
onSelectPeer={selectPeer}
|
||||||
|
canManage={canManage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 编辑器四个 Tab 的内容区集中导出,便于与设计稿逐区对照 */
|
||||||
|
import { Input, Tabs, TextArea } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { PermissionTree, RoleDetail } from '@lingxi/shared';
|
||||||
|
import { IconAlert, IconLock } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { formatRoleDateTime } from './role-display';
|
||||||
|
import type { RoleDraftErrors } from './role-utils';
|
||||||
|
import { RolePermissionTree } from './RolePermissionTree';
|
||||||
|
import { RoleScopePicker } from './RoleScopePicker';
|
||||||
|
import { RoleSpecialPermissions } from './RoleSpecialPermissions';
|
||||||
|
import { RoleStatusTag } from './RoleStatusTag';
|
||||||
|
import type { RoleEditorState } from './use-role-editor';
|
||||||
|
|
||||||
|
export type RoleEditorTabKey = 'basic' | 'permissions' | 'scope' | 'special';
|
||||||
|
|
||||||
|
export interface RoleEditorTabsProps {
|
||||||
|
detail: RoleDetail;
|
||||||
|
tree: PermissionTree | undefined;
|
||||||
|
treeLoading: boolean;
|
||||||
|
treeError: Error | null;
|
||||||
|
onRetryTree: () => void;
|
||||||
|
editor: RoleEditorState;
|
||||||
|
/** 系统角色:权限 / 范围 / 特殊权限锁定,仅显示名可改 */
|
||||||
|
locked: boolean;
|
||||||
|
/** 无 role:manage:所有写控件只读 */
|
||||||
|
readonly: boolean;
|
||||||
|
activeTab: RoleEditorTabKey;
|
||||||
|
onChangeTab: (key: RoleEditorTabKey) => void;
|
||||||
|
errors: RoleDraftErrors;
|
||||||
|
onSpecialRequest: (code: string, granted: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({ message }: { message: string | undefined }): ReactNode {
|
||||||
|
return message === undefined ? null : <span className="lx-role-field__error">{message}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BasicTab({
|
||||||
|
detail,
|
||||||
|
editor,
|
||||||
|
readonly,
|
||||||
|
locked,
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
detail: RoleDetail;
|
||||||
|
editor: RoleEditorState;
|
||||||
|
readonly: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
errors: RoleDraftErrors;
|
||||||
|
}): ReactNode {
|
||||||
|
const nameLocked = readonly;
|
||||||
|
const descLocked = readonly || locked;
|
||||||
|
return (
|
||||||
|
<div className="lx-role-editor__basic">
|
||||||
|
<section className="lx-role-editor__block">
|
||||||
|
<header className="lx-role-editor__block-head">
|
||||||
|
<h3>基本信息</h3>
|
||||||
|
<span>用于列表展示、成员分配与审计检索</span>
|
||||||
|
</header>
|
||||||
|
<div className="lx-role-editor__grid">
|
||||||
|
<label className="lx-role-field">
|
||||||
|
<span className="lx-role-field__label">
|
||||||
|
角色名称
|
||||||
|
<i className="lx-role-field__required">*</i>
|
||||||
|
<span className="lx-role-field__hint">{editor.draft.name.length}/64</span>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={editor.draft.name}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder="例如:审核员"
|
||||||
|
disabled={nameLocked}
|
||||||
|
onChange={editor.setName}
|
||||||
|
/>
|
||||||
|
<FieldError message={errors.name} />
|
||||||
|
</label>
|
||||||
|
<label className="lx-role-field">
|
||||||
|
<span className="lx-role-field__label">角色编码</span>
|
||||||
|
<span className="lx-role-field__readonly">
|
||||||
|
<IconLock size={13} />
|
||||||
|
{detail.code}
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-field__sub">创建后不可修改,接口与审计使用该编码</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="lx-role-field">
|
||||||
|
<span className="lx-role-field__label">
|
||||||
|
角色说明
|
||||||
|
<span className="lx-role-field__hint">{editor.draft.description.length}/255</span>
|
||||||
|
</span>
|
||||||
|
<TextArea
|
||||||
|
value={editor.draft.description}
|
||||||
|
maxLength={255}
|
||||||
|
autosize={{ minRows: 2, maxRows: 4 }}
|
||||||
|
placeholder="一句话说明该角色的职责"
|
||||||
|
disabled={descLocked}
|
||||||
|
onChange={editor.setDescription}
|
||||||
|
/>
|
||||||
|
<FieldError message={errors.description} />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="lx-role-editor__block">
|
||||||
|
<header className="lx-role-editor__block-head">
|
||||||
|
<h3>绑定用户</h3>
|
||||||
|
<span>停用或删除角色前请先转交</span>
|
||||||
|
</header>
|
||||||
|
<div className="lx-role-editor__bind-head">
|
||||||
|
<b>{detail.userCount} 名用户</b>
|
||||||
|
<span>绑定后立即获得该角色的权限与数据范围</span>
|
||||||
|
</div>
|
||||||
|
{detail.boundUsers.length === 0 ? (
|
||||||
|
<p className="lx-role-editor__muted">暂未绑定用户</p>
|
||||||
|
) : (
|
||||||
|
<ul className="lx-role-editor__users">
|
||||||
|
{detail.boundUsers.map((user) => (
|
||||||
|
<li key={user.id} className="lx-role-editor__user">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
user.status === 'disabled'
|
||||||
|
? 'lx-role-editor__user-avatar is-disabled'
|
||||||
|
: 'lx-role-editor__user-avatar'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.name.slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-editor__user-name">{user.name}</span>
|
||||||
|
<span className="lx-role-editor__user-account">{user.account}</span>
|
||||||
|
<span className="lx-role-editor__user-dept">{user.departmentName}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
user.status === 'active'
|
||||||
|
? 'lx-status-tag is-active'
|
||||||
|
: 'lx-status-tag is-disabled'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.status === 'active' ? '在职' : '停用'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="lx-role-editor__block">
|
||||||
|
<header className="lx-role-editor__block-head">
|
||||||
|
<h3>生效与审计</h3>
|
||||||
|
</header>
|
||||||
|
<p className="lx-role-editor__note">
|
||||||
|
<IconAlert size={14} />
|
||||||
|
权限与数据范围变更在保存后写入审计日志(role.updated),绑定用户下一次请求即生效。
|
||||||
|
</p>
|
||||||
|
<p className="lx-role-editor__meta-line">
|
||||||
|
当前状态 <RoleStatusTag status={detail.status} /> · 最近更新{' '}
|
||||||
|
{formatRoleDateTime(detail.updatedAt)}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeState({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
onRetry: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
if (loading) {
|
||||||
|
return <p className="lx-role-editor__muted">权限点目录加载中…</p>;
|
||||||
|
}
|
||||||
|
if (error !== null) {
|
||||||
|
return (
|
||||||
|
<p className="lx-role-editor__muted">
|
||||||
|
权限点目录加载失败。
|
||||||
|
<button type="button" className="lx-role-editor__link" onClick={onRetry}>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <p className="lx-role-editor__muted">权限点目录暂不可用</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionsTab({
|
||||||
|
tree,
|
||||||
|
treeLoading,
|
||||||
|
treeError,
|
||||||
|
onRetryTree,
|
||||||
|
editor,
|
||||||
|
locked,
|
||||||
|
readonly,
|
||||||
|
}: Pick<
|
||||||
|
RoleEditorTabsProps,
|
||||||
|
'tree' | 'treeLoading' | 'treeError' | 'onRetryTree' | 'editor' | 'locked' | 'readonly'
|
||||||
|
>): ReactNode {
|
||||||
|
if (tree === undefined) {
|
||||||
|
return <TreeState loading={treeLoading} error={treeError} onRetry={onRetryTree} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="lx-role-editor__perm">
|
||||||
|
<RolePermissionTree
|
||||||
|
tree={tree}
|
||||||
|
selected={new Set(editor.draft.permissions)}
|
||||||
|
special={new Set(editor.draft.specialPermissions)}
|
||||||
|
conflicts={editor.conflictCodes}
|
||||||
|
readonly={readonly || locked}
|
||||||
|
onTogglePermission={editor.togglePermission}
|
||||||
|
onToggleModule={editor.toggleModule}
|
||||||
|
/>
|
||||||
|
{locked ? (
|
||||||
|
<p className="lx-role-editor__note">
|
||||||
|
<IconLock size={14} />
|
||||||
|
系统内置角色的权限由系统保证,不支持在此调整。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeTab({
|
||||||
|
editor,
|
||||||
|
locked,
|
||||||
|
readonly,
|
||||||
|
}: Pick<RoleEditorTabsProps, 'editor' | 'locked' | 'readonly'>): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="lx-role-editor__scope">
|
||||||
|
<header className="lx-role-editor__block-head">
|
||||||
|
<h3>数据范围</h3>
|
||||||
|
<span>决定该角色可见的客户与数据边界,与权限点同时生效取交集</span>
|
||||||
|
</header>
|
||||||
|
<RoleScopePicker
|
||||||
|
value={{
|
||||||
|
type: editor.draft.dataScope.type,
|
||||||
|
targetIds: editor.draft.dataScope.targetIds,
|
||||||
|
}}
|
||||||
|
readonly={readonly || locked}
|
||||||
|
onChange={editor.setScopeType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpecialTab({
|
||||||
|
tree,
|
||||||
|
treeLoading,
|
||||||
|
treeError,
|
||||||
|
onRetryTree,
|
||||||
|
editor,
|
||||||
|
detail,
|
||||||
|
locked,
|
||||||
|
readonly,
|
||||||
|
onSpecialRequest,
|
||||||
|
}: Pick<
|
||||||
|
RoleEditorTabsProps,
|
||||||
|
| 'tree'
|
||||||
|
| 'treeLoading'
|
||||||
|
| 'treeError'
|
||||||
|
| 'onRetryTree'
|
||||||
|
| 'editor'
|
||||||
|
| 'detail'
|
||||||
|
| 'locked'
|
||||||
|
| 'readonly'
|
||||||
|
| 'onSpecialRequest'
|
||||||
|
>): ReactNode {
|
||||||
|
if (tree === undefined) {
|
||||||
|
return <TreeState loading={treeLoading} error={treeError} onRetry={onRetryTree} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="lx-role-editor__special">
|
||||||
|
<header className="lx-role-editor__block-head">
|
||||||
|
<h3>特殊权限</h3>
|
||||||
|
<span>默认关闭、单独授予、不随角色复制带入</span>
|
||||||
|
</header>
|
||||||
|
<RoleSpecialPermissions
|
||||||
|
tree={tree}
|
||||||
|
granted={new Set(editor.draft.specialPermissions)}
|
||||||
|
readonly={readonly || locked}
|
||||||
|
holderCount={detail.userCount}
|
||||||
|
onRequest={onSpecialRequest}
|
||||||
|
/>
|
||||||
|
{locked ? (
|
||||||
|
<p className="lx-role-editor__note">
|
||||||
|
<IconLock size={14} />
|
||||||
|
系统内置角色的特殊权限由系统保证,不支持在此调整。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabLabel(label: string, badge?: string, danger = false): ReactNode {
|
||||||
|
return (
|
||||||
|
<span className="lx-role-editor__tab-label">
|
||||||
|
{label}
|
||||||
|
{badge === undefined ? null : (
|
||||||
|
<span
|
||||||
|
className={danger ? 'lx-role-editor__tab-badge is-danger' : 'lx-role-editor__tab-badge'}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑器头部 Tabs:基本信息 / 权限点 / 数据范围 / 特殊权限 */
|
||||||
|
export function RoleEditorTabs(props: RoleEditorTabsProps): ReactNode {
|
||||||
|
const totalPermissions =
|
||||||
|
props.tree?.totalPermissions ??
|
||||||
|
props.detail.permissionCount + props.detail.specialPermissionCount;
|
||||||
|
const selectedCount =
|
||||||
|
props.editor.draft.permissions.length + props.editor.draft.specialPermissions.length;
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
className="lx-role-editor__tabs"
|
||||||
|
type="line"
|
||||||
|
activeKey={props.activeTab}
|
||||||
|
onChange={(key) => {
|
||||||
|
props.onChangeTab(key as RoleEditorTabKey);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.TabPane key="basic" itemKey="basic" tab={tabLabel('基本信息')}>
|
||||||
|
{props.activeTab === 'basic' ? (
|
||||||
|
<BasicTab
|
||||||
|
detail={props.detail}
|
||||||
|
editor={props.editor}
|
||||||
|
readonly={props.readonly}
|
||||||
|
locked={props.locked}
|
||||||
|
errors={props.errors}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane
|
||||||
|
key="permissions"
|
||||||
|
itemKey="permissions"
|
||||||
|
tab={tabLabel(
|
||||||
|
'权限点',
|
||||||
|
`${String(selectedCount)} / ${String(totalPermissions)}`,
|
||||||
|
props.editor.conflicts.length > 0,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.activeTab === 'permissions' ? (
|
||||||
|
<PermissionsTab
|
||||||
|
tree={props.tree}
|
||||||
|
treeLoading={props.treeLoading}
|
||||||
|
treeError={props.treeError}
|
||||||
|
onRetryTree={props.onRetryTree}
|
||||||
|
editor={props.editor}
|
||||||
|
locked={props.locked}
|
||||||
|
readonly={props.readonly}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane key="scope" itemKey="scope" tab={tabLabel('数据范围')}>
|
||||||
|
{props.activeTab === 'scope' ? (
|
||||||
|
<ScopeTab editor={props.editor} locked={props.locked} readonly={props.readonly} />
|
||||||
|
) : null}
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane
|
||||||
|
key="special"
|
||||||
|
itemKey="special"
|
||||||
|
tab={tabLabel(
|
||||||
|
'特殊权限',
|
||||||
|
`${String(props.editor.draft.specialPermissions.length)} / ${String(
|
||||||
|
props.tree?.specialPermissions.length ?? 6,
|
||||||
|
)}`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.activeTab === 'special' ? (
|
||||||
|
<SpecialTab
|
||||||
|
tree={props.tree}
|
||||||
|
treeLoading={props.treeLoading}
|
||||||
|
treeError={props.treeError}
|
||||||
|
onRetryTree={props.onRetryTree}
|
||||||
|
editor={props.editor}
|
||||||
|
detail={props.detail}
|
||||||
|
locked={props.locked}
|
||||||
|
readonly={props.readonly}
|
||||||
|
onSpecialRequest={props.onSpecialRequest}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 列表卡包含筛选态、行菜单与骨架,集中在一处便于对齐状态 */
|
||||||
|
import { Button, Dropdown, Skeleton } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { RoleListItem } from '@lingxi/shared';
|
||||||
|
import { ErrorState, PageEmpty } from '@lingxi/ui';
|
||||||
|
import {
|
||||||
|
IconCopy,
|
||||||
|
IconDelete,
|
||||||
|
IconEdit,
|
||||||
|
IconMore,
|
||||||
|
IconPause,
|
||||||
|
IconPlay,
|
||||||
|
IconShield,
|
||||||
|
IconView,
|
||||||
|
} from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { scopeLabelOf } from './role-display';
|
||||||
|
import { RoleStatusTag } from './RoleStatusTag';
|
||||||
|
|
||||||
|
export type RoleRowAction = 'view' | 'rename' | 'edit' | 'copy' | 'disable' | 'enable' | 'delete';
|
||||||
|
|
||||||
|
export interface RoleListPanelProps {
|
||||||
|
roles: readonly RoleListItem[];
|
||||||
|
/** 未筛选的角色总数,用于区分「没有匹配」与「还没有自定义角色」 */
|
||||||
|
totalCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
selectedRoleId: string | null;
|
||||||
|
/** 卡头右侧说明:内置 / 自定义计数或「当前:角色名」 */
|
||||||
|
note: string;
|
||||||
|
footerNote?: string;
|
||||||
|
canManage: boolean;
|
||||||
|
showRowMenu: boolean;
|
||||||
|
isFiltered: boolean;
|
||||||
|
onSelect: (roleId: string) => void;
|
||||||
|
onRowAction: (action: RoleRowAction, role: RoleListItem) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onResetFilters: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rolePermissionText(role: RoleListItem): ReactNode {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<b>{role.permissionCount}</b> 普通
|
||||||
|
{role.specialPermissionCount > 0 ? (
|
||||||
|
<span className="lx-role-row__special"> · ★{role.specialPermissionCount}</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowMenu({
|
||||||
|
role,
|
||||||
|
canManage,
|
||||||
|
onRowAction,
|
||||||
|
}: {
|
||||||
|
role: RoleListItem;
|
||||||
|
canManage: boolean;
|
||||||
|
onRowAction: (action: RoleRowAction, role: RoleListItem) => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
if (!canManage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trigger = (action: RoleRowAction): void => {
|
||||||
|
setVisible(false);
|
||||||
|
onRowAction(action, role);
|
||||||
|
};
|
||||||
|
const menuItems: ReactNode[] = role.isSystem
|
||||||
|
? [
|
||||||
|
<Dropdown.Item
|
||||||
|
key="view"
|
||||||
|
icon={<IconView size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
trigger('view');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查看角色
|
||||||
|
</Dropdown.Item>,
|
||||||
|
<Dropdown.Item
|
||||||
|
key="rename"
|
||||||
|
icon={<IconEdit size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
trigger('rename');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
改名
|
||||||
|
</Dropdown.Item>,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
<Dropdown.Item
|
||||||
|
key="edit"
|
||||||
|
icon={<IconEdit size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
trigger('edit');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Dropdown.Item>,
|
||||||
|
<Dropdown.Item
|
||||||
|
key="copy"
|
||||||
|
icon={<IconCopy size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
trigger('copy');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</Dropdown.Item>,
|
||||||
|
<Dropdown.Item
|
||||||
|
key="toggle"
|
||||||
|
icon={role.status === 'active' ? <IconPause size={14} /> : <IconPlay size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
trigger(role.status === 'active' ? 'disable' : 'enable');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{role.status === 'active' ? '停用' : '启用'}
|
||||||
|
</Dropdown.Item>,
|
||||||
|
<Dropdown.Divider key="divider" />,
|
||||||
|
<Dropdown.Item
|
||||||
|
key="delete"
|
||||||
|
type="danger"
|
||||||
|
icon={<IconDelete size={14} />}
|
||||||
|
disabled={role.userCount > 0}
|
||||||
|
onClick={() => {
|
||||||
|
trigger('delete');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{role.userCount > 0 ? '删除(有绑定用户)' : '删除'}
|
||||||
|
</Dropdown.Item>,
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
trigger="click"
|
||||||
|
position="bottomRight"
|
||||||
|
visible={visible}
|
||||||
|
onVisibleChange={setVisible}
|
||||||
|
render={<Dropdown.Menu>{menuItems}</Dropdown.Menu>}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="lx-role-row__more"
|
||||||
|
theme="borderless"
|
||||||
|
size="small"
|
||||||
|
icon={<IconMore size={16} />}
|
||||||
|
aria-label={`${role.name} 更多操作`}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListBody({
|
||||||
|
roles,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
selectedRoleId,
|
||||||
|
canManage,
|
||||||
|
showRowMenu,
|
||||||
|
isFiltered,
|
||||||
|
onSelect,
|
||||||
|
onRowAction,
|
||||||
|
onCreate,
|
||||||
|
onResetFilters,
|
||||||
|
onRetry,
|
||||||
|
}: Pick<
|
||||||
|
RoleListPanelProps,
|
||||||
|
| 'roles'
|
||||||
|
| 'loading'
|
||||||
|
| 'error'
|
||||||
|
| 'selectedRoleId'
|
||||||
|
| 'canManage'
|
||||||
|
| 'showRowMenu'
|
||||||
|
| 'isFiltered'
|
||||||
|
| 'onSelect'
|
||||||
|
| 'onRowAction'
|
||||||
|
| 'onCreate'
|
||||||
|
| 'onResetFilters'
|
||||||
|
| 'onRetry'
|
||||||
|
>): ReactNode {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="lx-role-list__state" aria-busy="true">
|
||||||
|
<Skeleton placeholder={<Skeleton.Paragraph rows={9} />} loading />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error !== null) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
className="lx-role-list__state"
|
||||||
|
title="角色列表加载失败"
|
||||||
|
message={error.message}
|
||||||
|
onRetry={onRetry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (roles.length === 0) {
|
||||||
|
if (isFiltered) {
|
||||||
|
return (
|
||||||
|
<PageEmpty
|
||||||
|
title="没有匹配的角色"
|
||||||
|
description="换个角色名称、编码或说明再试,也可以清除当前筛选。"
|
||||||
|
action={<Button onClick={onResetFilters}>清除筛选</Button>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PageEmpty
|
||||||
|
title="还没有自定义角色"
|
||||||
|
description="内置角色由系统维护不可删除;可按岗位新建自定义角色并分配权限。"
|
||||||
|
action={
|
||||||
|
<Button theme="solid" type="primary" onClick={onCreate}>
|
||||||
|
新建角色
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="lx-role-list__items" role="listbox" aria-label="角色">
|
||||||
|
{roles.map((role) => {
|
||||||
|
const selected = role.id === selectedRoleId;
|
||||||
|
return (
|
||||||
|
<li key={role.id}>
|
||||||
|
<div
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
tabIndex={0}
|
||||||
|
className={[
|
||||||
|
'lx-role-row',
|
||||||
|
selected ? 'is-selected' : '',
|
||||||
|
role.status === 'disabled' ? 'is-disabled' : '',
|
||||||
|
]
|
||||||
|
.filter((item) => item !== '')
|
||||||
|
.join(' ')}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(role.id);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
onSelect(role.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-role-row__icon" aria-hidden="true">
|
||||||
|
<IconShield size={16} />
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-row__main">
|
||||||
|
<span className="lx-role-row__name">
|
||||||
|
<RoleStatusTag status={role.status} dotOnly />
|
||||||
|
<b>{role.name}</b>
|
||||||
|
{role.isSystem ? (
|
||||||
|
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
|
||||||
|
) : null}
|
||||||
|
{role.status === 'disabled' ? <span className="lx-role-tag">停用</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-row__meta">
|
||||||
|
{role.code}
|
||||||
|
<span className="lx-role-row__dot">·</span>
|
||||||
|
{scopeLabelOf(role.dataScope.type)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-row__side">
|
||||||
|
<span className="lx-role-row__perm">{rolePermissionText(role)}</span>
|
||||||
|
<span className="lx-role-row__users">{role.userCount} 人绑定</span>
|
||||||
|
</span>
|
||||||
|
{showRowMenu ? (
|
||||||
|
<span
|
||||||
|
className="lx-role-row__menu"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RowMenu role={role} canManage={canManage} onRowAction={onRowAction} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 左侧角色列表卡:卡头说明 + 列表 / 三态 + 页脚统计 */
|
||||||
|
export function RoleListPanel(props: RoleListPanelProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<section className="lx-role-list" aria-label="角色列表">
|
||||||
|
<header className="lx-role-list__head">
|
||||||
|
<span className="lx-role-list__title">角色</span>
|
||||||
|
<span className="lx-role-list__note">{props.note}</span>
|
||||||
|
</header>
|
||||||
|
<div className="lx-role-list__body">
|
||||||
|
<ListBody {...props} />
|
||||||
|
</div>
|
||||||
|
<footer className="lx-role-list__foot">
|
||||||
|
<span className="lx-role-list__range">共 {props.roles.length} 个角色</span>
|
||||||
|
<span className="lx-role-list__foot-note">
|
||||||
|
{props.footerNote ??
|
||||||
|
`启用 ${String(props.roles.filter((role) => role.status === 'active').length)} · 停用 ${String(props.roles.filter((role) => role.status === 'disabled').length)}`}
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 新建页负责模板预填、表单草稿与创建编排,集中便于对照原型 */
|
||||||
|
import { Button, Input, TextArea } from '@douyinfe/semi-ui-19';
|
||||||
|
import {
|
||||||
|
isPermissionCode,
|
||||||
|
type PermissionCode,
|
||||||
|
type PermissionModuleNode,
|
||||||
|
type PermissionNode,
|
||||||
|
type RoleDetail,
|
||||||
|
type RoleListItem,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { ErrorState, notifySuccess, notifyWarning, PageSkeleton } from '@lingxi/ui';
|
||||||
|
import { IconAdd, IconBack, IconCopy, IconDocs, IconShield } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCreateRoleMutation,
|
||||||
|
usePermissionTreeQuery,
|
||||||
|
useRoleDetailQuery,
|
||||||
|
useRoleListQuery,
|
||||||
|
} from '@/hooks/queries/use-roles';
|
||||||
|
import { roleDetailPath, ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
import { SpecialPermissionDialog } from './role-dialogs';
|
||||||
|
import { scopeLabelOf } from './role-display';
|
||||||
|
import {
|
||||||
|
createEmptyDraft,
|
||||||
|
draftFromDetail,
|
||||||
|
findDraftSodConflicts,
|
||||||
|
type RoleDraft,
|
||||||
|
type RoleDraftErrors,
|
||||||
|
validateCreateDraft,
|
||||||
|
} from './role-utils';
|
||||||
|
import { RolePermissionTree } from './RolePermissionTree';
|
||||||
|
import { RoleScopePicker } from './RoleScopePicker';
|
||||||
|
import { RoleSpecialPermissions } from './RoleSpecialPermissions';
|
||||||
|
|
||||||
|
const TEMPLATE_ROLE_QUERY = {
|
||||||
|
limit: 100,
|
||||||
|
status: 'active',
|
||||||
|
sort: 'created_at',
|
||||||
|
order: 'desc',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const BLANK_TEMPLATE = '__blank__';
|
||||||
|
|
||||||
|
interface TemplateListProps {
|
||||||
|
templates: readonly RoleListItem[];
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
selectedKey: string;
|
||||||
|
onSelect: (key: string) => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateList({
|
||||||
|
templates,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
selectedKey,
|
||||||
|
onSelect,
|
||||||
|
onRetry,
|
||||||
|
}: TemplateListProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<section className="lx-role-templates" aria-label="角色模板">
|
||||||
|
<header className="lx-role-templates__head">
|
||||||
|
<span className="lx-role-templates__title">角色模板</span>
|
||||||
|
<span className="lx-role-templates__note">
|
||||||
|
{templates.length === 0 ? '仅空白' : `${String(templates.length + 1)} 个`}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className="lx-role-templates__body">
|
||||||
|
{loading ? (
|
||||||
|
<div className="lx-role-templates__state">
|
||||||
|
<PageSkeleton rows={8} />
|
||||||
|
</div>
|
||||||
|
) : error !== null ? (
|
||||||
|
<ErrorState
|
||||||
|
className="lx-role-templates__state"
|
||||||
|
title="角色模板加载失败"
|
||||||
|
message={error.message}
|
||||||
|
onRetry={onRetry}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="lx-role-template-list" role="listbox" aria-label="选择模板">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedKey === BLANK_TEMPLATE}
|
||||||
|
className={
|
||||||
|
selectedKey === BLANK_TEMPLATE ? 'lx-role-template is-active' : 'lx-role-template'
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(BLANK_TEMPLATE);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-role-template__icon" aria-hidden="true">
|
||||||
|
<IconDocs size={16} />
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-template__main">
|
||||||
|
<span className="lx-role-template__name">从空白开始</span>
|
||||||
|
<span className="lx-role-template__desc">不预置任何权限,逐项配置</span>
|
||||||
|
<span className="lx-role-template__meta">
|
||||||
|
<span>权限 0</span>
|
||||||
|
<i>·</i>
|
||||||
|
<span>未设置范围</span>
|
||||||
|
<i>·</i>
|
||||||
|
<span>特殊 0</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{templates.map((role) => {
|
||||||
|
const active = selectedKey === role.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={role.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
className={active ? 'lx-role-template is-active' : 'lx-role-template'}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(role.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-role-template__icon" aria-hidden="true">
|
||||||
|
<IconShield size={16} />
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-template__main">
|
||||||
|
<span className="lx-role-template__name">
|
||||||
|
{role.name}
|
||||||
|
{role.isSystem ? (
|
||||||
|
<span className="lx-role-tag lx-role-tag--builtin">内置</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-template__desc">{role.description}</span>
|
||||||
|
<span className="lx-role-template__meta">
|
||||||
|
<span>权限 {role.permissionCount}</span>
|
||||||
|
<i>·</i>
|
||||||
|
<span>{scopeLabelOf(role.dataScope.type)}</span>
|
||||||
|
<i>·</i>
|
||||||
|
<span className={role.specialPermissionCount > 0 ? 'has-special' : ''}>
|
||||||
|
{role.specialPermissionCount > 0
|
||||||
|
? `★特殊 ${String(role.specialPermissionCount)}`
|
||||||
|
: '特殊 0'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<footer className="lx-role-templates__foot">
|
||||||
|
模板只带入普通权限与数据范围,★ 特殊权限需创建后单独授予
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewFormHeader({
|
||||||
|
templateRole,
|
||||||
|
fromCopy,
|
||||||
|
templateLoading,
|
||||||
|
}: {
|
||||||
|
templateRole: RoleDetail | undefined;
|
||||||
|
fromCopy: boolean;
|
||||||
|
templateLoading: boolean;
|
||||||
|
}): ReactNode {
|
||||||
|
const title =
|
||||||
|
templateRole === undefined
|
||||||
|
? '从空白开始创建'
|
||||||
|
: fromCopy
|
||||||
|
? `复制「${templateRole.name}」`
|
||||||
|
: `从「${templateRole.name}」模板创建`;
|
||||||
|
const sub =
|
||||||
|
templateRole === undefined
|
||||||
|
? '未预置任何权限与数据范围,按需逐项配置'
|
||||||
|
: fromCopy
|
||||||
|
? '已带入权限与数据范围,特殊权限需创建后单独授予'
|
||||||
|
: templateRole.description;
|
||||||
|
return (
|
||||||
|
<header className="lx-role-new__head">
|
||||||
|
<div>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{templateLoading ? '正在读取来源角色…' : sub}</p>
|
||||||
|
</div>
|
||||||
|
<span className="lx-role-tag lx-role-tag--brand">
|
||||||
|
{templateRole === undefined ? '空白配置' : fromCopy ? '复制角色' : '已套用模板'}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({ message }: { message: string | undefined }): ReactNode {
|
||||||
|
return message === undefined ? null : <span className="lx-role-field__error">{message}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建 / 复制角色页(/system/roles/new?from=):左模板列表 + 右表单 */
|
||||||
|
export function RoleNewPage(): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const fromRoleId = searchParams.get('from');
|
||||||
|
const listQuery = useRoleListQuery(TEMPLATE_ROLE_QUERY);
|
||||||
|
const tree = usePermissionTreeQuery();
|
||||||
|
const createRole = useCreateRoleMutation();
|
||||||
|
const templates = useMemo(
|
||||||
|
() => (listQuery.data?.items ?? []).filter((role) => !role.isSystem),
|
||||||
|
[listQuery.data],
|
||||||
|
);
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string>(fromRoleId ?? BLANK_TEMPLATE);
|
||||||
|
const [draft, setDraft] = useState<RoleDraft>(createEmptyDraft);
|
||||||
|
const [errors, setErrors] = useState<RoleDraftErrors>({});
|
||||||
|
const [specialDialog, setSpecialDialog] = useState<{
|
||||||
|
permission: PermissionNode;
|
||||||
|
granted: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [prefilledId, setPrefilledId] = useState<string | null>(null);
|
||||||
|
const templateRoleId = selectedKey === BLANK_TEMPLATE ? null : selectedKey;
|
||||||
|
const templateDetail = useRoleDetailQuery(templateRoleId);
|
||||||
|
const fromCopy = fromRoleId !== null && fromRoleId === selectedKey;
|
||||||
|
const conflicts = findDraftSodConflicts(draft);
|
||||||
|
const conflictCodes = useMemo(() => {
|
||||||
|
const codes = new Set<string>();
|
||||||
|
for (const conflict of conflicts) {
|
||||||
|
codes.add(conflict.permissions[0]);
|
||||||
|
codes.add(conflict.permissions[1]);
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}, [conflicts]);
|
||||||
|
|
||||||
|
// 模板数据到达后一次性预填(渲染期对齐状态,避免 effect 级联渲染)
|
||||||
|
const templateRole = templateDetail.data;
|
||||||
|
if (templateRoleId === null) {
|
||||||
|
if (prefilledId !== null) {
|
||||||
|
setPrefilledId(null);
|
||||||
|
setDraft(createEmptyDraft());
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
} else if (templateRole !== undefined && prefilledId !== templateRole.id) {
|
||||||
|
setPrefilledId(templateRole.id);
|
||||||
|
setDraft(draftFromDetail(templateRole, { includeSpecial: false, nameSuffix: ' 副本' }));
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePermission = (code: string): void => {
|
||||||
|
if (!isPermissionCode(code)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft((current) => {
|
||||||
|
const has = current.permissions.includes(code);
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
permissions: has
|
||||||
|
? current.permissions.filter((item) => item !== code)
|
||||||
|
: [...current.permissions, code],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleModule = (module: PermissionModuleNode, selectAll: boolean): void => {
|
||||||
|
const normalCodes: PermissionCode[] = module.permissions
|
||||||
|
.filter((permission) => !permission.isSpecial && isPermissionCode(permission.code))
|
||||||
|
.map((permission) => permission.code)
|
||||||
|
.filter(isPermissionCode);
|
||||||
|
setDraft((current) => {
|
||||||
|
const next = new Set(current.permissions);
|
||||||
|
for (const code of normalCodes) {
|
||||||
|
if (selectAll) {
|
||||||
|
next.add(code);
|
||||||
|
} else {
|
||||||
|
next.delete(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...current, permissions: [...next] };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
const validation = validateCreateDraft(draft);
|
||||||
|
setErrors(validation.errors);
|
||||||
|
if (validation.input === null) {
|
||||||
|
notifyWarning(validation.errors.name ?? '请检查角色信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
notifyWarning(`存在职责互斥:${conflicts[0]?.label ?? ''},请调整权限点`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createRole.mutate(validation.input, {
|
||||||
|
onSuccess: (result) => {
|
||||||
|
notifySuccess('角色已创建,正在进入编辑器');
|
||||||
|
void navigate(roleDetailPath(result.role.id), { replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-roles">
|
||||||
|
<header className="lx-roles__header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lx-roles__back"
|
||||||
|
aria-label="返回角色列表"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(ROUTES.system.roles);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconBack size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="lx-roles__head-txt">
|
||||||
|
<h1>新建角色</h1>
|
||||||
|
<p>从模板开始或从空白配置,创建后可继续调整权限与数据范围</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-roles__header-actions">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(ROUTES.system.roles);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
icon={<IconAdd size={16} />}
|
||||||
|
loading={createRole.isPending}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
创建角色
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="lx-roles__split">
|
||||||
|
<TemplateList
|
||||||
|
templates={templates}
|
||||||
|
loading={listQuery.isPending}
|
||||||
|
error={listQuery.error}
|
||||||
|
selectedKey={selectedKey}
|
||||||
|
onSelect={(key) => {
|
||||||
|
setSelectedKey(key);
|
||||||
|
setPrefilledId(null);
|
||||||
|
}}
|
||||||
|
onRetry={() => {
|
||||||
|
void listQuery.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="lx-role-new" aria-label="新建角色表单">
|
||||||
|
<NewFormHeader
|
||||||
|
templateRole={templateDetail.data}
|
||||||
|
fromCopy={fromCopy}
|
||||||
|
templateLoading={templateDetail.isPending && templateRoleId !== null}
|
||||||
|
/>
|
||||||
|
<div className="lx-role-new__body">
|
||||||
|
{fromCopy ? (
|
||||||
|
<div className="lx-role-new__alert is-info">
|
||||||
|
<IconCopy size={15} />
|
||||||
|
<span>
|
||||||
|
已复制「{templateDetail.data?.name ?? ''}」的权限与数据范围,特殊权限未带入。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{conflicts.length > 0 ? (
|
||||||
|
<div className="lx-role-new__alert is-danger">
|
||||||
|
<span>
|
||||||
|
命中职责互斥:{conflicts.map((conflict) => conflict.label).join(';')}
|
||||||
|
,请调整冲突权限点后重试。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="lx-role-editor__block">
|
||||||
|
<header className="lx-role-editor__block-head">
|
||||||
|
<h3>基本信息</h3>
|
||||||
|
<span>名称与说明用于列表展示和成员分配</span>
|
||||||
|
</header>
|
||||||
|
<div className="lx-role-editor__grid">
|
||||||
|
<label className="lx-role-field">
|
||||||
|
<span className="lx-role-field__label">
|
||||||
|
角色名称
|
||||||
|
<i className="lx-role-field__required">*</i>
|
||||||
|
<span className="lx-role-field__hint">{draft.name.length}/64</span>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={draft.name}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder="例如:审核员"
|
||||||
|
onChange={(value) => {
|
||||||
|
setDraft((current) => ({ ...current, name: value }));
|
||||||
|
setErrors({});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FieldError message={errors.name} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="lx-role-field">
|
||||||
|
<span className="lx-role-field__label">
|
||||||
|
角色说明
|
||||||
|
<span className="lx-role-field__hint">{draft.description.length}/255</span>
|
||||||
|
</span>
|
||||||
|
<TextArea
|
||||||
|
value={draft.description}
|
||||||
|
maxLength={255}
|
||||||
|
autosize={{ minRows: 2, maxRows: 4 }}
|
||||||
|
placeholder="一句话说明该角色的职责"
|
||||||
|
onChange={(value) => {
|
||||||
|
setDraft((current) => ({ ...current, description: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FieldError message={errors.description} />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="lx-role-editor__block">
|
||||||
|
<header className="lx-role-editor__block-head">
|
||||||
|
<h3>权限点</h3>
|
||||||
|
<span>模板已带入的权限点可继续增减</span>
|
||||||
|
</header>
|
||||||
|
{tree.data === undefined ? (
|
||||||
|
<p className="lx-role-editor__muted">
|
||||||
|
{tree.isPending
|
||||||
|
? '权限点目录加载中…'
|
||||||
|
: '权限点目录加载失败,创建后可在编辑器中继续配置。'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<RolePermissionTree
|
||||||
|
tree={tree.data}
|
||||||
|
selected={new Set(draft.permissions)}
|
||||||
|
special={new Set(draft.specialPermissions)}
|
||||||
|
conflicts={conflictCodes}
|
||||||
|
readonly={false}
|
||||||
|
onTogglePermission={togglePermission}
|
||||||
|
onToggleModule={toggleModule}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="lx-role-editor__block">
|
||||||
|
<header className="lx-role-editor__block-head">
|
||||||
|
<h3>数据范围</h3>
|
||||||
|
<span>与权限点同时生效,取交集</span>
|
||||||
|
</header>
|
||||||
|
<RoleScopePicker
|
||||||
|
value={{ type: draft.dataScope.type, targetIds: draft.dataScope.targetIds }}
|
||||||
|
readonly={false}
|
||||||
|
onChange={(type) => {
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
dataScope: {
|
||||||
|
type,
|
||||||
|
targetIds: type === 'customer_groups' ? current.dataScope.targetIds : [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="lx-role-editor__block">
|
||||||
|
<header className="lx-role-editor__block-head">
|
||||||
|
<h3>特殊权限</h3>
|
||||||
|
<span>默认全关,创建后也可在编辑器中授予</span>
|
||||||
|
</header>
|
||||||
|
{tree.data === undefined ? (
|
||||||
|
<p className="lx-role-editor__muted">特殊权限目录加载中…</p>
|
||||||
|
) : (
|
||||||
|
<RoleSpecialPermissions
|
||||||
|
tree={tree.data}
|
||||||
|
granted={new Set(draft.specialPermissions)}
|
||||||
|
readonly={false}
|
||||||
|
holderCount={0}
|
||||||
|
onRequest={(code, granted) => {
|
||||||
|
const permission = tree.data.specialPermissions.find(
|
||||||
|
(item) => item.code === code,
|
||||||
|
);
|
||||||
|
if (permission !== undefined) {
|
||||||
|
setSpecialDialog({ permission, granted });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<footer className="lx-role-new__foot">
|
||||||
|
<span className="lx-role-new__foot-note">
|
||||||
|
已选 {draft.permissions.length + draft.specialPermissions.length} /{' '}
|
||||||
|
{tree.data?.totalPermissions ?? 30} 权限点 · {scopeLabelOf(draft.dataScope.type)} ·
|
||||||
|
特殊权限 {draft.specialPermissions.length}
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-new__foot-hint">创建后进入编辑器继续配置</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SpecialPermissionDialog
|
||||||
|
visible={specialDialog !== null}
|
||||||
|
permission={specialDialog?.permission ?? null}
|
||||||
|
granted={specialDialog?.granted ?? false}
|
||||||
|
holderCount={0}
|
||||||
|
onCancel={() => {
|
||||||
|
setSpecialDialog(null);
|
||||||
|
}}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (specialDialog === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const code = specialDialog.permission.code;
|
||||||
|
if (!isPermissionCode(code)) {
|
||||||
|
setSpecialDialog(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
specialPermissions: specialDialog.granted
|
||||||
|
? [...current.specialPermissions, code]
|
||||||
|
: current.specialPermissions.filter((item) => item !== code),
|
||||||
|
}));
|
||||||
|
setSpecialDialog(null);
|
||||||
|
notifySuccess(
|
||||||
|
specialDialog.granted
|
||||||
|
? `已选择「${specialDialog.permission.name}」,创建后生效`
|
||||||
|
: `已取消「${specialDialog.permission.name}」`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import type { PermissionTree } from '@lingxi/shared';
|
||||||
|
import {
|
||||||
|
PERMISSION_CATALOG,
|
||||||
|
permissionsByModule,
|
||||||
|
permissionTreeSchema,
|
||||||
|
SOD_PAIRS,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { notifyWarning } from '@lingxi/ui';
|
||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { RolePermissionTree } from './RolePermissionTree';
|
||||||
|
|
||||||
|
vi.mock('@lingxi/ui', () => ({
|
||||||
|
notifyWarning: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// jsdom 无 canvas,Semi 整包会加载 Lottie;测试只渲染树,替换所需基础组件
|
||||||
|
vi.mock('@douyinfe/semi-ui-19', () => ({
|
||||||
|
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
|
||||||
|
<button type="button" onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
Input: ({
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}) => (
|
||||||
|
<input
|
||||||
|
value={value ?? ''}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange?.(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedNotifyWarning = vi.mocked(notifyWarning);
|
||||||
|
|
||||||
|
function buildTree(): PermissionTree {
|
||||||
|
const roleNode = (permission: (typeof PERMISSION_CATALOG)[number]) => ({
|
||||||
|
code: permission.code,
|
||||||
|
name: permission.name,
|
||||||
|
description: permission.description,
|
||||||
|
isSpecial: permission.isSpecial,
|
||||||
|
});
|
||||||
|
return permissionTreeSchema.parse({
|
||||||
|
modules: permissionsByModule().map((module) => ({
|
||||||
|
module: module.module,
|
||||||
|
moduleLabel: module.moduleLabel,
|
||||||
|
sortOrder: module.sortOrder,
|
||||||
|
permissions: module.permissions.map(roleNode),
|
||||||
|
})),
|
||||||
|
specialPermissions: PERMISSION_CATALOG.filter((permission) => permission.isSpecial).map(
|
||||||
|
roleNode,
|
||||||
|
),
|
||||||
|
sodPairs: SOD_PAIRS.map((pair) => ({
|
||||||
|
id: pair.id,
|
||||||
|
label: pair.label,
|
||||||
|
description: pair.description,
|
||||||
|
permissions: [pair.permissions[0], pair.permissions[1]],
|
||||||
|
})),
|
||||||
|
totalPermissions: PERMISSION_CATALOG.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderOptions {
|
||||||
|
selected?: string[];
|
||||||
|
special?: string[];
|
||||||
|
conflicts?: string[];
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree(options: RenderOptions = {}): {
|
||||||
|
onTogglePermission: ReturnType<typeof vi.fn>;
|
||||||
|
onToggleModule: ReturnType<typeof vi.fn>;
|
||||||
|
container: HTMLElement;
|
||||||
|
} {
|
||||||
|
const onTogglePermission = vi.fn();
|
||||||
|
const onToggleModule = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<RolePermissionTree
|
||||||
|
tree={buildTree()}
|
||||||
|
selected={new Set(options.selected ?? [])}
|
||||||
|
special={new Set(options.special ?? [])}
|
||||||
|
conflicts={new Set(options.conflicts ?? [])}
|
||||||
|
readonly={options.readonly ?? false}
|
||||||
|
onTogglePermission={onTogglePermission}
|
||||||
|
onToggleModule={onToggleModule}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { onTogglePermission, onToggleModule, container };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
mockedNotifyWarning.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('权限点树', () => {
|
||||||
|
it('按目录渲染 10 个模块与已选计数', () => {
|
||||||
|
const { container } = renderTree({ selected: ['script:read'] });
|
||||||
|
const tools = container.querySelector('.lx-role-tree__tools');
|
||||||
|
expect(tools?.textContent).toContain('已选');
|
||||||
|
expect(tools?.textContent).toContain('30');
|
||||||
|
expect(screen.getByText('脚本')).toBeTruthy();
|
||||||
|
expect(screen.getByText('素材')).toBeTruthy();
|
||||||
|
expect(screen.getByText('组织与角色')).toBeTruthy();
|
||||||
|
expect(screen.getByText('审计')).toBeTruthy();
|
||||||
|
expect(container.querySelector('.lx-role-module__count')?.textContent).toContain('1 / 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认展开已选模块,点击模块标题展开其余模块', () => {
|
||||||
|
renderTree({ selected: ['script:read'] });
|
||||||
|
expect(screen.getByText('查看脚本')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('查看素材任务')).toBeNull();
|
||||||
|
fireEvent.click(screen.getByText('素材'));
|
||||||
|
expect(screen.getByText('查看素材任务')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击普通权限点行触发切换', () => {
|
||||||
|
const { onTogglePermission } = renderTree({ selected: ['script:read'] });
|
||||||
|
fireEvent.click(screen.getByText('查看脚本'));
|
||||||
|
expect(onTogglePermission).toHaveBeenCalledWith('script:read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击模块三态复选框触发整模块选中', () => {
|
||||||
|
const { container, onToggleModule } = renderTree({ selected: ['script:read'] });
|
||||||
|
const module = container.querySelector('[data-module="script"]');
|
||||||
|
expect(module).not.toBeNull();
|
||||||
|
const check = module?.querySelector('.lx-role-module__check');
|
||||||
|
expect(check?.getAttribute('aria-checked')).toBe('mixed');
|
||||||
|
if (check !== null && check !== undefined) {
|
||||||
|
fireEvent.click(check);
|
||||||
|
}
|
||||||
|
expect(onToggleModule).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onToggleModule.mock.calls[0]?.[1]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('特殊权限行不切换,只提示需单独授予', () => {
|
||||||
|
const { container, onTogglePermission } = renderTree({});
|
||||||
|
fireEvent.click(screen.getByText('导出交付'));
|
||||||
|
const specialRow = container.querySelector('[data-code="delivery:export"]');
|
||||||
|
expect(specialRow).not.toBeNull();
|
||||||
|
if (specialRow !== null) {
|
||||||
|
fireEvent.click(specialRow);
|
||||||
|
}
|
||||||
|
expect(onTogglePermission).not.toHaveBeenCalledWith('delivery:export');
|
||||||
|
expect(mockedNotifyWarning).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SoD 冲突行高亮', () => {
|
||||||
|
const { container } = renderTree({
|
||||||
|
selected: ['script:write', 'script:review'],
|
||||||
|
conflicts: ['script:write', 'script:review'],
|
||||||
|
});
|
||||||
|
expect(container.querySelector('[data-code="script:write"]')?.className).toContain(
|
||||||
|
'is-conflict',
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[data-code="script:read"]')?.className).not.toContain(
|
||||||
|
'is-conflict',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('只读态禁用交互', () => {
|
||||||
|
const { onTogglePermission } = renderTree({ selected: ['script:read'], readonly: true });
|
||||||
|
fireEvent.click(screen.getByText('查看脚本'));
|
||||||
|
expect(onTogglePermission).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('搜索权限点后只保留匹配模块并自动展开', () => {
|
||||||
|
renderTree({});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('搜索权限点名称或编码'), {
|
||||||
|
target: { value: '发起导出' },
|
||||||
|
});
|
||||||
|
expect(screen.queryByText('脚本')).toBeNull();
|
||||||
|
expect(screen.getByText('导出交付')).toBeTruthy();
|
||||||
|
expect(screen.getByText('发起导出')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 权限树包含工具条、模块三态与行渲染,集中在一处便于对齐目录 */
|
||||||
|
import { Button, Input } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { PermissionModuleNode, PermissionTree } from '@lingxi/shared';
|
||||||
|
import { notifyWarning } from '@lingxi/ui';
|
||||||
|
import { IconChevronDown, IconChevronRight, IconSearch } from '@lingxi/ui/icons';
|
||||||
|
import type { KeyboardEvent, ReactNode } from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
export interface RolePermissionTreeProps {
|
||||||
|
tree: PermissionTree;
|
||||||
|
/** 普通权限点(特殊权限不得混入) */
|
||||||
|
selected: ReadonlySet<string>;
|
||||||
|
/** 特殊权限点(树内只读展示,授予在「特殊权限」区单独确认) */
|
||||||
|
special: ReadonlySet<string>;
|
||||||
|
/** SoD 冲突权限点编码,树内高亮 */
|
||||||
|
conflicts: ReadonlySet<string>;
|
||||||
|
readonly: boolean;
|
||||||
|
onTogglePermission: (code: string) => void;
|
||||||
|
onToggleModule: (module: PermissionModuleNode, selectAll: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleSelectedCount(
|
||||||
|
module: PermissionModuleNode,
|
||||||
|
selected: ReadonlySet<string>,
|
||||||
|
special: ReadonlySet<string>,
|
||||||
|
): number {
|
||||||
|
return module.permissions.filter((permission) =>
|
||||||
|
permission.isSpecial ? special.has(permission.code) : selected.has(permission.code),
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalPermissionsOf(module: PermissionModuleNode): string[] {
|
||||||
|
return module.permissions
|
||||||
|
.filter((permission) => !permission.isSpecial)
|
||||||
|
.map((permission) => permission.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialExpandedModules(
|
||||||
|
tree: PermissionTree,
|
||||||
|
selected: ReadonlySet<string>,
|
||||||
|
special: ReadonlySet<string>,
|
||||||
|
): ReadonlySet<string> {
|
||||||
|
const expanded = new Set<string>();
|
||||||
|
for (const module of tree.modules) {
|
||||||
|
if (moduleSelectedCount(module, selected, special) > 0) {
|
||||||
|
expanded.add(module.module);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expanded.size === 0) {
|
||||||
|
for (const module of tree.modules.slice(0, 2)) {
|
||||||
|
expanded.add(module.module);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesKeyword(
|
||||||
|
module: PermissionModuleNode,
|
||||||
|
keyword: string,
|
||||||
|
): PermissionModuleNode | null {
|
||||||
|
if (keyword === '') {
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
const permissions = module.permissions.filter((permission) =>
|
||||||
|
`${permission.name} ${permission.code} ${permission.description}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(keyword),
|
||||||
|
);
|
||||||
|
return permissions.length === 0 ? null : { ...module, permissions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckStateIcon({ state }: { state: 'on' | 'off' | 'half' }): ReactNode {
|
||||||
|
return <span className={`lx-role-check is-${state}`} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionRow({
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
isSpecial,
|
||||||
|
checked,
|
||||||
|
conflict,
|
||||||
|
readonly,
|
||||||
|
onToggle,
|
||||||
|
onSpecialHint,
|
||||||
|
}: {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
isSpecial: boolean;
|
||||||
|
checked: boolean;
|
||||||
|
conflict: boolean;
|
||||||
|
readonly: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onSpecialHint: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const interactive = !readonly && !isSpecial;
|
||||||
|
const activate = (): void => {
|
||||||
|
if (readonly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSpecial) {
|
||||||
|
onSpecialHint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onToggle();
|
||||||
|
};
|
||||||
|
const className = [
|
||||||
|
'lx-role-perm-row',
|
||||||
|
checked ? 'is-checked' : '',
|
||||||
|
isSpecial ? 'is-special' : '',
|
||||||
|
conflict ? 'is-conflict' : '',
|
||||||
|
interactive ? 'is-interactive' : '',
|
||||||
|
]
|
||||||
|
.filter((item) => item !== '')
|
||||||
|
.join(' ');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-disabled={!interactive}
|
||||||
|
tabIndex={interactive ? 0 : -1}
|
||||||
|
data-code={code}
|
||||||
|
onClick={activate}
|
||||||
|
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
activate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-role-perm-row__check">
|
||||||
|
<CheckStateIcon state={checked ? 'on' : 'off'} />
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-perm-row__main">
|
||||||
|
<span className="lx-role-perm-row__name">
|
||||||
|
{name}
|
||||||
|
{isSpecial ? <span className="lx-role-tag lx-role-tag--special">★ 特殊</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-perm-row__desc">{description}</span>
|
||||||
|
{isSpecial ? (
|
||||||
|
<span className="lx-role-perm-row__hint">
|
||||||
|
特殊权限需在「特殊权限」中单独授予并二次确认
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<code className="lx-role-perm-row__code">{code}</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleBlock({
|
||||||
|
module,
|
||||||
|
selected,
|
||||||
|
special,
|
||||||
|
conflicts,
|
||||||
|
readonly,
|
||||||
|
expanded,
|
||||||
|
onToggleExpanded,
|
||||||
|
onTogglePermission,
|
||||||
|
onToggleModule,
|
||||||
|
}: {
|
||||||
|
module: PermissionModuleNode;
|
||||||
|
selected: ReadonlySet<string>;
|
||||||
|
special: ReadonlySet<string>;
|
||||||
|
conflicts: ReadonlySet<string>;
|
||||||
|
readonly: boolean;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggleExpanded: () => void;
|
||||||
|
onTogglePermission: (code: string) => void;
|
||||||
|
onToggleModule: (module: PermissionModuleNode, selectAll: boolean) => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const normalCodes = normalPermissionsOf(module);
|
||||||
|
const selectedNormal = normalCodes.filter((code) => selected.has(code)).length;
|
||||||
|
const state: 'on' | 'off' | 'half' =
|
||||||
|
selectedNormal === 0 ? 'off' : selectedNormal === normalCodes.length ? 'on' : 'half';
|
||||||
|
const specialCount = module.permissions.filter((permission) => permission.isSpecial).length;
|
||||||
|
const totalSelected = moduleSelectedCount(module, selected, special);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={expanded ? 'lx-role-module is-open' : 'lx-role-module'}
|
||||||
|
data-module={module.module}
|
||||||
|
>
|
||||||
|
<div className="lx-role-module__head">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'lx-role-module__check',
|
||||||
|
readonly ? 'is-readonly' : '',
|
||||||
|
normalCodes.length === 0 ? 'is-locked' : '',
|
||||||
|
]
|
||||||
|
.filter((item) => item !== '')
|
||||||
|
.join(' ')}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={state === 'on' ? true : state === 'half' ? 'mixed' : false}
|
||||||
|
aria-disabled={readonly || normalCodes.length === 0}
|
||||||
|
tabIndex={readonly ? -1 : 0}
|
||||||
|
title={
|
||||||
|
normalCodes.length === 0
|
||||||
|
? '该模块权限点均为特殊权限,请在「特殊权限」中授予'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (readonly || normalCodes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onToggleModule(module, state !== 'on');
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (readonly || normalCodes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onToggleModule(module, state !== 'on');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckStateIcon state={state} />
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lx-role-module__toggle"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={onToggleExpanded}
|
||||||
|
>
|
||||||
|
<span className="lx-role-module__name">{module.moduleLabel}</span>
|
||||||
|
{specialCount > 0 ? (
|
||||||
|
<span className="lx-role-module__star" title="该模块含特殊权限">
|
||||||
|
★ {specialCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
totalSelected === 0 ? 'lx-role-module__count' : 'lx-role-module__count is-active'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{totalSelected} / {module.permissions.length}
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-module__caret">
|
||||||
|
{expanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded ? (
|
||||||
|
<div className="lx-role-module__body">
|
||||||
|
{module.permissions.map((permission) => (
|
||||||
|
<PermissionRow
|
||||||
|
key={permission.code}
|
||||||
|
code={permission.code}
|
||||||
|
name={permission.name}
|
||||||
|
description={permission.description}
|
||||||
|
isSpecial={permission.isSpecial}
|
||||||
|
checked={
|
||||||
|
permission.isSpecial ? special.has(permission.code) : selected.has(permission.code)
|
||||||
|
}
|
||||||
|
conflict={conflicts.has(permission.code)}
|
||||||
|
readonly={readonly}
|
||||||
|
onToggle={() => {
|
||||||
|
onTogglePermission(permission.code);
|
||||||
|
}}
|
||||||
|
onSpecialHint={() => {
|
||||||
|
notifyWarning('特殊权限需在「特殊权限」中单独授予并二次确认');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限点树:10 模块 / 30 点,模块三态、搜索、折叠;特殊权限树内只读 */
|
||||||
|
export function RolePermissionTree({
|
||||||
|
tree,
|
||||||
|
selected,
|
||||||
|
special,
|
||||||
|
conflicts,
|
||||||
|
readonly,
|
||||||
|
onTogglePermission,
|
||||||
|
onToggleModule,
|
||||||
|
}: RolePermissionTreeProps): ReactNode {
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() =>
|
||||||
|
initialExpandedModules(tree, selected, special),
|
||||||
|
);
|
||||||
|
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||||
|
const visibleModules = useMemo(() => {
|
||||||
|
const matched: PermissionModuleNode[] = [];
|
||||||
|
for (const module of tree.modules) {
|
||||||
|
const result = matchesKeyword(module, normalizedKeyword);
|
||||||
|
if (result !== null) {
|
||||||
|
matched.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}, [tree, normalizedKeyword]);
|
||||||
|
|
||||||
|
const isExpanded = (module: string): boolean => normalizedKeyword !== '' || expanded.has(module);
|
||||||
|
|
||||||
|
const setAllExpanded = (open: boolean): void => {
|
||||||
|
setExpanded(open ? new Set(tree.modules.map((module) => module.module)) : new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpanded = (module: string): void => {
|
||||||
|
setExpanded((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(module)) {
|
||||||
|
next.delete(module);
|
||||||
|
} else {
|
||||||
|
next.add(module);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-role-tree">
|
||||||
|
<div className="lx-role-tree__tools">
|
||||||
|
<Input
|
||||||
|
className="lx-role-tree__search"
|
||||||
|
value={keyword}
|
||||||
|
prefix={<IconSearch size={15} />}
|
||||||
|
placeholder="搜索权限点名称或编码"
|
||||||
|
aria-label="搜索权限点"
|
||||||
|
showClear
|
||||||
|
onChange={(value) => {
|
||||||
|
setKeyword(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="lx-role-tree__selected">
|
||||||
|
已选 <b>{selected.size + special.size}</b> / {tree.totalPermissions}
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-tree__links">
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setAllExpanded(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
展开全部
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setAllExpanded(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
收起全部
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{visibleModules.length === 0 ? (
|
||||||
|
<p className="lx-role-tree__empty">没有匹配的权限点,换个名称或编码再试。</p>
|
||||||
|
) : (
|
||||||
|
<div className="lx-role-tree__modules">
|
||||||
|
{visibleModules.map((module) => (
|
||||||
|
<ModuleBlock
|
||||||
|
key={module.module}
|
||||||
|
module={module}
|
||||||
|
selected={selected}
|
||||||
|
special={special}
|
||||||
|
conflicts={conflicts}
|
||||||
|
readonly={readonly}
|
||||||
|
expanded={isExpanded(module.module)}
|
||||||
|
onToggleExpanded={() => {
|
||||||
|
toggleExpanded(module.module);
|
||||||
|
}}
|
||||||
|
onTogglePermission={onTogglePermission}
|
||||||
|
onToggleModule={onToggleModule}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 单组件按原型区块展开,拆分反而降低可读性 */
|
||||||
|
import type { DataScopeType } from '@lingxi/shared';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { ROLE_SCOPE_OPTIONS, scopeOptionOf } from './role-display';
|
||||||
|
|
||||||
|
export interface RoleScopeValue {
|
||||||
|
type: DataScopeType;
|
||||||
|
targetIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleScopePickerProps {
|
||||||
|
value: RoleScopeValue;
|
||||||
|
readonly: boolean;
|
||||||
|
onChange: (type: DataScopeType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 六类数据范围单选卡片。
|
||||||
|
* A3 前客户组目录为空:选中「指定客户组」时展示空态,不伪造客户组数据。
|
||||||
|
*/
|
||||||
|
export function RoleScopePicker({ value, readonly, onChange }: RoleScopePickerProps): ReactNode {
|
||||||
|
const selected = scopeOptionOf(value.type);
|
||||||
|
return (
|
||||||
|
<div className="lx-role-scope">
|
||||||
|
<div className="lx-role-scope__grid" role="radiogroup" aria-label="数据范围">
|
||||||
|
{ROLE_SCOPE_OPTIONS.map((option) => {
|
||||||
|
const active = option.type === value.type;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.type}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
disabled={readonly}
|
||||||
|
className={active ? 'lx-role-scope__option is-active' : 'lx-role-scope__option'}
|
||||||
|
onClick={() => {
|
||||||
|
if (!readonly && !active) {
|
||||||
|
onChange(option.type);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-role-scope__radio" aria-hidden="true" />
|
||||||
|
<span className="lx-role-scope__copy">
|
||||||
|
<span className="lx-role-scope__name">{option.label}</span>
|
||||||
|
<span className="lx-role-scope__desc">{option.description}</span>
|
||||||
|
</span>
|
||||||
|
{option.needsTargets ? <span className="lx-role-scope__need">需选客户组</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{value.type === 'customer_groups' ? (
|
||||||
|
<div className="lx-role-scope__groups">
|
||||||
|
<div className="lx-role-scope__groups-head">
|
||||||
|
<b>客户组</b>
|
||||||
|
<span>
|
||||||
|
{value.targetIds.length === 0
|
||||||
|
? '选择客户组后按组批量授权'
|
||||||
|
: `已指定 ${String(value.targetIds.length)} 个客户组`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{value.targetIds.length === 0 ? (
|
||||||
|
<div className="lx-role-scope__empty">
|
||||||
|
<b>暂无可选客户组</b>
|
||||||
|
<span>
|
||||||
|
客户组将在客户模块上线后可选择;当前角色先按「指定客户组」保存,后续再补充授权范围。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="lx-role-scope__note">
|
||||||
|
已指定 {value.targetIds.length} 个客户组。客户组名称与覆盖客户将在客户模块上线后展示。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="lx-role-scope__foot">
|
||||||
|
<span className="lx-role-scope__foot-mark" aria-hidden="true" />
|
||||||
|
当前范围:{selected.label}
|
||||||
|
。数据范围随用户所属部门或负责客户动态计算,变更后立即对绑定用户生效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 单组件按原型区块展开,拆分反而降低可读性 */
|
||||||
|
import { Switch } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { PermissionTree } from '@lingxi/shared';
|
||||||
|
import { IconAlert, IconStar } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { specialPermissionRiskOf } from './role-display';
|
||||||
|
|
||||||
|
export interface RoleSpecialPermissionsProps {
|
||||||
|
tree: PermissionTree;
|
||||||
|
granted: ReadonlySet<string>;
|
||||||
|
readonly: boolean;
|
||||||
|
/** 当前角色绑定用户数(授予 / 撤销的影响面) */
|
||||||
|
holderCount: number;
|
||||||
|
onRequest: (code: string, granted: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 特殊权限区:6 个独立开关,授予 / 撤销由父级弹二次确认,不随复制带入 */
|
||||||
|
export function RoleSpecialPermissions({
|
||||||
|
tree,
|
||||||
|
granted,
|
||||||
|
readonly,
|
||||||
|
holderCount,
|
||||||
|
onRequest,
|
||||||
|
}: RoleSpecialPermissionsProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="lx-role-special">
|
||||||
|
<div className="lx-role-special__banner">
|
||||||
|
<IconAlert size={16} />
|
||||||
|
<span>特殊权限默认关闭,不随角色复制带入;授予后记录审计,并立即对绑定用户生效。</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-role-special__list">
|
||||||
|
{tree.specialPermissions.map((permission) => {
|
||||||
|
const active = granted.has(permission.code);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={permission.code}
|
||||||
|
className={active ? 'lx-role-special__row is-granted' : 'lx-role-special__row'}
|
||||||
|
>
|
||||||
|
<div className="lx-role-special__main">
|
||||||
|
<div className="lx-role-special__name">
|
||||||
|
{permission.name}
|
||||||
|
<span className="lx-role-tag lx-role-tag--special">★ 特殊</span>
|
||||||
|
</div>
|
||||||
|
<code className="lx-role-special__code">{permission.code}</code>
|
||||||
|
<p className="lx-role-special__desc">{permission.description}</p>
|
||||||
|
<p className="lx-role-special__risk">
|
||||||
|
<IconAlert size={13} />
|
||||||
|
{specialPermissionRiskOf(permission.code)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-role-special__side">
|
||||||
|
<Switch
|
||||||
|
checked={active}
|
||||||
|
disabled={readonly}
|
||||||
|
aria-label={permission.name}
|
||||||
|
onChange={(checked) => {
|
||||||
|
if (!readonly && checked !== active) {
|
||||||
|
onRequest(permission.code, checked);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="lx-role-special__holder">
|
||||||
|
{active ? <IconStar size={12} /> : null}
|
||||||
|
持有者 <b>{active ? holderCount : 0}</b> 名
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="lx-role-special__note">
|
||||||
|
特殊权限与普通权限分开授予:普通权限决定能做什么,特殊权限决定能否让数据离开平台或影响全部客户。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { RoleStatus } from '@lingxi/shared';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { roleStatusLabel } from './role-display';
|
||||||
|
|
||||||
|
export interface RoleStatusTagProps {
|
||||||
|
status: RoleStatus;
|
||||||
|
/** 列表行内只显示状态点,详情展示完整标签 */
|
||||||
|
dotOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleStatusTag({ status, dotOnly = false }: RoleStatusTagProps): ReactNode {
|
||||||
|
const active = status === 'active';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? dotOnly
|
||||||
|
? 'lx-status-dot'
|
||||||
|
: 'lx-status-tag is-active'
|
||||||
|
: dotOnly
|
||||||
|
? 'lx-status-dot is-disabled'
|
||||||
|
: 'lx-status-tag is-disabled'
|
||||||
|
}
|
||||||
|
title={roleStatusLabel(status)}
|
||||||
|
>
|
||||||
|
<span className="lx-status-tag__dot" aria-hidden="true" />
|
||||||
|
{dotOnly ? null : roleStatusLabel(status)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 列表页负责主从联动、筛选、菜单与五类弹窗编排,集中便于追踪数据流 */
|
||||||
|
import { Button, Input, Switch } from '@douyinfe/semi-ui-19';
|
||||||
|
import { Permission, type RoleDetail, type RoleListItem } from '@lingxi/shared';
|
||||||
|
import { notifySuccess } 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 {
|
||||||
|
useCreateRoleMutation,
|
||||||
|
useDeleteRoleMutation,
|
||||||
|
usePermissionTreeQuery,
|
||||||
|
useRoleDetailQuery,
|
||||||
|
useRoleListQuery,
|
||||||
|
useUpdateRoleMutation,
|
||||||
|
} from '@/hooks/queries/use-roles';
|
||||||
|
import { usePermission } from '@/hooks/use-permission';
|
||||||
|
import { roleDetailPath, ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CopyRoleDialog,
|
||||||
|
DeleteRoleDialog,
|
||||||
|
DisableRoleDialog,
|
||||||
|
RenameRoleDialog,
|
||||||
|
} from './role-dialogs';
|
||||||
|
import {
|
||||||
|
DEFAULT_ROLE_LIST_FILTERS,
|
||||||
|
filterRoles,
|
||||||
|
hasActiveRoleFilters,
|
||||||
|
type RoleListFilters,
|
||||||
|
summarizeRoles,
|
||||||
|
} from './role-display';
|
||||||
|
import { RoleDetailPanel } from './RoleDetailPanel';
|
||||||
|
import { RoleListPanel, type RoleRowAction } from './RoleListPanel';
|
||||||
|
|
||||||
|
/** 角色管理列表查询口径:角色 ≤ 20,一次拉全量后本地筛选(分页由列表卡兜底) */
|
||||||
|
const ROLE_LIST_QUERY = {
|
||||||
|
limit: 100,
|
||||||
|
status: 'all',
|
||||||
|
sort: 'created_at',
|
||||||
|
order: 'desc',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type DialogState = { kind: 'disable' | 'delete' | 'rename' | 'copy'; role: RoleListItem } | null;
|
||||||
|
|
||||||
|
function RoleFilterBar({
|
||||||
|
filters,
|
||||||
|
stats,
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
}: {
|
||||||
|
filters: RoleListFilters;
|
||||||
|
stats: ReturnType<typeof summarizeRoles>;
|
||||||
|
onChange: (next: Partial<RoleListFilters>) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<section className="lx-role-filters" aria-label="筛选角色">
|
||||||
|
<Input
|
||||||
|
className="lx-role-filters__keyword"
|
||||||
|
value={filters.keyword}
|
||||||
|
prefix={<IconSearch size={16} />}
|
||||||
|
placeholder="搜索角色名称或说明"
|
||||||
|
aria-label="搜索角色"
|
||||||
|
showClear
|
||||||
|
onChange={(value) => {
|
||||||
|
onChange({ keyword: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="lx-seg" role="group" aria-label="角色状态">
|
||||||
|
{[
|
||||||
|
{ value: 'all' as const, label: '全部', count: stats.total },
|
||||||
|
{ value: 'active' as const, label: '启用', count: stats.active },
|
||||||
|
{ value: 'disabled' as const, label: '停用', count: stats.disabled },
|
||||||
|
].map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={option.value === filters.status ? 'lx-seg__item is-active' : 'lx-seg__item'}
|
||||||
|
aria-pressed={option.value === filters.status}
|
||||||
|
onClick={() => {
|
||||||
|
onChange({ status: option.value });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
<i className="lx-role-filters__count">{option.count}</i>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="lx-role-filters__special">
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={filters.specialOnly}
|
||||||
|
onChange={(checked) => {
|
||||||
|
onChange({ specialOnly: checked });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
只看含特殊权限
|
||||||
|
</label>
|
||||||
|
<div className="lx-role-filters__stats" aria-label="角色统计">
|
||||||
|
<span className="lx-role-filters__chip">
|
||||||
|
<i>绑定用户</i>
|
||||||
|
<b>{stats.boundUsers}</b>
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-filters__chip">
|
||||||
|
<i>特殊权限授予</i>
|
||||||
|
<b>{stats.specialGrants}</b>
|
||||||
|
</span>
|
||||||
|
{hasActiveRoleFilters(filters) ? (
|
||||||
|
<Button theme="borderless" size="small" onClick={onReset}>
|
||||||
|
清除筛选
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 角色管理列表页:左角色列表 + 右只读详情面板(/system/roles) */
|
||||||
|
export function RolesPage(): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const canManage = usePermission(Permission.ROLE_MANAGE);
|
||||||
|
const tree = usePermissionTreeQuery();
|
||||||
|
const listQuery = useRoleListQuery(ROLE_LIST_QUERY);
|
||||||
|
const roles = useMemo(() => listQuery.data?.items ?? [], [listQuery.data]);
|
||||||
|
const [filters, setFilters] = useState<RoleListFilters>(DEFAULT_ROLE_LIST_FILTERS);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [dialog, setDialog] = useState<DialogState>(null);
|
||||||
|
const filteredRoles = useMemo(() => filterRoles(roles, filters), [roles, filters]);
|
||||||
|
const stats = useMemo(() => summarizeRoles(roles), [roles]);
|
||||||
|
const effectiveSelectedId = filteredRoles.some((role) => role.id === selectedId)
|
||||||
|
? selectedId
|
||||||
|
: (filteredRoles[0]?.id ?? null);
|
||||||
|
const detail = useRoleDetailQuery(effectiveSelectedId);
|
||||||
|
const copySource = useRoleDetailQuery(dialog?.kind === 'copy' ? dialog.role.id : null);
|
||||||
|
const updateRole = useUpdateRoleMutation();
|
||||||
|
const deleteRole = useDeleteRoleMutation();
|
||||||
|
const createRole = useCreateRoleMutation();
|
||||||
|
|
||||||
|
const openDialog = (kind: 'disable' | 'delete' | 'rename' | 'copy', role: RoleListItem): void => {
|
||||||
|
setDialog({ kind, role });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowAction = (action: RoleRowAction, role: RoleListItem): void => {
|
||||||
|
setSelectedId(role.id);
|
||||||
|
if (action === 'view' || action === 'edit') {
|
||||||
|
void navigate(roleDetailPath(role.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'enable') {
|
||||||
|
updateRole.mutate(
|
||||||
|
{ roleId: role.id, input: { status: 'enabled' } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
notifySuccess(`「${role.name}」已启用,${String(role.userCount)} 名用户下一次请求生效`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openDialog(action, role);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedDetail: RoleDetail | undefined = detail.data;
|
||||||
|
const activeRole = dialog?.role ?? selectedDetail ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-roles">
|
||||||
|
<header className="lx-roles__header">
|
||||||
|
<div className="lx-roles__head-txt">
|
||||||
|
<h1>角色管理</h1>
|
||||||
|
<p>配置功能权限、数据范围与特殊权限,变更即时生效并留痕</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-roles__header-actions">
|
||||||
|
<Can permission={Permission.ROLE_MANAGE}>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
icon={<IconAdd size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(ROUTES.system.roleNew);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建角色
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<RoleFilterBar
|
||||||
|
filters={filters}
|
||||||
|
stats={stats}
|
||||||
|
onChange={(next) => {
|
||||||
|
setFilters((current) => ({ ...current, ...next }));
|
||||||
|
}}
|
||||||
|
onReset={() => {
|
||||||
|
setFilters(DEFAULT_ROLE_LIST_FILTERS);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="lx-roles__split">
|
||||||
|
<RoleListPanel
|
||||||
|
roles={filteredRoles}
|
||||||
|
totalCount={roles.length}
|
||||||
|
loading={listQuery.isPending}
|
||||||
|
error={listQuery.error}
|
||||||
|
selectedRoleId={effectiveSelectedId}
|
||||||
|
note={`内置 ${String(stats.systemCount)} · 自定义 ${String(stats.total - stats.systemCount)}`}
|
||||||
|
canManage={canManage}
|
||||||
|
showRowMenu
|
||||||
|
isFiltered={hasActiveRoleFilters(filters)}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
onRowAction={handleRowAction}
|
||||||
|
onCreate={() => {
|
||||||
|
void navigate(ROUTES.system.roleNew);
|
||||||
|
}}
|
||||||
|
onResetFilters={() => {
|
||||||
|
setFilters(DEFAULT_ROLE_LIST_FILTERS);
|
||||||
|
}}
|
||||||
|
onRetry={() => {
|
||||||
|
void listQuery.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<RoleDetailPanel
|
||||||
|
roleId={effectiveSelectedId}
|
||||||
|
detail={selectedDetail}
|
||||||
|
tree={tree.data}
|
||||||
|
loading={detail.isPending}
|
||||||
|
error={detail.error}
|
||||||
|
canManage={canManage}
|
||||||
|
onRetry={() => {
|
||||||
|
void detail.refetch();
|
||||||
|
}}
|
||||||
|
onEdit={(roleId) => {
|
||||||
|
void navigate(roleDetailPath(roleId));
|
||||||
|
}}
|
||||||
|
onCopy={(role) => {
|
||||||
|
openDialog('copy', role);
|
||||||
|
}}
|
||||||
|
onRename={(role) => {
|
||||||
|
openDialog('rename', role);
|
||||||
|
}}
|
||||||
|
onDisable={(role) => {
|
||||||
|
openDialog('disable', role);
|
||||||
|
}}
|
||||||
|
onEnable={(role) => {
|
||||||
|
updateRole.mutate(
|
||||||
|
{ roleId: role.id, input: { status: 'enabled' } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
notifySuccess(`「${role.name}」已启用`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DisableRoleDialog
|
||||||
|
key={`disable-${dialog?.kind === 'disable' ? dialog.role.id : 'closed'}`}
|
||||||
|
visible={dialog?.kind === 'disable'}
|
||||||
|
role={activeRole}
|
||||||
|
loading={updateRole.isPending}
|
||||||
|
onCancel={() => {
|
||||||
|
setDialog(null);
|
||||||
|
}}
|
||||||
|
onConfirm={(reason) => {
|
||||||
|
if (dialog === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateRole.mutate(
|
||||||
|
{
|
||||||
|
roleId: dialog.role.id,
|
||||||
|
input: { status: 'disabled', ...(reason === '' ? {} : { reason }) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setDialog(null);
|
||||||
|
notifySuccess(
|
||||||
|
`「${dialog.role.name}」已停用,${String(dialog.role.userCount)} 名用户下一次请求生效`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DeleteRoleDialog
|
||||||
|
key={`delete-${dialog?.kind === 'delete' ? dialog.role.id : 'closed'}`}
|
||||||
|
visible={dialog?.kind === 'delete'}
|
||||||
|
role={activeRole}
|
||||||
|
loading={deleteRole.isPending}
|
||||||
|
onCancel={() => {
|
||||||
|
setDialog(null);
|
||||||
|
}}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (dialog === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteRole.mutate(dialog.role.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedId(null);
|
||||||
|
setDialog(null);
|
||||||
|
notifySuccess('角色已删除');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<RenameRoleDialog
|
||||||
|
key={`rename-${dialog?.kind === 'rename' ? dialog.role.id : 'closed'}`}
|
||||||
|
visible={dialog?.kind === 'rename'}
|
||||||
|
role={activeRole}
|
||||||
|
loading={updateRole.isPending}
|
||||||
|
onCancel={() => {
|
||||||
|
setDialog(null);
|
||||||
|
}}
|
||||||
|
onConfirm={(name) => {
|
||||||
|
if (dialog === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateRole.mutate(
|
||||||
|
{ roleId: dialog.role.id, input: { name } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setDialog(null);
|
||||||
|
notifySuccess('角色名称已更新');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CopyRoleDialog
|
||||||
|
key={`copy-${
|
||||||
|
dialog?.kind === 'copy'
|
||||||
|
? `${dialog.role.id}-${copySource.data?.id ?? 'loading'}`
|
||||||
|
: 'closed'
|
||||||
|
}`}
|
||||||
|
visible={dialog?.kind === 'copy'}
|
||||||
|
source={copySource.data ?? null}
|
||||||
|
sourceLoading={copySource.isPending}
|
||||||
|
loading={createRole.isPending}
|
||||||
|
onCancel={() => {
|
||||||
|
setDialog(null);
|
||||||
|
}}
|
||||||
|
onConfirm={(name) => {
|
||||||
|
const source = copySource.data;
|
||||||
|
if (source === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createRole.mutate(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
description: source.description,
|
||||||
|
permissions: [...source.permissions],
|
||||||
|
dataScope: {
|
||||||
|
type: source.dataScope.type,
|
||||||
|
...(source.dataScope.targetIds === undefined
|
||||||
|
? {}
|
||||||
|
: { targetIds: [...source.dataScope.targetIds] }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setDialog(null);
|
||||||
|
notifySuccess('已创建「' + result.role.name + '」,可在编辑器中继续调整权限');
|
||||||
|
void navigate(roleDetailPath(result.role.id));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 角色二次确认弹窗模板固定,集中在一处便于统一文案口径 */
|
||||||
|
import { Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { PermissionNode, RoleDetail, RoleListItem } from '@lingxi/shared';
|
||||||
|
import { IconAlert, IconShield, IconStar } from '@lingxi/ui/icons';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { RolePermissionMeta } from './role-display';
|
||||||
|
import {
|
||||||
|
permissionNameOf,
|
||||||
|
roleStatusLabel,
|
||||||
|
scopeLabelOf,
|
||||||
|
specialPermissionRiskOf,
|
||||||
|
} from './role-display';
|
||||||
|
import type { RoleDraftDiff } from './role-utils';
|
||||||
|
|
||||||
|
/** 停用 / 删除 / 改名弹窗所需的最小角色字段(列表项与详情均满足) */
|
||||||
|
export type RoleDialogTarget = Pick<
|
||||||
|
RoleListItem,
|
||||||
|
'id' | 'name' | 'code' | 'status' | 'userCount' | 'dataScope' | 'isSystem'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface RoleObjectCardProps {
|
||||||
|
name: string;
|
||||||
|
meta: string;
|
||||||
|
status?: string;
|
||||||
|
icon?: ComponentType<{ size?: number | string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 弹窗内的角色对象卡 */
|
||||||
|
export function RoleObjectCard({
|
||||||
|
name,
|
||||||
|
meta,
|
||||||
|
status,
|
||||||
|
icon: Icon = IconShield,
|
||||||
|
}: RoleObjectCardProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="lx-role-dialog__object">
|
||||||
|
<span className="lx-role-dialog__object-icon" aria-hidden="true">
|
||||||
|
<Icon size={16} />
|
||||||
|
</span>
|
||||||
|
<span className="lx-role-dialog__object-copy">
|
||||||
|
<span className="lx-role-dialog__object-title">{name}</span>
|
||||||
|
<span className="lx-role-dialog__object-meta">{meta}</span>
|
||||||
|
</span>
|
||||||
|
{status === undefined ? null : <span className="lx-role-tag">{status}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogWarn({
|
||||||
|
children,
|
||||||
|
tone = 'warning',
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
tone?: 'warning' | 'danger' | 'info';
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={`lx-role-dialog__warn is-${tone}`}>
|
||||||
|
<IconAlert size={15} />
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹窗内容由父级通过 key 控制重挂载:打开时重置初始值,关闭时卸载,避免 effect 同步状态。
|
||||||
|
|
||||||
|
export interface DisableRoleDialogProps {
|
||||||
|
visible: boolean;
|
||||||
|
role: RoleDialogTarget | null;
|
||||||
|
loading: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (reason: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停用角色:影响用户数 + 勾选知晓;停用可随时恢复 */
|
||||||
|
export function DisableRoleDialog({
|
||||||
|
visible,
|
||||||
|
role,
|
||||||
|
loading,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: DisableRoleDialogProps): ReactNode {
|
||||||
|
const [acknowledged, setAcknowledged] = useState(false);
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title="停用角色"
|
||||||
|
className="lx-role-modal"
|
||||||
|
width={520}
|
||||||
|
maskClosable={false}
|
||||||
|
closeOnEsc={!loading}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span className="lx-role-dialog__foot-note">
|
||||||
|
停用可随时恢复,不会删除角色及其权限配置
|
||||||
|
</span>
|
||||||
|
<Button onClick={onCancel} disabled={loading}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="danger"
|
||||||
|
disabled={!acknowledged || loading}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm(reason.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
停用角色
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="lx-role-dialog">
|
||||||
|
{role === null ? null : (
|
||||||
|
<RoleObjectCard
|
||||||
|
name={role.name}
|
||||||
|
meta={`${role.code} · ${scopeLabelOf(role.dataScope.type)} · ${String(role.userCount)} 名用户`}
|
||||||
|
status={roleStatusLabel(role.status)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DialogWarn>
|
||||||
|
停用后,{role?.userCount ?? 0}{' '}
|
||||||
|
名绑定用户将在下一次请求即失去该角色权限;停用不会删除角色配置。
|
||||||
|
</DialogWarn>
|
||||||
|
<label className="lx-role-dialog__field">
|
||||||
|
<span>停用原因(选填,写入审计)</span>
|
||||||
|
<Input
|
||||||
|
value={reason}
|
||||||
|
maxLength={200}
|
||||||
|
placeholder="例如:岗位调整、角色合并"
|
||||||
|
onChange={setReason}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Checkbox
|
||||||
|
checked={acknowledged}
|
||||||
|
onChange={(event) => {
|
||||||
|
setAcknowledged(event.target.checked === true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
我已知晓停用后这些用户下一次请求即失去该角色权限
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteRoleDialogProps {
|
||||||
|
visible: boolean;
|
||||||
|
role: RoleDialogTarget | null;
|
||||||
|
loading: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除角色:仅 0 绑定用户可用,删除不可恢复 */
|
||||||
|
export function DeleteRoleDialog({
|
||||||
|
visible,
|
||||||
|
role,
|
||||||
|
loading,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteRoleDialogProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title="删除角色"
|
||||||
|
className="lx-role-modal"
|
||||||
|
width={520}
|
||||||
|
maskClosable={false}
|
||||||
|
closeOnEsc={!loading}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span className="lx-role-dialog__foot-note">删除后不可恢复,审计日志保留授权记录</span>
|
||||||
|
<Button onClick={onCancel} disabled={loading}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="danger"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={loading || (role?.userCount ?? 0) > 0}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
删除角色
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="lx-role-dialog">
|
||||||
|
{role === null ? null : (
|
||||||
|
<RoleObjectCard
|
||||||
|
name={role.name}
|
||||||
|
meta={`${role.code} · ${String(role.userCount)} 名用户`}
|
||||||
|
status="自定义"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DialogWarn tone="danger">
|
||||||
|
删除后不可恢复,该角色的权限配置会一并删除,授权记录会保留在审计日志中。
|
||||||
|
</DialogWarn>
|
||||||
|
<p className="lx-role-dialog__hint">
|
||||||
|
{role !== null && role.userCount > 0
|
||||||
|
? '该角色仍有绑定用户,无法删除;请先将用户调整到其他角色,或改为停用。'
|
||||||
|
: '该角色当前没有绑定用户,可从模板重新创建。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenameRoleDialogProps {
|
||||||
|
visible: boolean;
|
||||||
|
role: RoleDialogTarget | null;
|
||||||
|
loading: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 系统角色改名:仅显示名可改 */
|
||||||
|
export function RenameRoleDialog({
|
||||||
|
visible,
|
||||||
|
role,
|
||||||
|
loading,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: RenameRoleDialogProps): ReactNode {
|
||||||
|
const [name, setName] = useState(() => role?.name ?? '');
|
||||||
|
const trimmed = name.trim();
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title="修改角色名称"
|
||||||
|
className="lx-role-modal"
|
||||||
|
width={520}
|
||||||
|
onCancel={onCancel}
|
||||||
|
closeOnEsc={!loading}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span className="lx-role-dialog__foot-note">改名会记录审计</span>
|
||||||
|
<Button onClick={onCancel} disabled={loading}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
disabled={trimmed === '' || loading}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm(trimmed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存名称
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="lx-role-dialog">
|
||||||
|
<label className="lx-role-dialog__field">
|
||||||
|
<span>角色名称</span>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
maxLength={64}
|
||||||
|
onChange={setName}
|
||||||
|
onEnterPress={() => {
|
||||||
|
if (trimmed !== '' && !loading) {
|
||||||
|
onConfirm(trimmed);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<DialogWarn tone="info">
|
||||||
|
超级管理员为系统内置角色,仅显示名可修改;权限、数据范围与特殊权限由系统保证。
|
||||||
|
</DialogWarn>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CopyRoleDialogProps {
|
||||||
|
visible: boolean;
|
||||||
|
source: RoleDetail | null;
|
||||||
|
sourceLoading: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复制角色:带入普通权限与数据范围,特殊权限不随复制带入 */
|
||||||
|
export function CopyRoleDialog({
|
||||||
|
visible,
|
||||||
|
source,
|
||||||
|
sourceLoading,
|
||||||
|
loading,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: CopyRoleDialogProps): ReactNode {
|
||||||
|
const [name, setName] = useState(() => (source === null ? '' : `${source.name} 副本`));
|
||||||
|
const trimmed = name.trim();
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title="复制角色"
|
||||||
|
className="lx-role-modal"
|
||||||
|
width={520}
|
||||||
|
onCancel={onCancel}
|
||||||
|
closeOnEsc={!loading}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span className="lx-role-dialog__foot-note">新角色创建后状态为启用</span>
|
||||||
|
<Button onClick={onCancel} disabled={loading}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
disabled={trimmed === '' || loading || sourceLoading || source === null}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm(trimmed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
创建副本
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="lx-role-dialog">
|
||||||
|
{source === null ? (
|
||||||
|
<p className="lx-role-dialog__hint">
|
||||||
|
{sourceLoading ? '正在读取来源角色…' : '来源角色不可用'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<RoleObjectCard
|
||||||
|
name={source.name}
|
||||||
|
meta={`${source.code} · ${scopeLabelOf(source.dataScope.type)} · ${String(source.permissionCount)} 个权限点`}
|
||||||
|
status={roleStatusLabel(source.status)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<label className="lx-role-dialog__field">
|
||||||
|
<span>新角色名称</span>
|
||||||
|
<Input value={name} maxLength={64} placeholder="请输入新角色名称" onChange={setName} />
|
||||||
|
</label>
|
||||||
|
<DialogWarn tone="warning">
|
||||||
|
将带入 {source?.permissionCount ?? 0} 个普通权限点与「
|
||||||
|
{source === null ? '—' : scopeLabelOf(source.dataScope.type)}
|
||||||
|
」数据范围;特殊权限不会随复制带入, 需创建后单独授予。
|
||||||
|
</DialogWarn>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecialPermissionDialogProps {
|
||||||
|
visible: boolean;
|
||||||
|
permission: PermissionNode | null;
|
||||||
|
granted: boolean;
|
||||||
|
holderCount: number;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 特殊权限授予 / 撤销二次确认(保存前不落库,确认后更新草稿) */
|
||||||
|
export function SpecialPermissionDialog({
|
||||||
|
visible,
|
||||||
|
permission,
|
||||||
|
granted,
|
||||||
|
holderCount,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: SpecialPermissionDialogProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title={granted ? '授予特殊权限' : '撤销特殊权限'}
|
||||||
|
className="lx-role-modal"
|
||||||
|
width={520}
|
||||||
|
maskClosable={false}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span className="lx-role-dialog__foot-note">
|
||||||
|
{granted ? '授予后立即生效' : '撤销后立即生效'}
|
||||||
|
</span>
|
||||||
|
<Button onClick={onCancel}>取消</Button>
|
||||||
|
<Button theme="solid" type={granted ? 'primary' : 'danger'} onClick={onConfirm}>
|
||||||
|
{granted ? '确认授予' : '确认撤销'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="lx-role-dialog">
|
||||||
|
<DialogWarn tone={granted ? 'warning' : 'danger'}>
|
||||||
|
{granted
|
||||||
|
? `将为 ${String(holderCount)} 名用户授予「${permission?.name ?? ''}」特殊权限。该权限不随角色复制带入,授予后记录审计。`
|
||||||
|
: `撤销后 ${String(holderCount)} 名用户将立即失去「${permission?.name ?? ''}」权限,撤销会记录审计。`}
|
||||||
|
</DialogWarn>
|
||||||
|
{permission === null ? null : (
|
||||||
|
<RoleObjectCard
|
||||||
|
name={permission.name}
|
||||||
|
meta={permission.code}
|
||||||
|
status="特殊权限"
|
||||||
|
icon={IconStar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="lx-role-dialog__hint">
|
||||||
|
{permission === null
|
||||||
|
? ''
|
||||||
|
: `${permission.description}。${specialPermissionRiskOf(permission.code)}。`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveRoleDialogProps {
|
||||||
|
visible: boolean;
|
||||||
|
role: RoleDetail | null;
|
||||||
|
diff: RoleDraftDiff | null;
|
||||||
|
meta: ReadonlyMap<string, RolePermissionMeta> | null;
|
||||||
|
baseName: string;
|
||||||
|
draftName: string;
|
||||||
|
baseScope: string;
|
||||||
|
draftScope: string;
|
||||||
|
loading: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangeRow({ kind, text }: { kind: string; text: string }): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="lx-role-dialog__change">
|
||||||
|
<span className="lx-role-dialog__change-kind">{kind}</span>
|
||||||
|
<span className="lx-role-dialog__change-text">{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存前影响面确认:权限 diff + 基本信息 + 数据范围 + 影响用户数 */
|
||||||
|
export function SaveRoleDialog({
|
||||||
|
visible,
|
||||||
|
role,
|
||||||
|
diff,
|
||||||
|
meta,
|
||||||
|
baseName,
|
||||||
|
draftName,
|
||||||
|
baseScope,
|
||||||
|
draftScope,
|
||||||
|
loading,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: SaveRoleDialogProps): ReactNode {
|
||||||
|
const names = (codes: readonly string[]): string =>
|
||||||
|
codes.map((code) => (meta === null ? code : permissionNameOf(meta, code))).join('、');
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title="保存角色变更"
|
||||||
|
className="lx-role-modal"
|
||||||
|
width={560}
|
||||||
|
maskClosable={false}
|
||||||
|
closeOnEsc={!loading}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span className="lx-role-dialog__foot-note">可在审计日志中按角色检索本次变更</span>
|
||||||
|
<Button onClick={onCancel} disabled={loading}>
|
||||||
|
继续编辑
|
||||||
|
</Button>
|
||||||
|
<Button theme="solid" type="primary" onClick={onConfirm} loading={loading}>
|
||||||
|
保存并生效
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="lx-role-dialog">
|
||||||
|
{role === null ? null : (
|
||||||
|
<RoleObjectCard
|
||||||
|
name={role.name}
|
||||||
|
meta={`${role.code} · ${String(role.userCount)} 名用户`}
|
||||||
|
status={roleStatusLabel(role.status)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="lx-role-dialog__changes">
|
||||||
|
<div className="lx-role-dialog__changes-head">
|
||||||
|
变更摘要
|
||||||
|
<span>{diff?.totalChanges ?? 0} 项</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-role-dialog__change-list">
|
||||||
|
{diff === null ? null : (
|
||||||
|
<>
|
||||||
|
{diff.addedPermissions.length + diff.addedSpecial.length > 0 ? (
|
||||||
|
<ChangeRow
|
||||||
|
kind="新增"
|
||||||
|
text={names([...diff.addedPermissions, ...diff.addedSpecial])}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{diff.removedPermissions.length + diff.removedSpecial.length > 0 ? (
|
||||||
|
<ChangeRow
|
||||||
|
kind="移除"
|
||||||
|
text={names([...diff.removedPermissions, ...diff.removedSpecial])}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{diff.nameChanged ? (
|
||||||
|
<ChangeRow kind="名称" text={`${baseName} → ${draftName}`} />
|
||||||
|
) : null}
|
||||||
|
{diff.descriptionChanged ? <ChangeRow kind="说明" text="已更新角色说明" /> : null}
|
||||||
|
{diff.scopeChanged ? (
|
||||||
|
<ChangeRow kind="范围" text={`${baseScope} → ${draftScope}`} />
|
||||||
|
) : null}
|
||||||
|
{!diff.hasPermissionChanges &&
|
||||||
|
!diff.hasSpecialChanges &&
|
||||||
|
!diff.nameChanged &&
|
||||||
|
!diff.descriptionChanged &&
|
||||||
|
!diff.scopeChanged ? (
|
||||||
|
<ChangeRow kind="—" text="本次没有实际变更" />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="lx-role-dialog__hint">
|
||||||
|
保存后,{role?.userCount ?? 0} 名用户将在下一次请求生效,变更会记录审计(role.updated)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveRoleDialogProps {
|
||||||
|
visible: boolean;
|
||||||
|
roleName: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未保存离开提示(路由拦截 / 返回列表 / 切换角色共用) */
|
||||||
|
export function LeaveRoleDialog({
|
||||||
|
visible,
|
||||||
|
roleName,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: LeaveRoleDialogProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title="放弃未保存的修改?"
|
||||||
|
className="lx-role-modal"
|
||||||
|
width={480}
|
||||||
|
maskClosable={false}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span className="lx-role-dialog__foot-note">「{roleName}」有未保存的修改</span>
|
||||||
|
<Button onClick={onCancel}>继续编辑</Button>
|
||||||
|
<Button theme="solid" type="danger" onClick={onConfirm}>
|
||||||
|
放弃并离开
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="lx-role-dialog">
|
||||||
|
<DialogWarn tone="warning">
|
||||||
|
当前角色的权限、数据范围与基本信息修改尚未保存,离开后这些修改将丢失。
|
||||||
|
</DialogWarn>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type { PermissionTree, RoleListItem } from '@lingxi/shared';
|
||||||
|
import { permissionTreeSchema, roleListItemSchema } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildPermissionMetaMap,
|
||||||
|
DEFAULT_ROLE_LIST_FILTERS,
|
||||||
|
filterRoles,
|
||||||
|
formatRoleDateTime,
|
||||||
|
hasActiveRoleFilters,
|
||||||
|
permissionNameOf,
|
||||||
|
scopeLabelOf,
|
||||||
|
summarizePermissionModules,
|
||||||
|
summarizeRoles,
|
||||||
|
} from './role-display';
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
function buildRole(overrides: Record<string, unknown> = {}): RoleListItem {
|
||||||
|
index += 1;
|
||||||
|
return roleListItemSchema.parse({
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||||
|
code: `role_${String(index)}`,
|
||||||
|
name: `角色 ${String(index)}`,
|
||||||
|
description: '角色说明',
|
||||||
|
isSystem: false,
|
||||||
|
status: 'active',
|
||||||
|
permissionCount: 4,
|
||||||
|
specialPermissionCount: 0,
|
||||||
|
userCount: 3,
|
||||||
|
dataScope: { type: 'department' },
|
||||||
|
createdAt: '2026-10-27T02:40:00.000Z',
|
||||||
|
updatedAt: '2026-10-27T02:40:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree: PermissionTree = permissionTreeSchema.parse({
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
module: 'script',
|
||||||
|
moduleLabel: '脚本',
|
||||||
|
sortOrder: 10,
|
||||||
|
permissions: [
|
||||||
|
{ code: 'script:read', name: '查看脚本', description: '脚本库', isSpecial: false },
|
||||||
|
{ code: 'script:write', name: '编辑与生成脚本', description: '编辑', isSpecial: false },
|
||||||
|
{ code: 'script:review', name: '审核脚本', description: '审核', isSpecial: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: 'delivery',
|
||||||
|
moduleLabel: '导出交付',
|
||||||
|
sortOrder: 40,
|
||||||
|
permissions: [
|
||||||
|
{ code: 'delivery:read', name: '查看交付记录', description: '交付清单', isSpecial: false },
|
||||||
|
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
specialPermissions: [
|
||||||
|
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
|
||||||
|
],
|
||||||
|
sodPairs: [
|
||||||
|
{
|
||||||
|
id: 'script-write-review',
|
||||||
|
label: '脚本生成 × 脚本审核',
|
||||||
|
description: '不得同时拥有',
|
||||||
|
permissions: ['script:write', 'script:review'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalPermissions: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('权限点摘要与元数据', () => {
|
||||||
|
it('按 10 模块口径统计已选 / 总数(含特殊权限)', () => {
|
||||||
|
const summary = summarizePermissionModules(tree, [
|
||||||
|
'script:read',
|
||||||
|
'script:review',
|
||||||
|
'delivery:export',
|
||||||
|
]);
|
||||||
|
expect(summary).toEqual([
|
||||||
|
{ module: 'script', moduleLabel: '脚本', selected: 2, total: 3 },
|
||||||
|
{ module: 'delivery', moduleLabel: '导出交付', selected: 1, total: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('权限点编码映射到名称,缺失时回退编码', () => {
|
||||||
|
const meta = buildPermissionMetaMap(tree);
|
||||||
|
expect(permissionNameOf(meta, 'delivery:export')).toBe('发起导出');
|
||||||
|
expect(meta.get('delivery:export')?.isSpecial).toBe(true);
|
||||||
|
expect(permissionNameOf(meta, 'ghost:code')).toBe('ghost:code');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('数据范围与时间展示', () => {
|
||||||
|
it('六类数据范围名称与未知类型兜底', () => {
|
||||||
|
expect(scopeLabelOf('customer_groups')).toBe('指定客户组');
|
||||||
|
expect(scopeLabelOf('department_and_children')).toBe('本部门及下级');
|
||||||
|
expect(scopeLabelOf('created_by_me')).toBe('本人创建');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ISO 时间转 YYYY-MM-DD HH:mm,空值与非法值显示占位符', () => {
|
||||||
|
expect(formatRoleDateTime('2026-10-27T02:40:00.000Z')).toMatch(/^2026-10-27 \d{2}:40$/);
|
||||||
|
expect(formatRoleDateTime(null)).toBe('—');
|
||||||
|
expect(formatRoleDateTime('not-a-date')).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('角色统计与筛选', () => {
|
||||||
|
it('汇总角色 / 启用 / 停用 / 绑定用户 / 特殊授予 / 内置数', () => {
|
||||||
|
const stats = summarizeRoles([
|
||||||
|
buildRole({ isSystem: true, userCount: 2, specialPermissionCount: 6 }),
|
||||||
|
buildRole({ status: 'disabled', userCount: 0 }),
|
||||||
|
buildRole({ userCount: 5, specialPermissionCount: 1 }),
|
||||||
|
]);
|
||||||
|
expect(stats).toEqual({
|
||||||
|
total: 3,
|
||||||
|
active: 2,
|
||||||
|
disabled: 1,
|
||||||
|
boundUsers: 7,
|
||||||
|
specialGrants: 7,
|
||||||
|
systemCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按关键词 / 状态 / 只看特殊权限筛选', () => {
|
||||||
|
const roles = [
|
||||||
|
buildRole({ name: '审核员', code: 'reviewer', description: '脚本与素材审核' }),
|
||||||
|
buildRole({ name: '设计师', code: 'designer', status: 'disabled' }),
|
||||||
|
buildRole({ name: '行业配置', specialPermissionCount: 1 }),
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
filterRoles(roles, { ...DEFAULT_ROLE_LIST_FILTERS, keyword: '审核' }).map(
|
||||||
|
(role) => role.code,
|
||||||
|
),
|
||||||
|
).toEqual(['reviewer']);
|
||||||
|
expect(
|
||||||
|
filterRoles(roles, { ...DEFAULT_ROLE_LIST_FILTERS, status: 'disabled' }).map(
|
||||||
|
(role) => role.name,
|
||||||
|
),
|
||||||
|
).toEqual(['设计师']);
|
||||||
|
expect(
|
||||||
|
filterRoles(roles, { ...DEFAULT_ROLE_LIST_FILTERS, specialOnly: true }).map(
|
||||||
|
(role) => role.name,
|
||||||
|
),
|
||||||
|
).toEqual(['行业配置']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('识别是否存在生效筛选', () => {
|
||||||
|
expect(hasActiveRoleFilters(DEFAULT_ROLE_LIST_FILTERS)).toBe(false);
|
||||||
|
expect(hasActiveRoleFilters({ ...DEFAULT_ROLE_LIST_FILTERS, keyword: ' ' })).toBe(false);
|
||||||
|
expect(hasActiveRoleFilters({ ...DEFAULT_ROLE_LIST_FILTERS, specialOnly: true })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user