Compare commits
12
Commits
cda4b1825b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5a2914214 | ||
|
|
02c716d6f2 | ||
|
|
f16d04fe02 | ||
|
|
3b0a99d91f | ||
|
|
e4330a43e4 | ||
|
|
cc6e84cdbc | ||
|
|
32b44bdeec | ||
|
|
50afaee49c | ||
|
|
c90a036f82 | ||
|
|
3e4bf48841 | ||
|
|
140dc386c1 | ||
|
|
8626996bb1 |
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,7 +114,7 @@ docs/ PRD / 技术架构 / 立项建议 / 工程规范
|
|||||||
| 开发 | `pnpm dev` |
|
| 开发 | `pnpm dev` |
|
||||||
| 构建 | `pnpm build` |
|
| 构建 | `pnpm build` |
|
||||||
| 质量 | `pnpm lint` · `pnpm lint:fix` · `pnpm format` · `pnpm format:check` · `pnpm typecheck` |
|
| 质量 | `pnpm lint` · `pnpm lint:fix` · `pnpm format` · `pnpm format:check` · `pnpm typecheck` |
|
||||||
| 测试 | `pnpm test` · `pnpm test:int` · `pnpm test:e2e` · `pnpm ci:local` |
|
| 测试 | `pnpm test` · `pnpm test:int` · `pnpm test:e2e` · `pnpm ci:local`(CI 只本地执行,远端无 runner) |
|
||||||
| 数据库 | `pnpm db:init`(迁移 + 幂等种子,不重置数据)· `pnpm db:migrate` · `pnpm db:reset`(清库重建;默认被拦截,必须 `ALLOW_DB_RESET=1` 显式确认;禁止手工删库 / 删数据卷) |
|
| 数据库 | `pnpm db:init`(迁移 + 幂等种子,不重置数据)· `pnpm db:migrate` · `pnpm db:reset`(清库重建;默认被拦截,必须 `ALLOW_DB_RESET=1` 显式确认;禁止手工删库 / 删数据卷) |
|
||||||
| 契约 | `pnpm openapi:diff` |
|
| 契约 | `pnpm openapi:diff` |
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ docs/ PRD / 技术架构 / 立项建议 / 工程规范
|
|||||||
1. 基于最新 `main` 直接提交,小步 commit(**Conventional Commits,提交信息一律用中文**,`type` / `scope` 保留英文)。
|
1. 基于最新 `main` 直接提交,小步 commit(**Conventional Commits,提交信息一律用中文**,`type` / `scope` 保留英文)。
|
||||||
2. 本地门禁:`pnpm format:check && pnpm lint && pnpm typecheck && pnpm test`。
|
2. 本地门禁:`pnpm format:check && pnpm lint && pnpm typecheck && pnpm test`。
|
||||||
3. 提交说明必含:**背景 / 改动 / 影响面 / 回滚方式 / 验证证据**;迁移、权限、AI、导出类变更须在推送前完成双人走查。
|
3. 提交说明必含:**背景 / 改动 / 影响面 / 回滚方式 / 验证证据**;迁移、权限、AI、导出类变更须在推送前完成双人走查。
|
||||||
4. 直接 `git push origin main`;禁止 force push `main`,禁止 Merge commit;推送后立即确认 CI,失败按前向修复处理。
|
4. 直接 `git push origin main`;禁止 force push `main`,禁止 Merge commit。**远端 Gitea 未部署 Actions runner,线上 CI 不可用,禁止等待/依赖远端 CI**;CI 一律在推送前本地执行(`pnpm ci:local`),本地门禁全绿才能推送。
|
||||||
5. 发布:staging 验证 → 生产灰度;**迁移先行、代码后行**。
|
5. 发布:staging 验证 → 生产灰度;**迁移先行、代码后行**。
|
||||||
|
|
||||||
细则:[Git 规范](docs/工程规范/01-Git规范.md) · [Code Review](docs/工程规范/07-CodeReview与PR规范.md) · [测试与 CI](docs/工程规范/06-测试与CI规范.md)
|
细则:[Git 规范](docs/工程规范/01-Git规范.md) · [Code Review](docs/工程规范/07-CodeReview与PR规范.md) · [测试与 CI](docs/工程规范/06-测试与CI规范.md)
|
||||||
@@ -154,7 +154,7 @@ docs/ PRD / 技术架构 / 立项建议 / 工程规范
|
|||||||
| API 与路由 | [04-API与路由规范](docs/工程规范/04-API与路由规范.md) | REST /api/v1、错误码、鉴权链、任务协议 |
|
| API 与路由 | [04-API与路由规范](docs/工程规范/04-API与路由规范.md) | REST /api/v1、错误码、鉴权链、任务协议 |
|
||||||
| 数据库 | [05-数据库规范](docs/工程规范/05-数据库规范.md) | ULID、customer_id、游标分页、expand/contract |
|
| 数据库 | [05-数据库规范](docs/工程规范/05-数据库规范.md) | ULID、customer_id、游标分页、expand/contract |
|
||||||
| 测试与 CI | [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md) | 分层测试、权限三态、覆盖率只升不降 |
|
| 测试与 CI | [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md) | 分层测试、权限三态、覆盖率只升不降 |
|
||||||
| Code Review | [07-CodeReview与PR规范](docs/工程规范/07-CodeReview与PR规范.md) | 提交说明、双人场景、DoD、推送后观察 |
|
| Code Review | [07-CodeReview与PR规范](docs/工程规范/07-CodeReview与PR规范.md) | 提交说明、双人场景、DoD、本地 CI 后推送 |
|
||||||
| 日志与可观测 | [08-日志与可观测性规范](docs/工程规范/08-日志与可观测性规范.md) | Pino 字段、trace_id、审计与日志分离、告警 |
|
| 日志与可观测 | [08-日志与可观测性规范](docs/工程规范/08-日志与可观测性规范.md) | Pino 字段、trace_id、审计与日志分离、告警 |
|
||||||
| 环境变量与密钥 | [09-环境变量与密钥规范](docs/工程规范/09-环境变量与密钥规范.md) | `.env.example` 入库、启动校验、轮换与泄露处置 |
|
| 环境变量与密钥 | [09-环境变量与密钥规范](docs/工程规范/09-环境变量与密钥规范.md) | `.env.example` 入库、启动校验、轮换与泄露处置 |
|
||||||
| 资产命名与图层 | [10-资产命名与PSD图层规范](docs/工程规范/10-资产命名与PSD图层规范.md) | 命名占位符、PSD 图层前缀、规格矩阵、低质量标记 |
|
| 资产命名与图层 | [10-资产命名与PSD图层规范](docs/工程规范/10-资产命名与PSD图层规范.md) | 命名占位符、PSD 图层前缀、规格矩阵、低质量标记 |
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
| **A0 报告** | [灵犀_A0工程骨架与契约基线_实施报告_V0.1.md](docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md) | V0.1 | A0 完成范围、门禁实测、环境适配与待确认事项 |
|
| **A0 报告** | [灵犀_A0工程骨架与契约基线_实施报告_V0.1.md](docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md) | V0.1 | A0 完成范围、门禁实测、环境适配与待确认事项 |
|
||||||
| **A1 报告** | [灵犀_A1身份认证与用户管理_实施报告_V0.1.md](docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md) | V0.1 | A1 完成范围、契约/实现/测试、遗留与下一步 |
|
| **A1 报告** | [灵犀_A1身份认证与用户管理_实施报告_V0.1.md](docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md) | V0.1 | A1 完成范围、契约/实现/测试、遗留与下一步 |
|
||||||
| **A2 报告** | [灵犀_A2角色权限与数据范围_实施报告_V0.1.md](docs/开发计划/灵犀_A2角色权限与数据范围_实施报告_V0.1.md) | V0.1 | A2 完成范围、H1 决策、前后端/测试、遗留与下一步 |
|
| **A2 报告** | [灵犀_A2角色权限与数据范围_实施报告_V0.1.md](docs/开发计划/灵犀_A2角色权限与数据范围_实施报告_V0.1.md) | V0.1 | A2 完成范围、H1 决策、前后端/测试、遗留与下一步 |
|
||||||
| **ADR** | [docs/adr/](docs/adr/README.md) | — | 架构决策记录(ADR-001 权限点目录与六类数据范围) |
|
| **A3 报告** | [灵犀_A3客户与行业目录_实施报告_V0.1.md](docs/开发计划/灵犀_A3客户与行业目录_实施报告_V0.1.md) | V0.1 | A3 完成范围、两级行业决策、混搭 UI、测试与遗留 |
|
||||||
|
| **A4 报告** | [灵犀_A4数据字典与系统配置_实施报告_V0.1.md](docs/开发计划/灵犀_A4数据字典与系统配置_实施报告_V0.1.md) | V0.1 | A4 完成范围、三层合并/配置版本、方案 A UI、测试与遗留 |
|
||||||
|
| **ADR** | [docs/adr/](docs/adr/README.md) | — | 架构决策记录(ADR-001 权限;ADR-002 两级行业目录) |
|
||||||
| 历史 PRD | `docs/PRD/灵犀_信息流素材生产平台_PRD_V0.1~V0.3.md` | 归档 | 仅作沿革参考,**不具约束力** |
|
| 历史 PRD | `docs/PRD/灵犀_信息流素材生产平台_PRD_V0.1~V0.3.md` | 归档 | 仅作沿革参考,**不具约束力** |
|
||||||
|
|
||||||
> 阅读顺序:`AGENTS.md` → PRD §1/§6 → 技术架构 §0.2/§3 → 按任务查 `docs/工程规范/`。
|
> 阅读顺序:`AGENTS.md` → PRD §1/§6 → 技术架构 §0.2/§3 → 按任务查 `docs/工程规范/`。
|
||||||
@@ -118,7 +120,7 @@ pnpm openapi:export && pnpm openapi:diff # 契约快照与破坏性变更检
|
|||||||
pnpm ci:local # 本地跑完整 CI 门禁(已内置 e2e 所需 env)
|
pnpm ci:local # 本地跑完整 CI 门禁(已内置 e2e 所需 env)
|
||||||
```
|
```
|
||||||
|
|
||||||
> Gitea 暂未部署 Actions runner,推送后远程 CI 会停留在 queued;当前以 `pnpm ci:local` 为准,runner 就绪后 `.gitea/workflows/ci.yml` 会自动接管。
|
> **远端 Gitea 未部署 Actions runner,线上 CI 不可用**。所有 CI 一律在本地执行:推送前跑 `pnpm ci:local`(或至少 `format:check + lint + typecheck + test`)。`.gitea/workflows/ci.yml` 仅作本地流水线参考 / 手动触发,**不随 push 自动运行**。
|
||||||
|
|
||||||
命令与门禁见 [AGENTS.md §6](AGENTS.md) 与 [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md)。当前 UI 采用「顶部模块菜单 + 左侧功能菜单」布局,视觉方向为 **A · 云白蓝(传统企业后台 + 现代浅色质感)**。
|
命令与门禁见 [AGENTS.md §6](AGENTS.md) 与 [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md)。当前 UI 采用「顶部模块菜单 + 左侧功能菜单」布局,视觉方向为 **A · 云白蓝(传统企业后台 + 现代浅色质感)**。
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { APP_CONFIG, AppConfigModule } from './config/config.module';
|
|||||||
import type { AppConfig } from './config/env';
|
import type { AppConfig } from './config/env';
|
||||||
import { PrismaModule } from './infra/prisma/prisma.module';
|
import { PrismaModule } from './infra/prisma/prisma.module';
|
||||||
import { RedisModule } from './infra/redis/redis.module';
|
import { RedisModule } from './infra/redis/redis.module';
|
||||||
|
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||||
|
import { ClientModule } from './modules/client/client.module';
|
||||||
import { IdentityModule } from './modules/identity/identity.module';
|
import { IdentityModule } from './modules/identity/identity.module';
|
||||||
import { PlatformModule } from './modules/platform/platform.module';
|
import { PlatformModule } from './modules/platform/platform.module';
|
||||||
|
|
||||||
@@ -49,6 +51,8 @@ import { PlatformModule } from './modules/platform/platform.module';
|
|||||||
RedisModule,
|
RedisModule,
|
||||||
SecurityModule,
|
SecurityModule,
|
||||||
IdentityModule,
|
IdentityModule,
|
||||||
|
ClientModule,
|
||||||
|
CatalogModule,
|
||||||
PlatformModule,
|
PlatformModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { PermissionCatalogService } from './catalog/permission-catalog.service';
|
|||||||
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
|
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
|
||||||
import { PermissionService } from './permission.service';
|
import { PermissionService } from './permission.service';
|
||||||
import { RequestContextService } from './request-context';
|
import { RequestContextService } from './request-context';
|
||||||
import { CUSTOMER_DIRECTORY, EmptyCustomerDirectory } from './scope/customer-directory';
|
|
||||||
import { ScopeResolver } from './scope/scope-resolver.service';
|
import { ScopeResolver } from './scope/scope-resolver.service';
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
|
|
||||||
@@ -15,8 +14,8 @@ import { TokenService } from './token.service';
|
|||||||
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、数据范围解析、JWT 与守卫链。
|
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、数据范围解析、JWT 与守卫链。
|
||||||
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
|
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
|
||||||
*
|
*
|
||||||
* `CUSTOMER_DIRECTORY` 是客户目录端口:A2 注册空集实现,A3 落地 client 模块后替换 provider
|
* `CUSTOMER_DIRECTORY` 由全局 ClientModule 提供真实实现(A3);
|
||||||
* 即可让六类数据范围解析出真实客户集合,ScopeResolver / 守卫链无需改动。
|
* ScopeResolver / 守卫链只依赖端口,不感知客户模块。
|
||||||
*/
|
*/
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -32,7 +31,6 @@ import { TokenService } from './token.service';
|
|||||||
JwtAuthGuard,
|
JwtAuthGuard,
|
||||||
SpecialPermissionGuard,
|
SpecialPermissionGuard,
|
||||||
ScopeGuard,
|
ScopeGuard,
|
||||||
{ provide: CUSTOMER_DIRECTORY, useClass: EmptyCustomerDirectory },
|
|
||||||
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
|
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
|
||||||
{ provide: APP_GUARD, useExisting: PermissionGuard },
|
{ provide: APP_GUARD, useExisting: PermissionGuard },
|
||||||
{ provide: APP_GUARD, useExisting: SpecialPermissionGuard },
|
{ provide: APP_GUARD, useExisting: SpecialPermissionGuard },
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import { API_PREFIX, ErrorCode, Permission, ulid } from '@lingxi/shared';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createApp, type CreatedApiApp } from '../../bootstrap';
|
||||||
|
import { loadDotenv } from '../../config/load-dotenv';
|
||||||
|
|
||||||
|
const PASSWORD_HASH =
|
||||||
|
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||||
|
const PASSWORD = 'lingxi2026';
|
||||||
|
const PREFIX = 'itqa4.';
|
||||||
|
const TYPE_PREFIX = 'itqa4_';
|
||||||
|
const INDUSTRY_PREFIX = 'ITQA4-';
|
||||||
|
const API = API_PREFIX;
|
||||||
|
|
||||||
|
loadDotenv();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface TokenBody {
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonResult<T> {
|
||||||
|
status: number;
|
||||||
|
body: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DictTypeSummaryBody {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
group: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
builtin: boolean;
|
||||||
|
industryScoped: boolean;
|
||||||
|
itemCount: number;
|
||||||
|
activeItemCount: number;
|
||||||
|
industryCount: number;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DictItemBody {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
overridden: boolean;
|
||||||
|
status: string;
|
||||||
|
parentName: string | null;
|
||||||
|
industryPath: string | null;
|
||||||
|
refCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DictItemListBody {
|
||||||
|
items: DictItemBody[];
|
||||||
|
layers: { source: string; industryId: string | null; label: string; count: number }[];
|
||||||
|
effectiveCount: number;
|
||||||
|
overriddenCount: number;
|
||||||
|
disabledCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let phoneCounter = 0;
|
||||||
|
let app: CreatedApiApp['app'];
|
||||||
|
let adminToken = '';
|
||||||
|
let noPermissionToken = '';
|
||||||
|
let rootIndustryId = '';
|
||||||
|
let leafIndustryId = '';
|
||||||
|
const industryTypeCode = `${TYPE_PREFIX}industry_scoped`;
|
||||||
|
const emptyTypeCode = `${TYPE_PREFIX}empty_tmp`;
|
||||||
|
const createdRoleIds: string[] = [];
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
|
function nextPhone(): string {
|
||||||
|
phoneCounter += 1;
|
||||||
|
return `135${String(phoneCounter).padStart(8, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRole(suffix: string, permissions: string[]): Promise<string> {
|
||||||
|
const id = ulid();
|
||||||
|
await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
code: `${PREFIX}role_${suffix}`,
|
||||||
|
name: `${PREFIX}角色-${suffix}`,
|
||||||
|
description: 'A4 集成测试角色',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
|
||||||
|
dataScopes: { create: [{ id: ulid(), scopeType: 'all', targetId: null }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdRoleIds.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(suffix: string, roleId: string): Promise<string> {
|
||||||
|
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||||||
|
const account = `${PREFIX}${suffix}`;
|
||||||
|
const id = ulid();
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
name: `A4 集成测试${suffix}`,
|
||||||
|
account,
|
||||||
|
email: `${account}@lingxi.cn`,
|
||||||
|
phone: nextPhone(),
|
||||||
|
passwordHash: PASSWORD_HASH,
|
||||||
|
departmentId: department.id,
|
||||||
|
mustChangePassword: false,
|
||||||
|
roles: { create: [{ roleId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdUserIds.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(account: string): Promise<string> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${API}/auth/login`,
|
||||||
|
payload: { account, password: PASSWORD },
|
||||||
|
});
|
||||||
|
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
||||||
|
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
|
||||||
|
}
|
||||||
|
return response.json<TokenBody>().accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<T>(
|
||||||
|
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
payload?: unknown,
|
||||||
|
): Promise<JsonResult<T>> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method,
|
||||||
|
url: `${API}${url}`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||||
|
});
|
||||||
|
const body = response.body.length === 0 ? (null as T) : response.json<T>();
|
||||||
|
return { status: response.statusCode, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createType(
|
||||||
|
code: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<DictTypeSummaryBody> {
|
||||||
|
const created = await call<DictTypeSummaryBody>('POST', '/dicts', adminToken, {
|
||||||
|
code,
|
||||||
|
...body,
|
||||||
|
});
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
return created.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createItem(typeCode: string, body: Record<string, unknown>): Promise<DictItemBody> {
|
||||||
|
const created = await call<DictItemBody>('POST', `/dicts/${typeCode}/items`, adminToken, body);
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
return created.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupFixtures(): Promise<void> {
|
||||||
|
await prisma.dictType.deleteMany({ where: { code: { startsWith: TYPE_PREFIX } } });
|
||||||
|
await prisma.industry.deleteMany({ where: { name: { startsWith: INDUSTRY_PREFIX } } });
|
||||||
|
await prisma.user.deleteMany({ where: { account: { startsWith: PREFIX } } });
|
||||||
|
if (createdRoleIds.length > 0) {
|
||||||
|
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanupFixtures();
|
||||||
|
const adminRole = await createRole('admin', [Permission.DICT_MANAGE]);
|
||||||
|
await createUser('admin', adminRole);
|
||||||
|
const readRole = await createRole('reader', [Permission.CUSTOMER_READ]);
|
||||||
|
await createUser('reader', readRole);
|
||||||
|
|
||||||
|
const root = await prisma.industry.create({
|
||||||
|
data: { id: ulid(), parentId: null, level: 1, name: `${INDUSTRY_PREFIX}电商`, sortOrder: 10 },
|
||||||
|
});
|
||||||
|
rootIndustryId = root.id;
|
||||||
|
const leaf = await prisma.industry.create({
|
||||||
|
data: {
|
||||||
|
id: ulid(),
|
||||||
|
parentId: rootIndustryId,
|
||||||
|
level: 2,
|
||||||
|
name: `${INDUSTRY_PREFIX}服饰鞋包`,
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
leafIndustryId = leaf.id;
|
||||||
|
|
||||||
|
const created = await createApp();
|
||||||
|
app = created.app;
|
||||||
|
await app.init();
|
||||||
|
adminToken = await login(`${PREFIX}admin`);
|
||||||
|
noPermissionToken = await login(`${PREFIX}reader`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupFixtures();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
|
||||||
|
if (app) await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4 字典 · 类型列表 / 详情 / 写操作', () => {
|
||||||
|
it('类型列表:分组顺序 + 统计;keyword / group 过滤;无权限 403', async () => {
|
||||||
|
const list = await call<{ items: DictTypeSummaryBody[]; next_cursor: string | null }>(
|
||||||
|
'GET',
|
||||||
|
'/dicts?limit=100',
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(list.status).toBe(200);
|
||||||
|
expect(list.body.items.length).toBeGreaterThanOrEqual(13);
|
||||||
|
const groupOrder = ['content', 'platform', 'quality', 'audience'];
|
||||||
|
const ranks = list.body.items.map((item) => groupOrder.indexOf(item.group));
|
||||||
|
expect(ranks).toEqual([...ranks].sort((left, right) => left - right));
|
||||||
|
const platformSpec = list.body.items.find((item) => item.code === 'platform_spec');
|
||||||
|
expect(platformSpec).toMatchObject({ builtin: true, industryScoped: true });
|
||||||
|
expect(platformSpec?.itemCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const filtered = await call<{ items: DictTypeSummaryBody[] }>(
|
||||||
|
'GET',
|
||||||
|
'/dicts?group=quality&status=active&limit=100',
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(filtered.status).toBe(200);
|
||||||
|
expect(filtered.body.items.every((item) => item.group === 'quality')).toBe(true);
|
||||||
|
|
||||||
|
const forbidden = await call<{ code: number }>('GET', '/dicts', noPermissionToken);
|
||||||
|
expect(forbidden.status).toBe(403);
|
||||||
|
expect(forbidden.body.code).toBe(ErrorCode.PERMISSION_DENIED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('新建类型 / 详情 / 编码重复 40902 / 不存在 40401', async () => {
|
||||||
|
const created = await createType(industryTypeCode, {
|
||||||
|
name: 'ITQA4 行业脚本方向',
|
||||||
|
group: 'content',
|
||||||
|
kind: 'tree',
|
||||||
|
description: 'A4 集成测试三层合并',
|
||||||
|
industryScoped: true,
|
||||||
|
sortOrder: 95,
|
||||||
|
});
|
||||||
|
expect(created).toMatchObject({
|
||||||
|
code: industryTypeCode,
|
||||||
|
builtin: false,
|
||||||
|
industryScoped: true,
|
||||||
|
kind: 'tree',
|
||||||
|
});
|
||||||
|
|
||||||
|
const detail = await call<DictTypeSummaryBody>('GET', `/dicts/${industryTypeCode}`, adminToken);
|
||||||
|
expect(detail.status).toBe(200);
|
||||||
|
expect(detail.body.code).toBe(industryTypeCode);
|
||||||
|
|
||||||
|
const duplicate = await call<{ code: number }>('POST', '/dicts', adminToken, {
|
||||||
|
code: industryTypeCode,
|
||||||
|
name: '重复编码',
|
||||||
|
group: 'content',
|
||||||
|
});
|
||||||
|
expect(duplicate.status).toBe(409);
|
||||||
|
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
|
||||||
|
|
||||||
|
const missing = await call<{ code: number }>('GET', '/dicts/itqa4_missing_type', adminToken);
|
||||||
|
expect(missing.status).toBe(404);
|
||||||
|
expect(missing.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('更新类型(编码 / 形态 / 行业作用域不可改)与启停', async () => {
|
||||||
|
const updated = await call<DictTypeSummaryBody>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}`,
|
||||||
|
adminToken,
|
||||||
|
{ name: 'ITQA4 行业脚本方向(改)', sortOrder: 96 },
|
||||||
|
);
|
||||||
|
expect(updated.status).toBe(200);
|
||||||
|
expect(updated.body).toMatchObject({ name: 'ITQA4 行业脚本方向(改)', sortOrder: 96 });
|
||||||
|
|
||||||
|
const immutable = await call<{ code: number }>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}`,
|
||||||
|
adminToken,
|
||||||
|
{ code: 'itqa4_changed_code' },
|
||||||
|
);
|
||||||
|
expect(immutable.status).toBe(400);
|
||||||
|
expect(immutable.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const disabled = await call<DictTypeSummaryBody>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}/status`,
|
||||||
|
adminToken,
|
||||||
|
{ status: 'disabled' },
|
||||||
|
);
|
||||||
|
expect(disabled.status).toBe(200);
|
||||||
|
expect(disabled.body.status).toBe('disabled');
|
||||||
|
const enabled = await call<DictTypeSummaryBody>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}/status`,
|
||||||
|
adminToken,
|
||||||
|
{ status: 'active' },
|
||||||
|
);
|
||||||
|
expect(enabled.body.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('删除:内置类型 40001、有子条目 40001、空自定义类型 204 后 404', async () => {
|
||||||
|
const builtin = await call<{ code: number }>('DELETE', '/dicts/platform_spec', adminToken);
|
||||||
|
expect(builtin.status).toBe(400);
|
||||||
|
expect(builtin.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const blockedCode = `${TYPE_PREFIX}delete_blocked`;
|
||||||
|
await createType(blockedCode, { name: 'ITQA4 待删类型', group: 'content' });
|
||||||
|
await createItem(blockedCode, { code: 'keep_me', name: '保留条目' });
|
||||||
|
const withItems = await call<{ code: number }>('DELETE', `/dicts/${blockedCode}`, adminToken);
|
||||||
|
expect(withItems.status).toBe(400);
|
||||||
|
expect(withItems.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
await createType(emptyTypeCode, { name: 'ITQA4 空类型', group: 'content' });
|
||||||
|
const removed = await call<null>('DELETE', `/dicts/${emptyTypeCode}`, adminToken);
|
||||||
|
expect(removed.status).toBe(204);
|
||||||
|
const gone = await call<{ code: number }>('GET', `/dicts/${emptyTypeCode}`, adminToken);
|
||||||
|
expect(gone.status).toBe(404);
|
||||||
|
expect(gone.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4 字典 · 条目三层合并(R-10 / A4-QA-01)', () => {
|
||||||
|
it('准备通用 5 / 父行业 2 / 本行业 1 数据', async () => {
|
||||||
|
const generic = [
|
||||||
|
{ code: 'oral', name: '口播', sortOrder: 10 },
|
||||||
|
{ code: 'drama', name: '剧情', sortOrder: 20 },
|
||||||
|
{ code: 'review', name: '测评', sortOrder: 30 },
|
||||||
|
{ code: 'seeding', name: '种草', sortOrder: 40 },
|
||||||
|
{ code: 'scene', name: '情景', sortOrder: 50 },
|
||||||
|
];
|
||||||
|
for (const item of generic) {
|
||||||
|
await createItem(industryTypeCode, { ...item, parentCode: null, industryId: null });
|
||||||
|
}
|
||||||
|
await createItem(industryTypeCode, {
|
||||||
|
code: 'live_clip',
|
||||||
|
name: '直播切片',
|
||||||
|
industryId: rootIndustryId,
|
||||||
|
sortOrder: 60,
|
||||||
|
});
|
||||||
|
await createItem(industryTypeCode, {
|
||||||
|
code: 'seeding',
|
||||||
|
name: '种草短视频',
|
||||||
|
industryId: rootIndustryId,
|
||||||
|
sortOrder: 40,
|
||||||
|
});
|
||||||
|
await createItem(industryTypeCode, {
|
||||||
|
code: 'outfit',
|
||||||
|
name: '穿搭教程',
|
||||||
|
industryId: leafIndustryId,
|
||||||
|
sortOrder: 70,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('二级行业三层合并:7 条生效、source / overridden / layers 正确', async () => {
|
||||||
|
const result = await call<DictItemListBody>(
|
||||||
|
'GET',
|
||||||
|
`/dicts/${industryTypeCode}/items?industry_id=${leafIndustryId}`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(result.body.items).toHaveLength(7);
|
||||||
|
expect(result.body.effectiveCount).toBe(7);
|
||||||
|
expect(result.body.overriddenCount).toBe(1);
|
||||||
|
expect(result.body.disabledCount).toBe(0);
|
||||||
|
expect(result.body.layers).toEqual([
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 5 },
|
||||||
|
{ source: 'parent', industryId: rootIndustryId, label: `${INDUSTRY_PREFIX}电商`, count: 2 },
|
||||||
|
{
|
||||||
|
source: 'current',
|
||||||
|
industryId: leafIndustryId,
|
||||||
|
label: `${INDUSTRY_PREFIX}服饰鞋包`,
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const seeding = result.body.items.find((item) => item.code === 'seeding');
|
||||||
|
expect(seeding).toMatchObject({
|
||||||
|
name: '种草短视频',
|
||||||
|
source: 'parent',
|
||||||
|
overridden: true,
|
||||||
|
industryPath: `${INDUSTRY_PREFIX}电商`,
|
||||||
|
refCount: 0,
|
||||||
|
});
|
||||||
|
expect(result.body.items.find((item) => item.code === 'live_clip')).toMatchObject({
|
||||||
|
source: 'parent',
|
||||||
|
overridden: false,
|
||||||
|
});
|
||||||
|
expect(result.body.items.find((item) => item.code === 'outfit')).toMatchObject({
|
||||||
|
source: 'current',
|
||||||
|
industryPath: `${INDUSTRY_PREFIX}电商 / ${INDUSTRY_PREFIX}服饰鞋包`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不传行业只返回通用层;一级行业 = 通用 + 本行业;行业不存在 40401', async () => {
|
||||||
|
const genericOnly = await call<DictItemListBody>(
|
||||||
|
'GET',
|
||||||
|
`/dicts/${industryTypeCode}/items`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(genericOnly.body.items).toHaveLength(5);
|
||||||
|
expect(genericOnly.body.items.every((item) => item.source === 'generic')).toBe(true);
|
||||||
|
|
||||||
|
const rootView = await call<DictItemListBody>(
|
||||||
|
'GET',
|
||||||
|
`/dicts/${industryTypeCode}/items?industry_id=${rootIndustryId}`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(rootView.body.layers.map((layer) => layer.source)).toEqual(['generic', 'current']);
|
||||||
|
expect(rootView.body.items).toHaveLength(6);
|
||||||
|
|
||||||
|
const missing = await call<{ code: number }>(
|
||||||
|
'GET',
|
||||||
|
`/dicts/${industryTypeCode}/items?industry_id=01HZZZZZZZZZZZZZZZZZZZZZZZ`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(missing.status).toBe(404);
|
||||||
|
expect(missing.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非行业作用域类型强制忽略 industry_id,只返回通用层', async () => {
|
||||||
|
const code = `${TYPE_PREFIX}list_tmp`;
|
||||||
|
await createType(code, { name: 'ITQA4 时长档', group: 'content', kind: 'list' });
|
||||||
|
await createItem(code, { code: '15s', name: '15 秒', sortOrder: 10 });
|
||||||
|
const result = await call<DictItemListBody>(
|
||||||
|
'GET',
|
||||||
|
`/dicts/${code}/items?industry_id=${leafIndustryId}`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(result.body.items).toHaveLength(1);
|
||||||
|
expect(result.body.layers).toEqual([
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.body.items[0]).toMatchObject({ code: '15s', source: 'generic' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('条目写校验:非行业类型传行业 / 列表型传父级 / 父级不存在 / 重复编码', async () => {
|
||||||
|
const code = `${TYPE_PREFIX}list_tmp`;
|
||||||
|
const industryOnList = await call<{ code: number }>(
|
||||||
|
'POST',
|
||||||
|
`/dicts/${code}/items`,
|
||||||
|
adminToken,
|
||||||
|
{ code: '30s', name: '30 秒', industryId: leafIndustryId },
|
||||||
|
);
|
||||||
|
expect(industryOnList.status).toBe(400);
|
||||||
|
expect(industryOnList.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const parentOnList = await call<{ code: number }>('POST', `/dicts/${code}/items`, adminToken, {
|
||||||
|
code: '30s',
|
||||||
|
name: '30 秒',
|
||||||
|
parentCode: '15s',
|
||||||
|
});
|
||||||
|
expect(parentOnList.status).toBe(400);
|
||||||
|
expect(parentOnList.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const missingParent = await call<{ code: number }>(
|
||||||
|
'POST',
|
||||||
|
`/dicts/${industryTypeCode}/items`,
|
||||||
|
adminToken,
|
||||||
|
{ code: 'new_child', name: '新子项', parentCode: 'missing_parent' },
|
||||||
|
);
|
||||||
|
expect(missingParent.status).toBe(400);
|
||||||
|
expect(missingParent.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const duplicate = await call<{ code: number }>(
|
||||||
|
'POST',
|
||||||
|
`/dicts/${industryTypeCode}/items`,
|
||||||
|
adminToken,
|
||||||
|
{ code: 'oral', name: '口播重复' },
|
||||||
|
);
|
||||||
|
expect(duplicate.status).toBe(409);
|
||||||
|
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('条目编辑:改名 / 停用;编码不可改(unknown 字段 40001)', async () => {
|
||||||
|
const created = await createItem(industryTypeCode, {
|
||||||
|
code: 'voiceover',
|
||||||
|
name: '口播旁白',
|
||||||
|
sortOrder: 80,
|
||||||
|
});
|
||||||
|
const updated = await call<DictItemBody>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}/items/${created.id}`,
|
||||||
|
adminToken,
|
||||||
|
{ name: '口播旁白(改)', status: 'disabled' },
|
||||||
|
);
|
||||||
|
expect(updated.status).toBe(200);
|
||||||
|
expect(updated.body).toMatchObject({
|
||||||
|
code: 'voiceover',
|
||||||
|
name: '口播旁白(改)',
|
||||||
|
status: 'disabled',
|
||||||
|
});
|
||||||
|
|
||||||
|
const immutable = await call<{ code: number }>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}/items/${created.id}`,
|
||||||
|
adminToken,
|
||||||
|
{ code: 'changed_code' },
|
||||||
|
);
|
||||||
|
expect(immutable.status).toBe(400);
|
||||||
|
expect(immutable.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const missing = await call<{ code: number }>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}/items/01HZZZZZZZZZZZZZZZZZZZZZZZ`,
|
||||||
|
adminToken,
|
||||||
|
{ name: '不存在' },
|
||||||
|
);
|
||||||
|
expect(missing.status).toBe(404);
|
||||||
|
expect(missing.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { DictsController } from './dicts.controller';
|
||||||
|
import { DictsService } from './dicts.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* catalog 模块(A4-BE-01 / A4-BE-04):数据字典类型与条目、行业作用域三层合并、快照工具。
|
||||||
|
* 只依赖 @Global 的 PrismaModule / SecurityModule(RequestContextService),不反向依赖其他业务模块。
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [DictsController],
|
||||||
|
providers: [DictsService],
|
||||||
|
exports: [DictsService],
|
||||||
|
})
|
||||||
|
export class CatalogModule {}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { DictItemSource } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { type DictMergeLayer, type DictMergeRow, mergeDictLayers } from './dict-merge';
|
||||||
|
|
||||||
|
function row(overrides: Partial<DictMergeRow> & Pick<DictMergeRow, 'code' | 'name'>): DictMergeRow {
|
||||||
|
return {
|
||||||
|
id: `id-${overrides.code}`,
|
||||||
|
parentCode: null,
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
updatedAt: '2026-11-04T00:00:00.000Z',
|
||||||
|
updatedBy: '江岚',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function layer(
|
||||||
|
source: DictItemSource,
|
||||||
|
industryId: string | null,
|
||||||
|
items: DictMergeRow[],
|
||||||
|
): DictMergeLayer {
|
||||||
|
const label = source === 'generic' ? '通用' : source === 'parent' ? '父行业' : '本行业';
|
||||||
|
return { source, industryId, label, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A4-QA-01 口径:通用 5 + 父行业 2 + 本行业 1,其中 seeding 被父行业覆盖 → 生效 7 条 */
|
||||||
|
function scriptDirectionLayers(): DictMergeLayer[] {
|
||||||
|
return [
|
||||||
|
layer('generic', null, [
|
||||||
|
row({ code: 'oral', name: '口播', sortOrder: 10 }),
|
||||||
|
row({ code: 'drama', name: '剧情', sortOrder: 20 }),
|
||||||
|
row({ code: 'review', name: '测评', sortOrder: 30 }),
|
||||||
|
row({ code: 'seeding', name: '种草', sortOrder: 40 }),
|
||||||
|
row({ code: 'scene', name: '情景', sortOrder: 50 }),
|
||||||
|
]),
|
||||||
|
layer('parent', 'ec', [
|
||||||
|
row({ code: 'live_clip', name: '直播切片', industryId: 'ec', sortOrder: 60 }),
|
||||||
|
row({ code: 'seeding', name: '种草短视频', industryId: 'ec', sortOrder: 40 }),
|
||||||
|
]),
|
||||||
|
layer('current', 'ec_apparel', [
|
||||||
|
row({ code: 'outfit', name: '穿搭教程', industryId: 'ec_apparel', sortOrder: 70 }),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mergeDictLayers · 三层合并', () => {
|
||||||
|
it('通用 5 + 父行业 2 + 本行业 1:同 code 覆盖后生效 7 条', () => {
|
||||||
|
const result = mergeDictLayers(scriptDirectionLayers());
|
||||||
|
expect(result.items).toHaveLength(7);
|
||||||
|
expect(result.effectiveCount).toBe(7);
|
||||||
|
expect(result.disabledCount).toBe(0);
|
||||||
|
expect(result.overriddenCount).toBe(1);
|
||||||
|
expect(result.layers).toEqual([
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 5 },
|
||||||
|
{ source: 'parent', industryId: 'ec', label: '父行业', count: 2 },
|
||||||
|
{ source: 'current', industryId: 'ec_apparel', label: '本行业', count: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('来源与覆盖标记正确:种草短视频 parent / 直播切片 parent / 穿搭教程 current', () => {
|
||||||
|
const result = mergeDictLayers(scriptDirectionLayers());
|
||||||
|
const seeding = result.items.find((item) => item.code === 'seeding');
|
||||||
|
const liveClip = result.items.find((item) => item.code === 'live_clip');
|
||||||
|
const outfit = result.items.find((item) => item.code === 'outfit');
|
||||||
|
expect(seeding).toMatchObject({
|
||||||
|
name: '种草短视频',
|
||||||
|
source: 'parent',
|
||||||
|
overridden: true,
|
||||||
|
});
|
||||||
|
expect(liveClip).toMatchObject({ source: 'parent', overridden: false });
|
||||||
|
expect(outfit).toMatchObject({ source: 'current', overridden: false });
|
||||||
|
// 通用层的「种草」被父行业覆盖,不再出现在生效结果中
|
||||||
|
expect(result.items.filter((item) => item.code === 'seeding')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('条目按 sortOrder asc, name asc 排序', () => {
|
||||||
|
const result = mergeDictLayers(scriptDirectionLayers());
|
||||||
|
expect(result.items.map((item) => item.code)).toEqual([
|
||||||
|
'oral',
|
||||||
|
'drama',
|
||||||
|
'review',
|
||||||
|
'seeding',
|
||||||
|
'scene',
|
||||||
|
'live_clip',
|
||||||
|
'outfit',
|
||||||
|
]);
|
||||||
|
expect(result.items.map((item) => item.sortOrder)).toEqual([10, 20, 30, 40, 50, 60, 70]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非行业作用域只传入通用层:全部 source=generic、无覆盖', () => {
|
||||||
|
const genericLayer = layer('generic', null, [
|
||||||
|
row({ code: '15s', name: '15 秒' }),
|
||||||
|
row({ code: '30s', name: '30 秒' }),
|
||||||
|
]);
|
||||||
|
const result = mergeDictLayers([genericLayer]);
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
expect(result.items.every((item) => item.source === 'generic')).toBe(true);
|
||||||
|
expect(result.items.every((item) => !item.overridden)).toBe(true);
|
||||||
|
expect(result.overriddenCount).toBe(0);
|
||||||
|
expect(result.layers).toEqual([
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('effectiveCount 只计 active,disabledCount 计 disabled,被覆盖的禁用条目不重复计数', () => {
|
||||||
|
const result = mergeDictLayers([
|
||||||
|
layer('generic', null, [
|
||||||
|
row({ code: 'L1', name: '自然光', sortOrder: 10 }),
|
||||||
|
row({ code: 'L5', name: '高反差硬光', sortOrder: 50, status: 'disabled' }),
|
||||||
|
]),
|
||||||
|
layer('current', 'fin', [
|
||||||
|
row({ code: 'L1', name: '自然光(金融)', industryId: 'fin', sortOrder: 10 }),
|
||||||
|
row({ code: 'fin_L6', name: '商务蓝调', industryId: 'fin', status: 'disabled' }),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
expect(result.effectiveCount).toBe(1);
|
||||||
|
expect(result.disabledCount).toBe(2);
|
||||||
|
expect(result.overriddenCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('三层同 code 时以本行业为准,只计一条生效;层级顺序无关', () => {
|
||||||
|
const layers: DictMergeLayer[] = [
|
||||||
|
layer('current', 'leaf', [row({ code: 'oral', name: '本行业口播', industryId: 'leaf' })]),
|
||||||
|
layer('generic', null, [row({ code: 'oral', name: '口播' })]),
|
||||||
|
layer('parent', 'root', [row({ code: 'oral', name: '父行业口播', industryId: 'root' })]),
|
||||||
|
];
|
||||||
|
const result = mergeDictLayers(layers);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
name: '本行业口播',
|
||||||
|
source: 'current',
|
||||||
|
overridden: true,
|
||||||
|
});
|
||||||
|
expect(result.overriddenCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 数据字典三层合并(A4-BE-01 / PRD R-10)。
|
||||||
|
*
|
||||||
|
* 存储只有一张通用枚举表 `dict_items`:`industry_id IS NULL` 为通用层,
|
||||||
|
* 否则为行业层;二级行业的视角为「通用 → 父行业 → 本行业」。
|
||||||
|
* 同 code 以更具体层覆盖,最终只返回生效条目(每条带 source / overridden)。
|
||||||
|
*
|
||||||
|
* 本文件是纯函数,不依赖 Prisma / Nest,便于单测覆盖合并口径。
|
||||||
|
*/
|
||||||
|
import type { DictItemSource, DictLayer, DictStatus } from '@lingxi/shared';
|
||||||
|
|
||||||
|
/** 参与合并的条目行(已完成 Prisma -> 领域字段映射) */
|
||||||
|
export interface DictMergeRow {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
parentCode: string | null;
|
||||||
|
industryId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
status: DictStatus;
|
||||||
|
remark: string;
|
||||||
|
updatedAt: string;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一层来源:通用 / 父行业 / 本行业 */
|
||||||
|
export interface DictMergeLayer {
|
||||||
|
source: DictItemSource;
|
||||||
|
industryId: string | null;
|
||||||
|
label: string;
|
||||||
|
items: readonly DictMergeRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并后的生效条目:source 表示来自哪一层,overridden 表示覆盖了更笼统层的同 code 条目 */
|
||||||
|
export interface DictEffectiveItem extends DictMergeRow {
|
||||||
|
source: DictItemSource;
|
||||||
|
overridden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictMergeResult {
|
||||||
|
items: DictEffectiveItem[];
|
||||||
|
layers: DictLayer[];
|
||||||
|
/** 只统计 active 条目 */
|
||||||
|
effectiveCount: number;
|
||||||
|
/** 统计被更具体层覆盖的生效条目数(同 code 覆盖) */
|
||||||
|
overriddenCount: number;
|
||||||
|
/** 统计合并结果中 disabled 的条目数 */
|
||||||
|
disabledCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYER_PRIORITY: Readonly<Record<DictItemSource, number>> = {
|
||||||
|
generic: 0,
|
||||||
|
parent: 1,
|
||||||
|
current: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
function layerRank(source: DictItemSource): number {
|
||||||
|
return LAYER_PRIORITY[source];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排序:sortOrder asc, name asc, code asc(最后一个键保证结果稳定) */
|
||||||
|
function compareEffective(left: DictEffectiveItem, right: DictEffectiveItem): number {
|
||||||
|
if (left.sortOrder !== right.sortOrder) return left.sortOrder - right.sortOrder;
|
||||||
|
const byName = left.name.localeCompare(right.name, 'zh-CN');
|
||||||
|
if (byName !== 0) return byName;
|
||||||
|
return left.code.localeCompare(right.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三层合并:低优先级层先落表,高优先级层同 code 覆盖并标记 overridden。
|
||||||
|
* 入参 layers 的顺序不影响结果(内部按 generic < parent < current 排序)。
|
||||||
|
*/
|
||||||
|
export function mergeDictLayers(layers: readonly DictMergeLayer[]): DictMergeResult {
|
||||||
|
const orderedLayers = [...layers].sort(
|
||||||
|
(left, right) => layerRank(left.source) - layerRank(right.source),
|
||||||
|
);
|
||||||
|
const merged = new Map<string, DictEffectiveItem>();
|
||||||
|
|
||||||
|
for (const layer of orderedLayers) {
|
||||||
|
for (const item of layer.items) {
|
||||||
|
const overridden = merged.has(item.code);
|
||||||
|
merged.set(item.code, { ...item, source: layer.source, overridden });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...merged.values()].sort(compareEffective);
|
||||||
|
const resultLayers: DictLayer[] = orderedLayers.map((layer) => ({
|
||||||
|
source: layer.source,
|
||||||
|
industryId: layer.industryId,
|
||||||
|
label: layer.label,
|
||||||
|
count: layer.items.length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
layers: resultLayers,
|
||||||
|
effectiveCount: items.filter((item) => item.status === 'active').length,
|
||||||
|
overriddenCount: items.filter((item) => item.overridden).length,
|
||||||
|
disabledCount: items.filter((item) => item.status === 'disabled').length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { dictItemSnapshotSchema } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
mapDictGroup,
|
||||||
|
mapDictKind,
|
||||||
|
mapDictStatus,
|
||||||
|
toDictItem,
|
||||||
|
toDictSnapshot,
|
||||||
|
toDictTypeSummary,
|
||||||
|
} from './dict.mapper';
|
||||||
|
|
||||||
|
describe('dict.mapper · Prisma 行 -> 共享 DTO', () => {
|
||||||
|
it('类型统计与枚举转换:group / kind / status 映射为契约小写值', () => {
|
||||||
|
const summary = toDictTypeSummary(
|
||||||
|
{
|
||||||
|
code: 'script_direction',
|
||||||
|
name: '脚本方向',
|
||||||
|
groupKey: 'content',
|
||||||
|
kind: 'LIST',
|
||||||
|
description: '脚本与素材的结构化方向',
|
||||||
|
industryScoped: true,
|
||||||
|
builtin: true,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sortOrder: 60,
|
||||||
|
updatedAt: new Date('2026-11-04T02:00:00.000Z'),
|
||||||
|
updatedBy: '江岚',
|
||||||
|
},
|
||||||
|
{ itemCount: 11, activeItemCount: 10, industryCount: 2 },
|
||||||
|
);
|
||||||
|
expect(summary).toEqual({
|
||||||
|
code: 'script_direction',
|
||||||
|
name: '脚本方向',
|
||||||
|
group: 'content',
|
||||||
|
kind: 'list',
|
||||||
|
description: '脚本与素材的结构化方向',
|
||||||
|
industryScoped: true,
|
||||||
|
builtin: true,
|
||||||
|
status: 'active',
|
||||||
|
sortOrder: 60,
|
||||||
|
itemCount: 11,
|
||||||
|
activeItemCount: 10,
|
||||||
|
industryCount: 2,
|
||||||
|
updatedAt: '2026-11-04T02:00:00.000Z',
|
||||||
|
updatedBy: '江岚',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('库内异常分组回退 content;kind / status 只映射两个分支', () => {
|
||||||
|
expect(mapDictGroup('unknown_group')).toBe('content');
|
||||||
|
expect(mapDictGroup('quality')).toBe('quality');
|
||||||
|
expect(mapDictKind('TREE')).toBe('tree');
|
||||||
|
expect(mapDictKind('LIST')).toBe('list');
|
||||||
|
expect(mapDictStatus('ACTIVE')).toBe('active');
|
||||||
|
expect(mapDictStatus('DISABLED')).toBe('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('条目映射:source / overridden / parentName / industryPath / refCount=0', () => {
|
||||||
|
const item = toDictItem(
|
||||||
|
{
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||||
|
code: 'seeding',
|
||||||
|
name: '种草短视频',
|
||||||
|
parentCode: null,
|
||||||
|
industryId: 'ec',
|
||||||
|
sortOrder: 40,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
remark: '父行业覆盖通用层',
|
||||||
|
updatedAt: new Date('2026-11-04T02:00:00.000Z'),
|
||||||
|
updatedBy: '江岚',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeCode: 'script_direction',
|
||||||
|
source: 'parent',
|
||||||
|
overridden: true,
|
||||||
|
parentName: null,
|
||||||
|
industryPath: '电商',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(item).toMatchObject({
|
||||||
|
typeCode: 'script_direction',
|
||||||
|
code: 'seeding',
|
||||||
|
name: '种草短视频',
|
||||||
|
parentName: null,
|
||||||
|
industryId: 'ec',
|
||||||
|
industryPath: '电商',
|
||||||
|
status: 'active',
|
||||||
|
source: 'parent',
|
||||||
|
overridden: true,
|
||||||
|
refCount: 0,
|
||||||
|
updatedAt: '2026-11-04T02:00:00.000Z',
|
||||||
|
updatedBy: '江岚',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('快照 R-10:先取快照再改字典名称,快照保留填写时名称', () => {
|
||||||
|
const item = toDictItem(
|
||||||
|
{
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||||
|
code: 'oral',
|
||||||
|
name: '口播',
|
||||||
|
parentCode: null,
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
remark: '',
|
||||||
|
updatedAt: new Date('2026-11-04T02:00:00.000Z'),
|
||||||
|
updatedBy: '江岚',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeCode: 'script_direction',
|
||||||
|
source: 'generic',
|
||||||
|
overridden: false,
|
||||||
|
parentName: null,
|
||||||
|
industryPath: null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const snapshot = toDictSnapshot(item, '2026-11-04T10:00:00.000Z');
|
||||||
|
// 模拟字典改名 / 停用(历史引用方不感知)
|
||||||
|
const renamed = { ...item, name: '口播(改名后)', status: 'disabled' as const };
|
||||||
|
expect(renamed.name).not.toBe(snapshot.name);
|
||||||
|
expect(snapshot.name).toBe('口播');
|
||||||
|
expect(snapshot).toEqual({
|
||||||
|
typeCode: 'script_direction',
|
||||||
|
code: 'oral',
|
||||||
|
name: '口播',
|
||||||
|
parentCode: null,
|
||||||
|
industryId: null,
|
||||||
|
capturedAt: '2026-11-04T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(dictItemSnapshotSchema.parse(snapshot)).toEqual(snapshot);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* 数据字典 Prisma 行 -> 共享 DTO 映射(A4-BE-01 / A4-BE-04)。
|
||||||
|
*
|
||||||
|
* 只做字段映射与枚举转换;三层合并、行业路径与父级名称由 service 计算结果后传入。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
DICT_GROUP_KEYS,
|
||||||
|
type DictGroupKey,
|
||||||
|
type DictItem,
|
||||||
|
type DictItemSnapshot,
|
||||||
|
type DictItemSource,
|
||||||
|
type DictStatus,
|
||||||
|
type DictTypeKind,
|
||||||
|
type DictTypeSummary,
|
||||||
|
toDictItemSnapshot,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import type { DictKind, DictStatus as PrismaDictStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
export function iso(value: Date): string {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDictStatus(value: PrismaDictStatus): DictStatus {
|
||||||
|
return value === 'ACTIVE' ? 'active' : 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDictKind(value: DictKind): DictTypeKind {
|
||||||
|
return value === 'TREE' ? 'tree' : 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分组键是共享契约的封闭枚举;库内异常值回退 content,避免整页 500 */
|
||||||
|
export function mapDictGroup(value: string): DictGroupKey {
|
||||||
|
return DICT_GROUP_KEYS.find((key): key is DictGroupKey => key === value) ?? 'content';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 类型统计(由 dict_items 聚合,A4 不跨模块取引用数) */
|
||||||
|
export interface DictTypeCounts {
|
||||||
|
itemCount: number;
|
||||||
|
activeItemCount: number;
|
||||||
|
industryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictTypeRow {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
groupKey: string;
|
||||||
|
kind: DictKind;
|
||||||
|
description: string;
|
||||||
|
industryScoped: boolean;
|
||||||
|
builtin: boolean;
|
||||||
|
status: PrismaDictStatus;
|
||||||
|
sortOrder: number;
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDictTypeSummary(row: DictTypeRow, counts: DictTypeCounts): DictTypeSummary {
|
||||||
|
return {
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
group: mapDictGroup(row.groupKey),
|
||||||
|
kind: mapDictKind(row.kind),
|
||||||
|
description: row.description,
|
||||||
|
industryScoped: row.industryScoped,
|
||||||
|
builtin: row.builtin,
|
||||||
|
status: mapDictStatus(row.status),
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
itemCount: counts.itemCount,
|
||||||
|
activeItemCount: counts.activeItemCount,
|
||||||
|
industryCount: counts.industryCount,
|
||||||
|
updatedAt: iso(row.updatedAt),
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictItemRow {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
parentCode: string | null;
|
||||||
|
industryId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
status: PrismaDictStatus;
|
||||||
|
remark: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictItemView {
|
||||||
|
/** 所属类型编码(行本身不带,由 service 传入) */
|
||||||
|
typeCode: string;
|
||||||
|
source: DictItemSource;
|
||||||
|
overridden: boolean;
|
||||||
|
parentName: string | null;
|
||||||
|
/** 行业路径:一级行业名 / 二级行业名;通用层为 null */
|
||||||
|
industryPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字典引用数:A4 暂无引用表,B0 / B1 接入后改为真实统计(接口字段已保留) */
|
||||||
|
const DICT_ITEM_REF_COUNT = 0;
|
||||||
|
|
||||||
|
export function toDictItem(row: DictItemRow, view: DictItemView): DictItem {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
typeCode: view.typeCode,
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
parentCode: row.parentCode,
|
||||||
|
parentName: view.parentName,
|
||||||
|
industryId: row.industryId,
|
||||||
|
industryPath: view.industryPath,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
status: mapDictStatus(row.status),
|
||||||
|
remark: row.remark,
|
||||||
|
source: view.source,
|
||||||
|
overridden: view.overridden,
|
||||||
|
refCount: DICT_ITEM_REF_COUNT,
|
||||||
|
updatedAt: iso(row.updatedAt),
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填写时快照(R-10):引用字典的业务记录保存该快照,之后字典改名 / 停用不影响历史数据。
|
||||||
|
* 直接委托 shared 的 `toDictItemSnapshot`,保证前后端快照结构同源。
|
||||||
|
*/
|
||||||
|
export function toDictSnapshot(item: DictItem, capturedAt: string): DictItemSnapshot {
|
||||||
|
return toDictItemSnapshot(item, capturedAt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import {
|
||||||
|
type CreateDictItemInput,
|
||||||
|
createDictItemInputSchema,
|
||||||
|
type CreateDictTypeInput,
|
||||||
|
createDictTypeInputSchema,
|
||||||
|
type DictItem,
|
||||||
|
type DictItemListQuery,
|
||||||
|
dictItemListQuerySchema,
|
||||||
|
type DictItemListResponse,
|
||||||
|
type DictTypeListQuery,
|
||||||
|
dictTypeListQuerySchema,
|
||||||
|
type DictTypeSummary,
|
||||||
|
ErrorCode,
|
||||||
|
type ListResponse,
|
||||||
|
Permission,
|
||||||
|
type SetDictTypeStatusInput,
|
||||||
|
setDictTypeStatusInputSchema,
|
||||||
|
type UpdateDictItemInput,
|
||||||
|
updateDictItemInputSchema,
|
||||||
|
type UpdateDictTypeInput,
|
||||||
|
updateDictTypeInputSchema,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||||
|
import type { AuthContext } from '../../common/security/auth.types';
|
||||||
|
import { Audit, CurrentUser, RequirePermissions } from '../../common/security/decorators';
|
||||||
|
import { DictsService } from './dicts.service';
|
||||||
|
|
||||||
|
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||||
|
if (auth === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据字典管理(A4-BE-01 / A4-BE-04):全部端点要求 `dict:manage`;
|
||||||
|
* 写接口加 @Audit,Service 内通过 patchAudit 记录前后值。
|
||||||
|
*/
|
||||||
|
@ApiTags('catalog-dicts')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('dicts')
|
||||||
|
@RequirePermissions(Permission.DICT_MANAGE)
|
||||||
|
export class DictsController {
|
||||||
|
constructor(@Inject(DictsService) private readonly dicts: DictsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '字典类型列表(分组顺序 + 排序;含条目 / 行业层统计)' })
|
||||||
|
list(
|
||||||
|
@Query(new ZodValidationPipe(dictTypeListQuerySchema)) query: DictTypeListQuery,
|
||||||
|
): Promise<ListResponse<DictTypeSummary>> {
|
||||||
|
return this.dicts.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':code')
|
||||||
|
@ApiOperation({ summary: '字典类型详情(不存在 40401)' })
|
||||||
|
detail(@Param('code') code: string): Promise<DictTypeSummary> {
|
||||||
|
return this.dicts.detail(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Audit('catalog.dict_type.create')
|
||||||
|
@ApiOperation({ summary: '新建字典类型(编码唯一;builtin=false)' })
|
||||||
|
create(
|
||||||
|
@Body(new ZodValidationPipe(createDictTypeInputSchema)) input: CreateDictTypeInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<DictTypeSummary> {
|
||||||
|
return this.dicts.create(input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':code')
|
||||||
|
@Audit('catalog.dict_type.update')
|
||||||
|
@ApiOperation({ summary: '更新字典类型(编码 / 形态 / 行业作用域不可改)' })
|
||||||
|
update(
|
||||||
|
@Param('code') code: string,
|
||||||
|
@Body(new ZodValidationPipe(updateDictTypeInputSchema)) input: UpdateDictTypeInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<DictTypeSummary> {
|
||||||
|
return this.dicts.update(code, input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':code/status')
|
||||||
|
@Audit('catalog.dict_type.status')
|
||||||
|
@ApiOperation({ summary: '启用 / 停用字典类型(内置类型允许停用)' })
|
||||||
|
setStatus(
|
||||||
|
@Param('code') code: string,
|
||||||
|
@Body(new ZodValidationPipe(setDictTypeStatusInputSchema)) input: SetDictTypeStatusInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<DictTypeSummary> {
|
||||||
|
return this.dicts.setStatus(code, input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':code')
|
||||||
|
@HttpCode(204)
|
||||||
|
@Audit('catalog.dict_type.delete')
|
||||||
|
@ApiOperation({ summary: '删除自定义字典类型(内置 / 有子条目时不可删)' })
|
||||||
|
async remove(@Param('code') code: string): Promise<void> {
|
||||||
|
await this.dicts.remove(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':code/items')
|
||||||
|
@ApiOperation({ summary: '生效条目列表(industry_id 三层合并;非行业类型忽略该参数)' })
|
||||||
|
listItems(
|
||||||
|
@Param('code') code: string,
|
||||||
|
@Query(new ZodValidationPipe(dictItemListQuerySchema)) query: DictItemListQuery,
|
||||||
|
): Promise<DictItemListResponse> {
|
||||||
|
return this.dicts.listItems(code, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':code/items')
|
||||||
|
@Audit('catalog.dict_item.create')
|
||||||
|
@ApiOperation({ summary: '新建字典条目(编码 / 父级 / 行业校验;编码创建后不可改)' })
|
||||||
|
createItem(
|
||||||
|
@Param('code') code: string,
|
||||||
|
@Body(new ZodValidationPipe(createDictItemInputSchema)) input: CreateDictItemInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<DictItem> {
|
||||||
|
return this.dicts.createItem(code, input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':code/items/:itemId')
|
||||||
|
@Audit('catalog.dict_item.update')
|
||||||
|
@ApiOperation({ summary: '更新字典条目(仅名称 / 排序 / 启停 / 备注)' })
|
||||||
|
updateItem(
|
||||||
|
@Param('code') code: string,
|
||||||
|
@Param('itemId') itemId: string,
|
||||||
|
@Body(new ZodValidationPipe(updateDictItemInputSchema)) input: UpdateDictItemInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<DictItem> {
|
||||||
|
return this.dicts.updateItem(code, itemId, input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import type { RequestContextService } from '../../common/security/request-context';
|
||||||
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import { DictsService } from './dicts.service';
|
||||||
|
|
||||||
|
const NOW = new Date('2026-11-04T02:00:00.000Z');
|
||||||
|
|
||||||
|
interface DictTypeFixture {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
groupKey: string;
|
||||||
|
kind: 'LIST' | 'TREE';
|
||||||
|
description: string;
|
||||||
|
industryScoped: boolean;
|
||||||
|
builtin: boolean;
|
||||||
|
status: 'ACTIVE' | 'DISABLED';
|
||||||
|
sortOrder: number;
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeFixture(overrides: Partial<DictTypeFixture> = {}): DictTypeFixture {
|
||||||
|
return {
|
||||||
|
id: 't1',
|
||||||
|
code: 'script_direction',
|
||||||
|
name: '脚本方向',
|
||||||
|
groupKey: 'content',
|
||||||
|
kind: 'LIST',
|
||||||
|
description: '脚本与素材的结构化方向',
|
||||||
|
industryScoped: false,
|
||||||
|
builtin: true,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sortOrder: 60,
|
||||||
|
updatedAt: NOW,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemFixture {
|
||||||
|
id: string;
|
||||||
|
typeId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
parentCode: string | null;
|
||||||
|
industryId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
status: 'ACTIVE' | 'DISABLED';
|
||||||
|
remark: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemFixture(overrides: Partial<ItemFixture> = {}): ItemFixture {
|
||||||
|
return {
|
||||||
|
id: `i-${overrides.code ?? 'oral'}-${overrides.industryId ?? 'generic'}`,
|
||||||
|
typeId: 't1',
|
||||||
|
code: 'oral',
|
||||||
|
name: '口播',
|
||||||
|
parentCode: null,
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
remark: '',
|
||||||
|
updatedAt: NOW,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
service: DictsService;
|
||||||
|
prisma: {
|
||||||
|
dictType: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
dictItem: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
industry: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
user: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
};
|
||||||
|
patchAudit: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(
|
||||||
|
prismaInput: Record<string, Record<string, ReturnType<typeof vi.fn>>> = {},
|
||||||
|
): Harness {
|
||||||
|
const dictType = {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
delete: vi.fn().mockResolvedValue({}),
|
||||||
|
...prismaInput.dictType,
|
||||||
|
};
|
||||||
|
const dictItem = {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
findUniqueOrThrow: vi.fn(),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
groupBy: vi.fn().mockResolvedValue([]),
|
||||||
|
...prismaInput.dictItem,
|
||||||
|
};
|
||||||
|
const industry = {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
...prismaInput.industry,
|
||||||
|
};
|
||||||
|
const user = {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ name: '江岚' }),
|
||||||
|
...prismaInput.user,
|
||||||
|
};
|
||||||
|
const patchAudit = vi.fn();
|
||||||
|
const service = new DictsService(
|
||||||
|
{ dictType, dictItem, industry, user } as unknown as PrismaService,
|
||||||
|
{ patchAudit } as unknown as RequestContextService,
|
||||||
|
);
|
||||||
|
return { service, prisma: { dictType, dictItem, industry, user }, patchAudit };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||||
|
return promise.then(
|
||||||
|
() => expect.unreachable('应抛出异常'),
|
||||||
|
(error: unknown) => {
|
||||||
|
expect(error).toBeInstanceOf(AppException);
|
||||||
|
expect((error as AppException).code).toBe(code);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DictsService · 类型列表 / 详情', () => {
|
||||||
|
it('按 DICT_GROUPS 顺序 + sortOrder 排序,并返回条目 / 启用 / 行业层统计', async () => {
|
||||||
|
const rows = [
|
||||||
|
typeFixture({
|
||||||
|
id: 't-quality',
|
||||||
|
code: 'quality_banned',
|
||||||
|
name: '禁用词',
|
||||||
|
groupKey: 'quality',
|
||||||
|
sortOrder: 10,
|
||||||
|
}),
|
||||||
|
typeFixture({ id: 't-content', code: 'hook_type', name: '钩子类型', sortOrder: 40 }),
|
||||||
|
typeFixture({ id: 't-content-2', code: 'shot_type', name: '景别', sortOrder: 50 }),
|
||||||
|
];
|
||||||
|
const { service } = harness({
|
||||||
|
dictType: { findMany: vi.fn().mockResolvedValue(rows) },
|
||||||
|
dictItem: {
|
||||||
|
groupBy: vi.fn().mockResolvedValue([
|
||||||
|
{ typeId: 't-content', status: 'ACTIVE', industryId: null, _count: { _all: 6 } },
|
||||||
|
{ typeId: 't-content', status: 'DISABLED', industryId: 'ec', _count: { _all: 1 } },
|
||||||
|
{ typeId: 't-content-2', status: 'ACTIVE', industryId: null, _count: { _all: 3 } },
|
||||||
|
{ typeId: 't-quality', status: 'ACTIVE', industryId: null, _count: { _all: 15 } },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.list({ status: 'all', limit: 20 });
|
||||||
|
expect(result.items.map((item) => item.code)).toEqual([
|
||||||
|
'hook_type',
|
||||||
|
'shot_type',
|
||||||
|
'quality_banned',
|
||||||
|
]);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
itemCount: 7,
|
||||||
|
activeItemCount: 6,
|
||||||
|
industryCount: 1,
|
||||||
|
group: 'content',
|
||||||
|
kind: 'list',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
expect(result.next_cursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('游标分页:limit 截断并返回 next_cursor;非法游标 40001', async () => {
|
||||||
|
const rows = [
|
||||||
|
typeFixture({ id: 't1', code: 'a_type', groupKey: 'content', sortOrder: 10 }),
|
||||||
|
typeFixture({ id: 't2', code: 'b_type', groupKey: 'content', sortOrder: 20 }),
|
||||||
|
typeFixture({ id: 't3', code: 'c_type', groupKey: 'content', sortOrder: 30 }),
|
||||||
|
];
|
||||||
|
const { service } = harness({ dictType: { findMany: vi.fn().mockResolvedValue(rows) } });
|
||||||
|
const first = await service.list({ status: 'all', limit: 2 });
|
||||||
|
expect(first.items.map((item) => item.code)).toEqual(['a_type', 'b_type']);
|
||||||
|
expect(first.next_cursor).toBe('b_type');
|
||||||
|
const second = await service.list({ status: 'all', limit: 2, cursor: 'b_type' });
|
||||||
|
expect(second.items.map((item) => item.code)).toEqual(['c_type']);
|
||||||
|
expect(second.next_cursor).toBeNull();
|
||||||
|
await expectRejected(
|
||||||
|
service.list({ status: 'all', limit: 2, cursor: 'missing' }),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('详情不存在返回 40401', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expectRejected(service.detail('missing_type'), ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DictsService · 类型写操作', () => {
|
||||||
|
it('新建:编码重复 40902;成功后 builtin=false、updatedBy 为操作人姓名、写审计', async () => {
|
||||||
|
const duplicate = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue({ id: 'existing' }) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
duplicate.service.create(
|
||||||
|
{
|
||||||
|
code: 'new_type',
|
||||||
|
name: '新类型',
|
||||||
|
group: 'content',
|
||||||
|
kind: 'list',
|
||||||
|
description: '',
|
||||||
|
industryScoped: false,
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = typeFixture({
|
||||||
|
id: 't-new',
|
||||||
|
code: 'new_type',
|
||||||
|
name: '新类型',
|
||||||
|
builtin: false,
|
||||||
|
sortOrder: 10,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
});
|
||||||
|
const success = harness({
|
||||||
|
dictType: {
|
||||||
|
findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(created),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const summary = await success.service.create(
|
||||||
|
{
|
||||||
|
code: 'new_type',
|
||||||
|
name: '新类型',
|
||||||
|
group: 'content',
|
||||||
|
kind: 'list',
|
||||||
|
description: '',
|
||||||
|
industryScoped: false,
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
expect(summary).toMatchObject({ code: 'new_type', builtin: false, updatedBy: '江岚' });
|
||||||
|
expect(success.prisma.dictType.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ builtin: false, status: 'ACTIVE', updatedBy: '江岚' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(success.patchAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ targetType: 'dict_type', before: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('内置类型允许改名 / 停用;删除内置类型 40001', async () => {
|
||||||
|
const updated = typeFixture({ name: '脚本方向(新)', updatedBy: '江岚' });
|
||||||
|
const edit = harness({
|
||||||
|
dictType: {
|
||||||
|
findUnique: vi.fn().mockResolvedValueOnce(typeFixture()).mockResolvedValue(updated),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const summary = await edit.service.update('script_direction', { name: '脚本方向(新)' }, 'u1');
|
||||||
|
expect(summary.name).toBe('脚本方向(新)');
|
||||||
|
expect(edit.prisma.dictType.update).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const disabled = typeFixture({ status: 'DISABLED' });
|
||||||
|
const status = harness({
|
||||||
|
dictType: {
|
||||||
|
findUnique: vi.fn().mockResolvedValueOnce(typeFixture()).mockResolvedValue(disabled),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
status.service.setStatus('script_direction', { status: 'disabled' }, 'u1'),
|
||||||
|
).resolves.toMatchObject({ status: 'disabled' });
|
||||||
|
|
||||||
|
const remove = harness({ dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) } });
|
||||||
|
await expectRejected(remove.service.remove('script_direction'), ErrorCode.PARAM_INVALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自定义类型有子条目不可删,无条目可删并写审计', async () => {
|
||||||
|
const withItems = harness({
|
||||||
|
dictType: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(typeFixture({ builtin: false, id: 't-custom' })),
|
||||||
|
},
|
||||||
|
dictItem: { count: vi.fn().mockResolvedValue(3) },
|
||||||
|
});
|
||||||
|
await expectRejected(withItems.service.remove('custom_type'), ErrorCode.PARAM_INVALID);
|
||||||
|
expect(withItems.prisma.dictType.delete).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const empty = harness({
|
||||||
|
dictType: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(typeFixture({ builtin: false, id: 't-custom' })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(empty.service.remove('custom_type')).resolves.toBeUndefined();
|
||||||
|
expect(empty.prisma.dictType.delete).toHaveBeenCalledWith({ where: { id: 't-custom' } });
|
||||||
|
expect(empty.patchAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ targetType: 'dict_type', after: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DictsService · 条目三层合并', () => {
|
||||||
|
it('非行业作用域类型强制忽略 industry_id,只返回通用层', async () => {
|
||||||
|
const { service, prisma } = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: false })) },
|
||||||
|
dictItem: {
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
itemFixture({ code: 'oral', name: '口播' }),
|
||||||
|
itemFixture({ code: 'drama', name: '剧情', sortOrder: 20 }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.listItems('script_direction', { industry_id: 'ec' });
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
expect(result.items.every((item) => item.source === 'generic')).toBe(true);
|
||||||
|
expect(result.layers).toEqual([
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 2 },
|
||||||
|
]);
|
||||||
|
expect(prisma.industry.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('行业不存在返回 40401;一级行业只叠加通用 + 本行业', async () => {
|
||||||
|
const missing = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: true })) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
missing.service.listItems('script_direction', { industry_id: 'missing' }),
|
||||||
|
ErrorCode.NOT_FOUND,
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: true })) },
|
||||||
|
industry: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ id: 'ec', name: '电商', parentId: null, parent: null }),
|
||||||
|
},
|
||||||
|
dictItem: {
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
itemFixture({ code: 'oral', name: '口播' }),
|
||||||
|
itemFixture({ code: 'live_clip', name: '直播切片', industryId: 'ec', sortOrder: 60 }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await root.service.listItems('script_direction', { industry_id: 'ec' });
|
||||||
|
expect(result.layers.map((layer) => layer.source)).toEqual(['generic', 'current']);
|
||||||
|
expect(result.items.map((item) => [item.code, item.source])).toEqual([
|
||||||
|
['oral', 'generic'],
|
||||||
|
['live_clip', 'current'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('二级行业三层合并:通用 5 + 父行业 2 + 本行业 1,同 code 覆盖后 7 条', async () => {
|
||||||
|
const { service } = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: true })) },
|
||||||
|
industry: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: 'leaf',
|
||||||
|
name: '服饰鞋包',
|
||||||
|
parentId: 'ec',
|
||||||
|
parent: { id: 'ec', name: '电商' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
dictItem: {
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
itemFixture({ code: 'oral', name: '口播' }),
|
||||||
|
itemFixture({ code: 'drama', name: '剧情', sortOrder: 20 }),
|
||||||
|
itemFixture({ code: 'review', name: '测评', sortOrder: 30 }),
|
||||||
|
itemFixture({ code: 'seeding', name: '种草', sortOrder: 40 }),
|
||||||
|
itemFixture({ code: 'scene', name: '情景', sortOrder: 50 }),
|
||||||
|
itemFixture({ code: 'live_clip', name: '直播切片', industryId: 'ec', sortOrder: 60 }),
|
||||||
|
itemFixture({ code: 'seeding', name: '种草短视频', industryId: 'ec', sortOrder: 40 }),
|
||||||
|
itemFixture({ code: 'outfit', name: '穿搭教程', industryId: 'leaf', sortOrder: 70 }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.listItems('script_direction', { industry_id: 'leaf' });
|
||||||
|
expect(result.items).toHaveLength(7);
|
||||||
|
expect(result.effectiveCount).toBe(7);
|
||||||
|
expect(result.overriddenCount).toBe(1);
|
||||||
|
expect(result.layers).toEqual([
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 5 },
|
||||||
|
{ source: 'parent', industryId: 'ec', label: '电商', count: 2 },
|
||||||
|
{ source: 'current', industryId: 'leaf', label: '服饰鞋包', count: 1 },
|
||||||
|
]);
|
||||||
|
const seeding = result.items.find((item) => item.code === 'seeding');
|
||||||
|
expect(seeding).toMatchObject({
|
||||||
|
name: '种草短视频',
|
||||||
|
source: 'parent',
|
||||||
|
overridden: true,
|
||||||
|
industryPath: '电商',
|
||||||
|
});
|
||||||
|
expect(result.items.find((item) => item.code === 'outfit')).toMatchObject({
|
||||||
|
source: 'current',
|
||||||
|
industryPath: '电商 / 服饰鞋包',
|
||||||
|
});
|
||||||
|
expect(result.items.find((item) => item.code === 'oral')).toMatchObject({
|
||||||
|
industryPath: null,
|
||||||
|
parentName: null,
|
||||||
|
refCount: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DictsService · 条目写操作', () => {
|
||||||
|
it('新建条目:非行业类型传 industryId / 列表型传 parentCode / 父级不存在 / 重复编码均 40001 或 40902', async () => {
|
||||||
|
const nonScoped = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: false })) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
nonScoped.service.createItem(
|
||||||
|
'script_direction',
|
||||||
|
{
|
||||||
|
code: 'new_item',
|
||||||
|
name: '新条目',
|
||||||
|
parentCode: null,
|
||||||
|
industryId: 'ec',
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
|
||||||
|
const listType = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ kind: 'LIST' })) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
listType.service.createItem(
|
||||||
|
'script_direction',
|
||||||
|
{
|
||||||
|
code: 'new_item',
|
||||||
|
name: '新条目',
|
||||||
|
parentCode: 'oral',
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingParent = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ kind: 'TREE' })) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
missingParent.service.createItem(
|
||||||
|
'script_direction',
|
||||||
|
{
|
||||||
|
code: 'new_item',
|
||||||
|
name: '新条目',
|
||||||
|
parentCode: 'missing',
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicate = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) },
|
||||||
|
dictItem: { findFirst: vi.fn().mockResolvedValue({ id: 'existing' }) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
duplicate.service.createItem(
|
||||||
|
'script_direction',
|
||||||
|
{
|
||||||
|
code: 'oral',
|
||||||
|
name: '口播',
|
||||||
|
parentCode: null,
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('新建条目成功:写审计前后值并返回条目 DTO', async () => {
|
||||||
|
const row = itemFixture({ id: 'i-new', code: 'new_item', name: '新条目' });
|
||||||
|
const { service, prisma, patchAudit } = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) },
|
||||||
|
dictItem: { findUniqueOrThrow: vi.fn().mockResolvedValue(row) },
|
||||||
|
});
|
||||||
|
const item = await service.createItem(
|
||||||
|
'script_direction',
|
||||||
|
{
|
||||||
|
code: 'new_item',
|
||||||
|
name: '新条目',
|
||||||
|
parentCode: null,
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
expect(item).toMatchObject({
|
||||||
|
code: 'new_item',
|
||||||
|
source: 'generic',
|
||||||
|
overridden: false,
|
||||||
|
refCount: 0,
|
||||||
|
});
|
||||||
|
expect(prisma.dictItem.create).toHaveBeenCalled();
|
||||||
|
expect(patchAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ targetType: 'dict_item', before: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('编辑条目:不存在 40401;成功只更新名称 / 排序 / 启停 / 备注并写前后值', async () => {
|
||||||
|
const missing = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
missing.service.updateItem('script_direction', 'missing', { name: '新名' }, 'u1'),
|
||||||
|
ErrorCode.NOT_FOUND,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existing = itemFixture({ id: 'i1', code: 'oral', name: '口播' });
|
||||||
|
const updated = itemFixture({ id: 'i1', code: 'oral', name: '口播(改)', status: 'DISABLED' });
|
||||||
|
const { service, patchAudit } = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) },
|
||||||
|
dictItem: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(existing),
|
||||||
|
findUniqueOrThrow: vi.fn().mockResolvedValue(updated),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const item = await service.updateItem(
|
||||||
|
'script_direction',
|
||||||
|
'i1',
|
||||||
|
{ name: '口播(改)', status: 'disabled' },
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
expect(item).toMatchObject({ code: 'oral', name: '口播(改)', status: 'disabled' });
|
||||||
|
expect(patchAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
targetType: 'dict_item',
|
||||||
|
before: expect.objectContaining({ name: '口播' }),
|
||||||
|
after: expect.objectContaining({ name: '口播(改)' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 字典类型 / 条目 / 三层合并集中在一个服务,按方法拆分后仍需共享校验与审计口径 */
|
||||||
|
import {
|
||||||
|
type CreateDictItemInput,
|
||||||
|
type CreateDictTypeInput,
|
||||||
|
DICT_GROUPS,
|
||||||
|
type DictGroupKey,
|
||||||
|
type DictItem,
|
||||||
|
type DictItemListQuery,
|
||||||
|
type DictItemListResponse,
|
||||||
|
type DictItemSource,
|
||||||
|
type DictTypeListQuery,
|
||||||
|
type DictTypeSummary,
|
||||||
|
ErrorCode,
|
||||||
|
type ListResponse,
|
||||||
|
type SetDictTypeStatusInput,
|
||||||
|
ulid,
|
||||||
|
type UpdateDictItemInput,
|
||||||
|
type UpdateDictTypeInput,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { DictItem as PrismaDictItem, DictType as PrismaDictType } from '@prisma/client';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { RequestContextService } from '../../common/security/request-context';
|
||||||
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
import {
|
||||||
|
type DictTypeCounts,
|
||||||
|
iso,
|
||||||
|
mapDictGroup,
|
||||||
|
mapDictStatus,
|
||||||
|
toDictItem,
|
||||||
|
toDictTypeSummary,
|
||||||
|
} from './dict.mapper';
|
||||||
|
import { type DictMergeLayer, type DictMergeRow, mergeDictLayers } from './dict-merge';
|
||||||
|
|
||||||
|
interface IndustryLayerContext {
|
||||||
|
layers: { source: DictItemSource; industryId: string | null; label: string }[];
|
||||||
|
industryIds: string[];
|
||||||
|
/** 行业 id -> 展示路径(一级名 / 二级名) */
|
||||||
|
paths: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DictTypeAuditSnapshot {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
group: DictGroupKey;
|
||||||
|
description: string;
|
||||||
|
sortOrder: number;
|
||||||
|
status: 'active' | 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_ORDER: ReadonlyMap<DictGroupKey, number> = new Map(
|
||||||
|
DICT_GROUPS.map((group, index) => [group.key, index]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const EMPTY_COUNTS: DictTypeCounts = { itemCount: 0, activeItemCount: 0, industryCount: 0 };
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
||||||
|
}
|
||||||
|
|
||||||
|
function layerCodeKey(industryId: string | null, code: string): string {
|
||||||
|
return `${industryId ?? ''}\u0000${code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeSortRank(row: PrismaDictType): number {
|
||||||
|
return GROUP_ORDER.get(mapDictGroup(row.groupKey)) ?? DICT_GROUPS.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareTypeRows(left: PrismaDictType, right: PrismaDictType): number {
|
||||||
|
const byGroup = typeSortRank(left) - typeSortRank(right);
|
||||||
|
if (byGroup !== 0) return byGroup;
|
||||||
|
if (left.sortOrder !== right.sortOrder) return left.sortOrder - right.sortOrder;
|
||||||
|
return left.code.localeCompare(right.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeRowAudit(row: PrismaDictType): DictTypeAuditSnapshot {
|
||||||
|
return {
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
group: mapDictGroup(row.groupKey),
|
||||||
|
description: row.description,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
status: mapDictStatus(row.status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemAudit(row: PrismaDictItem): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
parentCode: row.parentCode,
|
||||||
|
industryId: row.industryId,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
status: mapDictStatus(row.status),
|
||||||
|
remark: row.remark,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMergeRow(row: PrismaDictItem): DictMergeRow {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
parentCode: row.parentCode,
|
||||||
|
industryId: row.industryId,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
status: mapDictStatus(row.status),
|
||||||
|
remark: row.remark,
|
||||||
|
updatedAt: iso(row.updatedAt),
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据字典(A4-BE-01 / A4-BE-04):
|
||||||
|
* - 类型:列表 / 详情 / 新建 / 编辑 / 启停 / 删除(编码、形态、行业作用域创建后不可改);
|
||||||
|
* - 条目:行业视角三层合并(通用 → 父行业 → 本行业)、新建 / 编辑;
|
||||||
|
* - 写操作统一 `RequestContextService.patchAudit` 记录前后值;
|
||||||
|
* - 快照由 `dict.mapper#toDictSnapshot` 提供,业务记录落库在 B0 / M1 接入(R-10)。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DictsService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(query: DictTypeListQuery): Promise<ListResponse<DictTypeSummary>> {
|
||||||
|
const where: Prisma.DictTypeWhereInput = {
|
||||||
|
...(query.keyword === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: query.keyword, mode: 'insensitive' } },
|
||||||
|
{ code: { contains: query.keyword, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
...(query.group === undefined ? {} : { groupKey: query.group }),
|
||||||
|
...(query.status === 'all'
|
||||||
|
? {}
|
||||||
|
: { status: query.status === 'active' ? 'ACTIVE' : 'DISABLED' }),
|
||||||
|
};
|
||||||
|
const rows = await this.prisma.dictType.findMany({ where });
|
||||||
|
const counts = await this.countsByType(rows.map((row) => row.id));
|
||||||
|
const sorted = [...rows].sort(compareTypeRows);
|
||||||
|
const start = this.cursorStart(sorted, query.cursor);
|
||||||
|
const page = sorted.slice(start, start + query.limit);
|
||||||
|
const hasMore = start + query.limit < sorted.length;
|
||||||
|
return {
|
||||||
|
items: page.map((row) => toDictTypeSummary(row, counts.get(row.id) ?? EMPTY_COUNTS)),
|
||||||
|
next_cursor: hasMore ? (page[page.length - 1]?.code ?? null) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(code: string): Promise<DictTypeSummary> {
|
||||||
|
const row = await this.findTypeOrThrow(code);
|
||||||
|
const counts = await this.countsByType([row.id]);
|
||||||
|
return toDictTypeSummary(row, counts.get(row.id) ?? EMPTY_COUNTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateDictTypeInput, actorId: string): Promise<DictTypeSummary> {
|
||||||
|
const duplicate = await this.prisma.dictType.findUnique({
|
||||||
|
where: { code: input.code },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (duplicate !== null) {
|
||||||
|
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '字典类型编码已存在' });
|
||||||
|
}
|
||||||
|
const id = ulid();
|
||||||
|
const updatedBy = await this.actorName(actorId);
|
||||||
|
try {
|
||||||
|
await this.prisma.dictType.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
code: input.code,
|
||||||
|
name: input.name,
|
||||||
|
groupKey: input.group,
|
||||||
|
kind: input.kind === 'tree' ? 'TREE' : 'LIST',
|
||||||
|
description: input.description,
|
||||||
|
industryScoped: input.industryScoped,
|
||||||
|
builtin: false,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
updatedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '字典类型编码已存在' });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const summary = await this.detail(input.code);
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'dict_type',
|
||||||
|
targetId: id,
|
||||||
|
before: null,
|
||||||
|
after: summary,
|
||||||
|
});
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
code: string,
|
||||||
|
input: UpdateDictTypeInput,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<DictTypeSummary> {
|
||||||
|
const existing = await this.findTypeOrThrow(code);
|
||||||
|
const updatedBy = await this.actorName(actorId);
|
||||||
|
await this.prisma.dictType.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
...(input.name === undefined ? {} : { name: input.name }),
|
||||||
|
...(input.group === undefined ? {} : { groupKey: input.group }),
|
||||||
|
...(input.description === undefined ? {} : { description: input.description }),
|
||||||
|
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
|
||||||
|
updatedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const summary = await this.detail(code);
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'dict_type',
|
||||||
|
targetId: existing.id,
|
||||||
|
before: typeRowAudit(existing),
|
||||||
|
after: {
|
||||||
|
code: summary.code,
|
||||||
|
name: summary.name,
|
||||||
|
group: summary.group,
|
||||||
|
description: summary.description,
|
||||||
|
sortOrder: summary.sortOrder,
|
||||||
|
status: summary.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStatus(
|
||||||
|
code: string,
|
||||||
|
input: SetDictTypeStatusInput,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<DictTypeSummary> {
|
||||||
|
const existing = await this.findTypeOrThrow(code);
|
||||||
|
const updatedBy = await this.actorName(actorId);
|
||||||
|
await this.prisma.dictType.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
status: input.status === 'active' ? 'ACTIVE' : 'DISABLED',
|
||||||
|
updatedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const summary = await this.detail(code);
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'dict_type',
|
||||||
|
targetId: existing.id,
|
||||||
|
before: typeRowAudit(existing),
|
||||||
|
after: {
|
||||||
|
code: summary.code,
|
||||||
|
name: summary.name,
|
||||||
|
group: summary.group,
|
||||||
|
description: summary.description,
|
||||||
|
sortOrder: summary.sortOrder,
|
||||||
|
status: summary.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(code: string): Promise<void> {
|
||||||
|
const existing = await this.findTypeOrThrow(code);
|
||||||
|
if (existing.builtin) {
|
||||||
|
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '内置类型不可删除' });
|
||||||
|
}
|
||||||
|
const itemCount = await this.prisma.dictItem.count({ where: { typeId: existing.id } });
|
||||||
|
if (itemCount > 0) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '请先删除条目或改为停用',
|
||||||
|
details: { itemCount },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.prisma.dictType.delete({ where: { id: existing.id } });
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'dict_type',
|
||||||
|
targetId: existing.id,
|
||||||
|
before: typeRowAudit(existing),
|
||||||
|
after: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listItems(code: string, query: DictItemListQuery): Promise<DictItemListResponse> {
|
||||||
|
const type = await this.findTypeOrThrow(code);
|
||||||
|
const view = await this.industryView(type, query.industry_id);
|
||||||
|
const rows = await this.prisma.dictItem.findMany({
|
||||||
|
where: {
|
||||||
|
typeId: type.id,
|
||||||
|
OR: [
|
||||||
|
{ industryId: null },
|
||||||
|
...(view.industryIds.length === 0 ? [] : [{ industryId: { in: view.industryIds } }]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergeLayers: DictMergeLayer[] = view.layers.map((definition) => ({
|
||||||
|
source: definition.source,
|
||||||
|
industryId: definition.industryId,
|
||||||
|
label: definition.label,
|
||||||
|
items: rows
|
||||||
|
.filter((row) => row.industryId === definition.industryId)
|
||||||
|
.map((row) => toMergeRow(row)),
|
||||||
|
}));
|
||||||
|
const merged = mergeDictLayers(mergeLayers);
|
||||||
|
|
||||||
|
const nameByLayerCode = new Map<string, string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
nameByLayerCode.set(layerCodeKey(row.industryId, row.code), row.name);
|
||||||
|
}
|
||||||
|
const effectiveNameByCode = new Map(merged.items.map((item) => [item.code, item.name]));
|
||||||
|
|
||||||
|
const items: DictItem[] = merged.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
typeCode: type.code,
|
||||||
|
code: item.code,
|
||||||
|
name: item.name,
|
||||||
|
parentCode: item.parentCode,
|
||||||
|
parentName:
|
||||||
|
item.parentCode === null
|
||||||
|
? null
|
||||||
|
: (nameByLayerCode.get(layerCodeKey(item.industryId, item.parentCode)) ??
|
||||||
|
effectiveNameByCode.get(item.parentCode) ??
|
||||||
|
null),
|
||||||
|
industryId: item.industryId,
|
||||||
|
industryPath: item.industryId === null ? null : (view.paths.get(item.industryId) ?? null),
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
status: item.status,
|
||||||
|
remark: item.remark,
|
||||||
|
source: item.source,
|
||||||
|
overridden: item.overridden,
|
||||||
|
// 引用数:A4 尚无引用表,B0 行业资产 / B1 素材接入后改为真实统计(接口字段已保留)
|
||||||
|
refCount: 0,
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
updatedBy: item.updatedBy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
next_cursor: null,
|
||||||
|
layers: merged.layers,
|
||||||
|
effectiveCount: merged.effectiveCount,
|
||||||
|
overriddenCount: merged.overriddenCount,
|
||||||
|
disabledCount: merged.disabledCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createItem(code: string, input: CreateDictItemInput, actorId: string): Promise<DictItem> {
|
||||||
|
const type = await this.findTypeOrThrow(code);
|
||||||
|
const industryId = input.industryId ?? null;
|
||||||
|
if (!type.industryScoped && industryId !== null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '该字典类型不支持行业作用域,不能指定行业',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (industryId !== null) {
|
||||||
|
await this.assertIndustryExists(industryId);
|
||||||
|
}
|
||||||
|
const parentCode = input.parentCode ?? null;
|
||||||
|
let parentName: string | null = null;
|
||||||
|
if (parentCode !== null) {
|
||||||
|
if (type.kind !== 'TREE') {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '列表型字典不允许设置父级编码',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const parent = await this.prisma.dictItem.findFirst({
|
||||||
|
where: { typeId: type.id, code: parentCode, industryId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
if (parent === null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '父级条目不存在或不属于同一行业层',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parentName = parent.name;
|
||||||
|
}
|
||||||
|
const duplicate = await this.prisma.dictItem.findFirst({
|
||||||
|
where: { typeId: type.id, code: input.code, industryId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (duplicate !== null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
message: '同一字典类型与行业层下条目编码已存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = ulid();
|
||||||
|
const updatedBy = await this.actorName(actorId);
|
||||||
|
try {
|
||||||
|
await this.prisma.dictItem.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
typeId: type.id,
|
||||||
|
code: input.code,
|
||||||
|
name: input.name,
|
||||||
|
parentCode,
|
||||||
|
industryId,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
status: input.status === 'active' ? 'ACTIVE' : 'DISABLED',
|
||||||
|
remark: input.remark,
|
||||||
|
updatedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
message: '同一字典类型与行业层下条目编码已存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const row = await this.prisma.dictItem.findUniqueOrThrow({ where: { id } });
|
||||||
|
const item = toDictItem(row, {
|
||||||
|
typeCode: type.code,
|
||||||
|
source: industryId === null ? 'generic' : 'current',
|
||||||
|
overridden: false,
|
||||||
|
parentName,
|
||||||
|
industryPath: await this.industryPathOf(industryId),
|
||||||
|
});
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'dict_item',
|
||||||
|
targetId: id,
|
||||||
|
before: null,
|
||||||
|
after: itemAudit(row),
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateItem(
|
||||||
|
code: string,
|
||||||
|
itemId: string,
|
||||||
|
input: UpdateDictItemInput,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<DictItem> {
|
||||||
|
const type = await this.findTypeOrThrow(code);
|
||||||
|
const existing = await this.prisma.dictItem.findFirst({
|
||||||
|
where: { id: itemId, typeId: type.id },
|
||||||
|
});
|
||||||
|
if (existing === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字典条目不存在' });
|
||||||
|
}
|
||||||
|
const updatedBy = await this.actorName(actorId);
|
||||||
|
await this.prisma.dictItem.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
...(input.name === undefined ? {} : { name: input.name }),
|
||||||
|
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
|
||||||
|
...(input.status === undefined
|
||||||
|
? {}
|
||||||
|
: { status: input.status === 'active' ? 'ACTIVE' : 'DISABLED' }),
|
||||||
|
...(input.remark === undefined ? {} : { remark: input.remark }),
|
||||||
|
updatedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const row = await this.prisma.dictItem.findUniqueOrThrow({ where: { id: existing.id } });
|
||||||
|
const parent =
|
||||||
|
row.parentCode === null
|
||||||
|
? null
|
||||||
|
: await this.prisma.dictItem.findFirst({
|
||||||
|
where: { typeId: type.id, code: row.parentCode, industryId: row.industryId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
const item = toDictItem(row, {
|
||||||
|
typeCode: type.code,
|
||||||
|
source: row.industryId === null ? 'generic' : 'current',
|
||||||
|
overridden: false,
|
||||||
|
parentName: parent?.name ?? null,
|
||||||
|
industryPath: await this.industryPathOf(row.industryId),
|
||||||
|
});
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'dict_item',
|
||||||
|
targetId: row.id,
|
||||||
|
before: itemAudit(existing),
|
||||||
|
after: itemAudit(row),
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findTypeOrThrow(code: string): Promise<PrismaDictType> {
|
||||||
|
const row = await this.prisma.dictType.findUnique({ where: { code } });
|
||||||
|
if (row === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字典类型不存在' });
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cursorStart(rows: readonly PrismaDictType[], cursor: string | undefined): number {
|
||||||
|
if (cursor === undefined) return 0;
|
||||||
|
const index = rows.findIndex((row) => row.code === cursor);
|
||||||
|
if (index < 0) {
|
||||||
|
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '分页游标无效' });
|
||||||
|
}
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按类型聚合:总条目、启用条目、出现过的行业层数(distinct industry_id) */
|
||||||
|
private async countsByType(typeIds: readonly string[]): Promise<Map<string, DictTypeCounts>> {
|
||||||
|
if (typeIds.length === 0) return new Map();
|
||||||
|
const grouped = await this.prisma.dictItem.groupBy({
|
||||||
|
by: ['typeId', 'status', 'industryId'],
|
||||||
|
where: { typeId: { in: [...typeIds] } },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const counts = new Map<
|
||||||
|
string,
|
||||||
|
{ itemCount: number; activeItemCount: number; industries: Set<string> }
|
||||||
|
>();
|
||||||
|
for (const row of grouped) {
|
||||||
|
const entry = counts.get(row.typeId) ?? {
|
||||||
|
itemCount: 0,
|
||||||
|
activeItemCount: 0,
|
||||||
|
industries: new Set<string>(),
|
||||||
|
};
|
||||||
|
entry.itemCount += row._count._all;
|
||||||
|
if (row.status === 'ACTIVE') entry.activeItemCount += row._count._all;
|
||||||
|
if (row.industryId !== null) entry.industries.add(row.industryId);
|
||||||
|
counts.set(row.typeId, entry);
|
||||||
|
}
|
||||||
|
return new Map(
|
||||||
|
[...counts].map(([typeId, entry]) => [
|
||||||
|
typeId,
|
||||||
|
{
|
||||||
|
itemCount: entry.itemCount,
|
||||||
|
activeItemCount: entry.activeItemCount,
|
||||||
|
industryCount: entry.industries.size,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行业视角:非行业作用域类型强制忽略 industry_id(只返回通用层);
|
||||||
|
* 一级行业 = 通用 + 本行业;二级行业 = 通用 + 父行业 + 本行业。
|
||||||
|
*/
|
||||||
|
private async industryView(
|
||||||
|
type: PrismaDictType,
|
||||||
|
industryId: string | undefined,
|
||||||
|
): Promise<IndustryLayerContext> {
|
||||||
|
const generic: { source: DictItemSource; industryId: string | null; label: string } = {
|
||||||
|
source: 'generic',
|
||||||
|
industryId: null,
|
||||||
|
label: '通用',
|
||||||
|
};
|
||||||
|
if (!type.industryScoped || industryId === undefined) {
|
||||||
|
return { layers: [generic], industryIds: [], paths: new Map() };
|
||||||
|
}
|
||||||
|
const industry = await this.prisma.industry.findUnique({
|
||||||
|
where: { id: industryId },
|
||||||
|
include: { parent: true },
|
||||||
|
});
|
||||||
|
if (industry === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '行业不存在' });
|
||||||
|
}
|
||||||
|
const paths = new Map<string, string>();
|
||||||
|
paths.set(
|
||||||
|
industry.id,
|
||||||
|
industry.parent === null ? industry.name : `${industry.parent.name} / ${industry.name}`,
|
||||||
|
);
|
||||||
|
const industryIds = [industry.id];
|
||||||
|
const layers: { source: DictItemSource; industryId: string | null; label: string }[] = [
|
||||||
|
generic,
|
||||||
|
];
|
||||||
|
if (industry.parent !== null) {
|
||||||
|
layers.push({
|
||||||
|
source: 'parent',
|
||||||
|
industryId: industry.parent.id,
|
||||||
|
label: industry.parent.name,
|
||||||
|
});
|
||||||
|
industryIds.push(industry.parent.id);
|
||||||
|
paths.set(industry.parent.id, industry.parent.name);
|
||||||
|
}
|
||||||
|
layers.push({ source: 'current', industryId: industry.id, label: industry.name });
|
||||||
|
return { layers, industryIds, paths };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertIndustryExists(industryId: string): Promise<void> {
|
||||||
|
const industry = await this.prisma.industry.findUnique({
|
||||||
|
where: { id: industryId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (industry === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '行业不存在' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async industryPathOf(industryId: string | null): Promise<string | null> {
|
||||||
|
if (industryId === null) return null;
|
||||||
|
const industry = await this.prisma.industry.findUnique({
|
||||||
|
where: { id: industryId },
|
||||||
|
include: { parent: true },
|
||||||
|
});
|
||||||
|
if (industry === null) return null;
|
||||||
|
return industry.parent === null ? industry.name : `${industry.parent.name} / ${industry.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** updated_by 存展示名(种子 / 前端均按姓名展示);用户不存在时回退 actorId,避免写入丢失 */
|
||||||
|
private async actorName(actorId: string): Promise<string> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: actorId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
return user?.name ?? actorId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import { API_PREFIX, ErrorCode, Permission, ulid } from '@lingxi/shared';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createApp, type CreatedApiApp } from '../../bootstrap';
|
||||||
|
import { loadDotenv } from '../../config/load-dotenv';
|
||||||
|
|
||||||
|
const PASSWORD_HASH =
|
||||||
|
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||||
|
const PASSWORD = 'lingxi2026';
|
||||||
|
const PREFIX = 'itqa3.';
|
||||||
|
const API = API_PREFIX;
|
||||||
|
|
||||||
|
loadDotenv();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface TokenBody {
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonResult<T> {
|
||||||
|
status: number;
|
||||||
|
body: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FixtureUser {
|
||||||
|
id: string;
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let phoneCounter = 0;
|
||||||
|
let app: CreatedApiApp['app'];
|
||||||
|
let superToken = '';
|
||||||
|
let scopedToken = '';
|
||||||
|
let approverToken = '';
|
||||||
|
let scopedUserId = '';
|
||||||
|
let rootIndustryId = '';
|
||||||
|
let leafIndustryId = '';
|
||||||
|
let customerId = '';
|
||||||
|
let groupId = '';
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
const createdRoleIds: string[] = [];
|
||||||
|
|
||||||
|
function nextPhone(): string {
|
||||||
|
phoneCounter += 1;
|
||||||
|
return `136${String(phoneCounter).padStart(8, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRole(
|
||||||
|
suffix: string,
|
||||||
|
permissions: string[],
|
||||||
|
dataScope: { type: string; targetId?: string },
|
||||||
|
): Promise<string> {
|
||||||
|
const id = ulid();
|
||||||
|
await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
code: `${PREFIX}role_${suffix}`,
|
||||||
|
name: `${PREFIX}角色-${suffix}`,
|
||||||
|
description: 'A3-QA-01 集成测试角色',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
|
||||||
|
dataScopes: {
|
||||||
|
create: [{ id: ulid(), scopeType: dataScope.type, targetId: dataScope.targetId ?? null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdRoleIds.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(suffix: string, roleId: string): Promise<FixtureUser> {
|
||||||
|
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||||||
|
const account = `${PREFIX}${suffix}`;
|
||||||
|
const id = ulid();
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
name: `A3 集成测试${suffix}`,
|
||||||
|
account,
|
||||||
|
email: `${account}@lingxi.cn`,
|
||||||
|
phone: nextPhone(),
|
||||||
|
passwordHash: PASSWORD_HASH,
|
||||||
|
departmentId: department.id,
|
||||||
|
mustChangePassword: false,
|
||||||
|
roles: { create: [{ roleId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdUserIds.push(id);
|
||||||
|
return { id, account };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(account: string): Promise<string> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${API}/auth/login`,
|
||||||
|
payload: { account, password: PASSWORD },
|
||||||
|
});
|
||||||
|
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
||||||
|
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
|
||||||
|
}
|
||||||
|
return response.json<TokenBody>().accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<T>(
|
||||||
|
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
payload?: unknown,
|
||||||
|
): Promise<JsonResult<T>> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method,
|
||||||
|
url: `${API}${url}`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||||
|
});
|
||||||
|
const body = response.body.length === 0 ? (null as T) : response.json<T>();
|
||||||
|
return { status: response.statusCode, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await prisma.customerField.deleteMany({
|
||||||
|
where: { customer: { name: { startsWith: 'ITQA3-' } } },
|
||||||
|
});
|
||||||
|
await prisma.customerGroupMember.deleteMany({
|
||||||
|
where: { group: { name: { startsWith: 'ITQA3-' } } },
|
||||||
|
});
|
||||||
|
await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||||
|
await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||||
|
await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||||
|
await prisma.user.deleteMany({ where: { account: { startsWith: PREFIX } } });
|
||||||
|
await prisma.role.deleteMany({ where: { code: { startsWith: `${PREFIX}role_` } } });
|
||||||
|
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
|
||||||
|
await createUser('admin', superAdminRole.id);
|
||||||
|
const scopedRole = await createRole('scoped', [Permission.CUSTOMER_READ], {
|
||||||
|
type: 'created_by_me',
|
||||||
|
});
|
||||||
|
const scopedUser = await createUser('scoped', scopedRole);
|
||||||
|
scopedUserId = scopedUser.id;
|
||||||
|
const approverRole = await createRole(
|
||||||
|
'approver',
|
||||||
|
[Permission.CUSTOMER_READ, Permission.CUSTOMER_FIELD_APPROVE],
|
||||||
|
{ type: 'all' },
|
||||||
|
);
|
||||||
|
await createUser('approver', approverRole);
|
||||||
|
|
||||||
|
const created = await createApp();
|
||||||
|
app = created.app;
|
||||||
|
await app.init();
|
||||||
|
const adminRow = await prisma.user.findUnique({ where: { account: `${PREFIX}admin` } });
|
||||||
|
if (adminRow === null) throw new Error('fixture user missing');
|
||||||
|
superToken = await login(`${PREFIX}admin`);
|
||||||
|
scopedToken = await login(`${PREFIX}scoped`);
|
||||||
|
approverToken = await login(`${PREFIX}approver`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.customerField.deleteMany({
|
||||||
|
where: { customer: { name: { startsWith: 'ITQA3-' } } },
|
||||||
|
});
|
||||||
|
await prisma.customerGroupMember.deleteMany({
|
||||||
|
where: { group: { name: { startsWith: 'ITQA3-' } } },
|
||||||
|
});
|
||||||
|
await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||||
|
await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||||
|
await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||||
|
if (createdRoleIds.length > 0) {
|
||||||
|
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
|
||||||
|
}
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
|
||||||
|
if (app) await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A3 客户与行业目录 · 唯一性 / 状态 / 范围 / 字段审批', () => {
|
||||||
|
it('两级行业:新建一级与二级,重名 40902,有子行业禁止停用/删除', async () => {
|
||||||
|
const root = await call<{ id: string; level: number; status: string }>(
|
||||||
|
'POST',
|
||||||
|
'/industries',
|
||||||
|
superToken,
|
||||||
|
{
|
||||||
|
name: 'ITQA3-电商',
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(root.status).toBe(201);
|
||||||
|
expect(root.body.level).toBe(1);
|
||||||
|
rootIndustryId = root.body.id;
|
||||||
|
|
||||||
|
const child = await call<{ id: string; parentId: string; level: number }>(
|
||||||
|
'POST',
|
||||||
|
'/industries',
|
||||||
|
superToken,
|
||||||
|
{
|
||||||
|
name: 'ITQA3-服饰',
|
||||||
|
parentId: rootIndustryId,
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(child.status).toBe(201);
|
||||||
|
expect(child.body.level).toBe(2);
|
||||||
|
leafIndustryId = child.body.id;
|
||||||
|
|
||||||
|
const duplicate = await call<{ code: number }>('POST', '/industries', superToken, {
|
||||||
|
name: 'ITQA3-电商',
|
||||||
|
});
|
||||||
|
expect(duplicate.status).toBe(409);
|
||||||
|
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
|
||||||
|
|
||||||
|
const blockedStop = await call<{ code: number }>(
|
||||||
|
'PATCH',
|
||||||
|
`/industries/${rootIndustryId}/status`,
|
||||||
|
superToken,
|
||||||
|
{ status: 'disabled' },
|
||||||
|
);
|
||||||
|
expect(blockedStop.status).toBe(409);
|
||||||
|
expect(blockedStop.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||||
|
|
||||||
|
const blockedDelete = await call<{ code: number }>(
|
||||||
|
'DELETE',
|
||||||
|
`/industries/${rootIndustryId}`,
|
||||||
|
superToken,
|
||||||
|
);
|
||||||
|
expect(blockedDelete.status).toBe(422);
|
||||||
|
expect(blockedDelete.body.code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('客户:简称必填 42201、同末级行业重名 40902、跨客户 404', async () => {
|
||||||
|
const missingShort = await call<{ code: number }>('POST', '/customers', superToken, {
|
||||||
|
industryId: leafIndustryId,
|
||||||
|
name: 'ITQA3-花间集',
|
||||||
|
ownerUserId: scopedUserId,
|
||||||
|
});
|
||||||
|
expect(missingShort.status).toBe(400);
|
||||||
|
expect(missingShort.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const customer = await call<{ id: string; genFieldCount: number }>(
|
||||||
|
'POST',
|
||||||
|
'/customers',
|
||||||
|
superToken,
|
||||||
|
{
|
||||||
|
industryId: leafIndustryId,
|
||||||
|
name: 'ITQA3-花间集',
|
||||||
|
shortName: 'HJ3',
|
||||||
|
ownerUserId: scopedUserId,
|
||||||
|
baseFields: [{ key: '门店', value: '沪-001' }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(customer.status).toBe(201);
|
||||||
|
customerId = customer.body.id;
|
||||||
|
expect(customer.body.genFieldCount).toBe(0);
|
||||||
|
|
||||||
|
const duplicate = await call<{ code: number }>('POST', '/customers', superToken, {
|
||||||
|
industryId: leafIndustryId,
|
||||||
|
name: 'ITQA3-花间集',
|
||||||
|
shortName: 'HJ3B',
|
||||||
|
ownerUserId: scopedUserId,
|
||||||
|
});
|
||||||
|
expect(duplicate.status).toBe(409);
|
||||||
|
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
|
||||||
|
|
||||||
|
const crossCustomer = await call<{ code: number }>(
|
||||||
|
'GET',
|
||||||
|
`/customers/${customerId}`,
|
||||||
|
scopedToken,
|
||||||
|
);
|
||||||
|
expect(crossCustomer.status).toBe(404);
|
||||||
|
expect(crossCustomer.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('客户组:创建、幂等添加成员、数据范围解析到客户', async () => {
|
||||||
|
const group = await call<{ id: string; memberCount: number }>(
|
||||||
|
'POST',
|
||||||
|
'/customer-groups',
|
||||||
|
superToken,
|
||||||
|
{
|
||||||
|
name: 'ITQA3-客户组',
|
||||||
|
description: 'A3 集成测试客户组',
|
||||||
|
customerIds: [customerId],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(group.status).toBe(201);
|
||||||
|
groupId = group.body.id;
|
||||||
|
expect(group.body.memberCount).toBe(1);
|
||||||
|
|
||||||
|
const again = await call<{ memberCount: number }>(
|
||||||
|
'POST',
|
||||||
|
`/customer-groups/${groupId}/members`,
|
||||||
|
superToken,
|
||||||
|
{
|
||||||
|
addCustomerIds: [customerId],
|
||||||
|
removeCustomerIds: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(again.status).toBe(200);
|
||||||
|
expect(again.body.memberCount).toBe(1);
|
||||||
|
|
||||||
|
const groupRole = await createRole('group', [Permission.CUSTOMER_READ], {
|
||||||
|
type: 'customer_groups',
|
||||||
|
targetId: groupId,
|
||||||
|
});
|
||||||
|
await createUser('group', groupRole);
|
||||||
|
const groupToken = await login(`${PREFIX}group`);
|
||||||
|
const visible = await call<{ items: { id: string }[] }>(
|
||||||
|
'GET',
|
||||||
|
`/customers?limit=50`,
|
||||||
|
groupToken,
|
||||||
|
);
|
||||||
|
expect(visible.status).toBe(200);
|
||||||
|
expect(visible.body.items.some((item) => item.id === customerId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('客户字段:进入生成申请待审批、审批通过生效、驳回原因必填、超限 40001', async () => {
|
||||||
|
const apply = await call<{ id: string; status: string; entersGeneration: boolean }>(
|
||||||
|
'POST',
|
||||||
|
`/customers/${customerId}/fields`,
|
||||||
|
superToken,
|
||||||
|
{
|
||||||
|
name: 'ITQA3-活动编码',
|
||||||
|
code: 'itqa3_campaign',
|
||||||
|
type: 'text',
|
||||||
|
purpose: '活动素材命名与检索',
|
||||||
|
entersGeneration: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(apply.status).toBe(201);
|
||||||
|
expect(apply.body.status).toBe('pending');
|
||||||
|
expect(apply.body.entersGeneration).toBe(true);
|
||||||
|
|
||||||
|
const pending = await call<{ items: { id: string; status: string }[] }>(
|
||||||
|
'GET',
|
||||||
|
'/customer-fields?status=pending&limit=50',
|
||||||
|
approverToken,
|
||||||
|
);
|
||||||
|
expect(pending.status).toBe(200);
|
||||||
|
expect(pending.body.items.some((item) => item.id === apply.body.id)).toBe(true);
|
||||||
|
|
||||||
|
const rejectWithoutReason = await call<{ code: number }>(
|
||||||
|
'POST',
|
||||||
|
`/customer-fields/${apply.body.id}/approve`,
|
||||||
|
approverToken,
|
||||||
|
{ decision: 'reject' },
|
||||||
|
);
|
||||||
|
expect(rejectWithoutReason.status).toBe(400);
|
||||||
|
expect(rejectWithoutReason.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const approve = await call<{ status: string }>(
|
||||||
|
'POST',
|
||||||
|
`/customer-fields/${apply.body.id}/approve`,
|
||||||
|
approverToken,
|
||||||
|
{ decision: 'approve' },
|
||||||
|
);
|
||||||
|
expect(approve.status).toBe(200);
|
||||||
|
expect(approve.body.status).toBe('active');
|
||||||
|
|
||||||
|
for (let index = 0; index < 4; index += 1) {
|
||||||
|
const extra = await call<{ code?: number }>(
|
||||||
|
'POST',
|
||||||
|
`/customers/${customerId}/fields`,
|
||||||
|
superToken,
|
||||||
|
{
|
||||||
|
name: `ITQA3-字段${index}`,
|
||||||
|
code: `itqa3_extra_${index}`,
|
||||||
|
type: 'text',
|
||||||
|
purpose: '补足进入生成字段上限',
|
||||||
|
entersGeneration: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(extra.status).toBe(201);
|
||||||
|
}
|
||||||
|
const overflow = await call<{ code: number }>(
|
||||||
|
'POST',
|
||||||
|
`/customers/${customerId}/fields`,
|
||||||
|
superToken,
|
||||||
|
{
|
||||||
|
name: 'ITQA3-超限字段',
|
||||||
|
code: 'itqa3_overflow',
|
||||||
|
type: 'text',
|
||||||
|
purpose: '第六个进入生成字段应被拒绝',
|
||||||
|
entersGeneration: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(overflow.status).toBe(400);
|
||||||
|
expect(overflow.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('客户转交:无 customer:transfer 返回 403;超级管理员转交成功', async () => {
|
||||||
|
const forbidden = await call<{ code: number }>(
|
||||||
|
'POST',
|
||||||
|
`/customers/${customerId}/transfer`,
|
||||||
|
scopedToken,
|
||||||
|
{
|
||||||
|
ownerUserId: scopedUserId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(forbidden.status).toBe(403);
|
||||||
|
expect(forbidden.body.code).toBe(ErrorCode.PERMISSION_DENIED);
|
||||||
|
|
||||||
|
const transferred = await call<{ ownerUserId: string }>(
|
||||||
|
'POST',
|
||||||
|
`/customers/${customerId}/transfer`,
|
||||||
|
superToken,
|
||||||
|
{ ownerUserId: createdUserIds[0] },
|
||||||
|
);
|
||||||
|
expect(transferred.status).toBe(200);
|
||||||
|
expect(transferred.body.ownerUserId).toBe(createdUserIds[0]);
|
||||||
|
});
|
||||||
|
it('客户状态机:暂停 / 结束只读', async () => {
|
||||||
|
const paused = await call<{ status: string }>(
|
||||||
|
'PATCH',
|
||||||
|
`/customers/${customerId}/status`,
|
||||||
|
superToken,
|
||||||
|
{
|
||||||
|
status: 'paused',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(paused.status).toBe(200);
|
||||||
|
expect(paused.body.status).toBe('paused');
|
||||||
|
const editPaused = await call<{ code: number }>(
|
||||||
|
'PATCH',
|
||||||
|
`/customers/${customerId}`,
|
||||||
|
superToken,
|
||||||
|
{
|
||||||
|
shortName: 'HJ3C',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(editPaused.status).toBe(409);
|
||||||
|
expect(editPaused.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||||
|
|
||||||
|
const ended = await call<{ status: string }>(
|
||||||
|
'PATCH',
|
||||||
|
`/customers/${customerId}/status`,
|
||||||
|
superToken,
|
||||||
|
{
|
||||||
|
status: 'ended',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(ended.status).toBe(200);
|
||||||
|
expect(ended.body.status).toBe('ended');
|
||||||
|
const editEnded = await call<{ code: number }>(
|
||||||
|
'PATCH',
|
||||||
|
`/customers/${customerId}`,
|
||||||
|
superToken,
|
||||||
|
{
|
||||||
|
shortName: 'HJ3D',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(editEnded.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import {
|
||||||
|
baseFieldSchema,
|
||||||
|
type CustomerDetail,
|
||||||
|
type CustomerField,
|
||||||
|
type CustomerGroupDetail,
|
||||||
|
type CustomerGroupSummary,
|
||||||
|
customerPreferencesSchema,
|
||||||
|
type CustomerStatus,
|
||||||
|
type CustomerSummary,
|
||||||
|
type IndustryNode,
|
||||||
|
type IndustryStatus,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import type { CustomerFieldStatus, IndustryStatus as PrismaIndustryStatus } from '@prisma/client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export function iso(value: Date): string {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoOrNull(value: Date | null): string | null {
|
||||||
|
return value === null ? null : value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapIndustryStatus(value: PrismaIndustryStatus): IndustryStatus {
|
||||||
|
return value === 'ACTIVE' ? 'active' : 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapCustomerStatus(value: string): CustomerStatus {
|
||||||
|
if (value === 'PAUSED') return 'paused';
|
||||||
|
if (value === 'ENDED') return 'ended';
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFieldStatus(value: CustomerFieldStatus): CustomerField['status'] {
|
||||||
|
if (value === 'ACTIVE') return 'active';
|
||||||
|
if (value === 'REJECTED') return 'rejected';
|
||||||
|
if (value === 'DISABLED') return 'disabled';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndustryRow {
|
||||||
|
id: string;
|
||||||
|
parentId: string | null;
|
||||||
|
level: number;
|
||||||
|
name: string;
|
||||||
|
sortOrder: number;
|
||||||
|
status: PrismaIndustryStatus;
|
||||||
|
remark: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toIndustryNode(
|
||||||
|
row: IndustryRow,
|
||||||
|
customerCount: number,
|
||||||
|
assetCount = 0,
|
||||||
|
): IndustryNode {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
parentId: row.parentId,
|
||||||
|
level: row.level === 2 ? 2 : 1,
|
||||||
|
name: row.name,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
status: mapIndustryStatus(row.status),
|
||||||
|
remark: row.remark,
|
||||||
|
customerCount,
|
||||||
|
assetCount,
|
||||||
|
updatedAt: iso(row.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerSummaryRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shortName: string;
|
||||||
|
industryId: string;
|
||||||
|
status: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
industry: { name: string; parent: { name: string } | null };
|
||||||
|
owner: { name: string };
|
||||||
|
customFields: unknown;
|
||||||
|
groupMembers: { group: { id: string; name: string } }[];
|
||||||
|
fields: { entersGeneration: boolean; status: CustomerFieldStatus }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function industryPath(row: Pick<CustomerSummaryRow, 'industry'>): string {
|
||||||
|
return row.industry.parent === null
|
||||||
|
? row.industry.name
|
||||||
|
: `${row.industry.parent.name} / ${row.industry.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genFieldCount(row: Pick<CustomerSummaryRow, 'fields'>): number {
|
||||||
|
return row.fields.filter((field) => field.entersGeneration && field.status !== 'REJECTED').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseFieldsSchema = z.array(baseFieldSchema);
|
||||||
|
|
||||||
|
export function toCustomerSummary(row: CustomerSummaryRow): CustomerSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
shortName: row.shortName,
|
||||||
|
industryId: row.industryId,
|
||||||
|
industryName: row.industry.name,
|
||||||
|
industryPath: industryPath(row),
|
||||||
|
status: mapCustomerStatus(row.status),
|
||||||
|
ownerUserId: row.ownerUserId,
|
||||||
|
ownerName: row.owner.name,
|
||||||
|
groups: row.groupMembers.map((member) => ({ id: member.group.id, name: member.group.name })),
|
||||||
|
baseFieldCount: parseBaseFields(row.customFields).length,
|
||||||
|
genFieldCount: genFieldCount(row),
|
||||||
|
updatedAt: iso(row.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerDetailRow extends CustomerSummaryRow {
|
||||||
|
contactName: string | null;
|
||||||
|
contactPhone: string | null;
|
||||||
|
preferences: unknown;
|
||||||
|
customFields: unknown;
|
||||||
|
remark: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
fields: CustomerFieldRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerFieldRow {
|
||||||
|
id: string;
|
||||||
|
customerId: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
options: unknown;
|
||||||
|
purpose: string;
|
||||||
|
entersGeneration: boolean;
|
||||||
|
status: CustomerFieldStatus;
|
||||||
|
applicant: { name: string };
|
||||||
|
approver: { name: string } | null;
|
||||||
|
appliedAt: Date;
|
||||||
|
approvedAt: Date | null;
|
||||||
|
rejectReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldTypeSchema = z.enum(['text', 'number', 'date', 'select']);
|
||||||
|
const optionsSchema = z.array(z.string());
|
||||||
|
|
||||||
|
export function toCustomerField(row: CustomerFieldRow): CustomerField {
|
||||||
|
const options = optionsSchema.safeParse(row.options);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
customerId: row.customerId,
|
||||||
|
name: row.name,
|
||||||
|
code: row.code,
|
||||||
|
type: fieldTypeSchema.safeParse(row.type).success ? fieldTypeSchema.parse(row.type) : 'text',
|
||||||
|
options: options.success ? options.data : [],
|
||||||
|
purpose: row.purpose,
|
||||||
|
entersGeneration: row.entersGeneration,
|
||||||
|
status: mapFieldStatus(row.status),
|
||||||
|
applicantName: row.applicant.name,
|
||||||
|
approverName: row.approver?.name ?? null,
|
||||||
|
appliedAt: iso(row.appliedAt),
|
||||||
|
approvedAt: isoOrNull(row.approvedAt),
|
||||||
|
rejectReason: row.rejectReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBaseFields(value: unknown): CustomerDetail['baseFields'] {
|
||||||
|
const parsed = baseFieldsSchema.safeParse(value);
|
||||||
|
return parsed.success ? parsed.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePreferences(value: unknown): CustomerDetail['preferences'] {
|
||||||
|
const parsed = customerPreferencesSchema.safeParse(value);
|
||||||
|
return parsed.success ? parsed.data : { naming: '', directory: '', specs: [], taboo: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCustomerDetail(row: CustomerDetailRow): CustomerDetail {
|
||||||
|
const baseFields = parseBaseFields(row.customFields);
|
||||||
|
return {
|
||||||
|
...toCustomerSummary({
|
||||||
|
...row,
|
||||||
|
fields: row.fields,
|
||||||
|
}),
|
||||||
|
baseFieldCount: baseFields.length,
|
||||||
|
contactName: row.contactName,
|
||||||
|
contactPhone: row.contactPhone,
|
||||||
|
preferences: parsePreferences(row.preferences),
|
||||||
|
baseFields,
|
||||||
|
fields: row.fields.map((field) => toCustomerField(field)),
|
||||||
|
remark: row.remark,
|
||||||
|
createdAt: iso(row.createdAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerGroupRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
_count: { members: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCustomerGroupSummary(row: CustomerGroupRow): CustomerGroupSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
memberCount: row._count.members,
|
||||||
|
updatedAt: iso(row.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerGroupMemberRow {
|
||||||
|
customer: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shortName: string;
|
||||||
|
status: string;
|
||||||
|
industry: { name: string; parent: { name: string } | null };
|
||||||
|
owner: { name: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCustomerGroupDetail(
|
||||||
|
row: CustomerGroupRow,
|
||||||
|
members: CustomerGroupMemberRow[],
|
||||||
|
): CustomerGroupDetail {
|
||||||
|
return {
|
||||||
|
...toCustomerGroupSummary(row),
|
||||||
|
members: members.map((member) => ({
|
||||||
|
id: member.customer.id,
|
||||||
|
name: member.customer.name,
|
||||||
|
shortName: member.customer.shortName,
|
||||||
|
industryPath:
|
||||||
|
member.customer.industry.parent === null
|
||||||
|
? member.customer.industry.name
|
||||||
|
: `${member.customer.industry.parent.name} / ${member.customer.industry.name}`,
|
||||||
|
status: mapCustomerStatus(member.customer.status),
|
||||||
|
ownerName: member.customer.owner.name,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CUSTOMER_DIRECTORY } from '../../common/security/scope/customer-directory';
|
||||||
|
import {
|
||||||
|
CustomerFieldsController,
|
||||||
|
CustomerFieldsOfCustomerController,
|
||||||
|
} from './customer-fields.controller';
|
||||||
|
import { CustomerFieldsService } from './customer-fields.service';
|
||||||
|
import { CustomerGroupsController } from './customer-groups.controller';
|
||||||
|
import { CustomerGroupsService } from './customer-groups.service';
|
||||||
|
import { CustomersController, CustomerTransferController } from './customers.controller';
|
||||||
|
import { CustomersService } from './customers.service';
|
||||||
|
import { IndustriesController } from './industries.controller';
|
||||||
|
import { IndustriesService } from './industries.service';
|
||||||
|
import { PrismaCustomerDirectory } from './prisma-customer-directory.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* client 模块(A3):两级行业、客户档案与状态机、客户组、客户级字段。
|
||||||
|
*
|
||||||
|
* 全局导出 `CUSTOMER_DIRECTORY` 真实实现,供 SecurityModule 的 ScopeResolver 解析六类
|
||||||
|
* 数据范围;client 本身不反向依赖 security 模块,避免循环依赖。
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
controllers: [
|
||||||
|
IndustriesController,
|
||||||
|
CustomersController,
|
||||||
|
CustomerTransferController,
|
||||||
|
CustomerGroupsController,
|
||||||
|
CustomerFieldsController,
|
||||||
|
CustomerFieldsOfCustomerController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
IndustriesService,
|
||||||
|
CustomersService,
|
||||||
|
CustomerGroupsService,
|
||||||
|
CustomerFieldsService,
|
||||||
|
PrismaCustomerDirectory,
|
||||||
|
{ provide: CUSTOMER_DIRECTORY, useExisting: PrismaCustomerDirectory },
|
||||||
|
],
|
||||||
|
exports: [CUSTOMER_DIRECTORY, IndustriesService, CustomersService],
|
||||||
|
})
|
||||||
|
export class ClientModule {}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
type ApplyCustomerFieldInput,
|
||||||
|
applyCustomerFieldInputSchema,
|
||||||
|
type ApproveCustomerFieldInput,
|
||||||
|
approveCustomerFieldInputSchema,
|
||||||
|
type CustomerField,
|
||||||
|
type CustomerFieldListItem,
|
||||||
|
type CustomerFieldListQuery,
|
||||||
|
customerFieldListQuerySchema,
|
||||||
|
ErrorCode,
|
||||||
|
type ListResponse,
|
||||||
|
Permission,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Body, Controller, Get, HttpCode, Inject, Param, Post, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||||
|
import type { AuthContext } from '../../common/security/auth.types';
|
||||||
|
import {
|
||||||
|
Audit,
|
||||||
|
CurrentScope,
|
||||||
|
CurrentUser,
|
||||||
|
RequirePermissions,
|
||||||
|
} from '../../common/security/decorators';
|
||||||
|
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||||
|
import { CustomerFieldsService } from './customer-fields.service';
|
||||||
|
|
||||||
|
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||||
|
if (auth === null) throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('client-customer-fields')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('customer-fields')
|
||||||
|
export class CustomerFieldsController {
|
||||||
|
constructor(@Inject(CustomerFieldsService) private readonly fields: CustomerFieldsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_FIELD_APPROVE, Permission.CUSTOMER_READ)
|
||||||
|
@ApiOperation({ summary: '字段审批队列(待审批 / 已生效 / 已驳回)' })
|
||||||
|
list(
|
||||||
|
@Query(new ZodValidationPipe(customerFieldListQuerySchema)) query: CustomerFieldListQuery,
|
||||||
|
@CurrentScope() scope: ScopeContext | null,
|
||||||
|
): Promise<ListResponse<CustomerFieldListItem>> {
|
||||||
|
return this.fields.list(query, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approve')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_FIELD_APPROVE)
|
||||||
|
@Audit('client.customer_field.approve')
|
||||||
|
@ApiOperation({ summary: '审批客户级字段(通过 / 驳回;驳回原因必填)' })
|
||||||
|
approve(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body(new ZodValidationPipe(approveCustomerFieldInputSchema)) input: ApproveCustomerFieldInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
@CurrentScope() scope: ScopeContext | null,
|
||||||
|
): Promise<CustomerField> {
|
||||||
|
return this.fields.approve(id, input, requireAuth(auth).userId, scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('client-customer-fields')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('customers')
|
||||||
|
export class CustomerFieldsOfCustomerController {
|
||||||
|
constructor(@Inject(CustomerFieldsService) private readonly fields: CustomerFieldsService) {}
|
||||||
|
|
||||||
|
@Get(':id/fields')
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||||
|
@ApiOperation({ summary: '客户级字段列表(基础字段在 customers.custom_fields)' })
|
||||||
|
list(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentScope() scope: ScopeContext | null,
|
||||||
|
): Promise<CustomerField[]> {
|
||||||
|
return this.fields.listByCustomer(id, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/fields')
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_FIELD_APPLY)
|
||||||
|
@Audit('client.customer_field.apply')
|
||||||
|
@ApiOperation({ summary: '申请客户级字段(进入生成字段需审批;每客户 ≤ 5)' })
|
||||||
|
apply(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body(new ZodValidationPipe(applyCustomerFieldInputSchema)) input: ApplyCustomerFieldInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
@CurrentScope() scope: ScopeContext | null,
|
||||||
|
): Promise<CustomerField> {
|
||||||
|
return this.fields.apply(id, input, requireAuth(auth).userId, scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 申请 / 审批共用字段状态与上限校验,集中便于审计 */
|
||||||
|
import {
|
||||||
|
type ApplyCustomerFieldInput,
|
||||||
|
type ApproveCustomerFieldInput,
|
||||||
|
CUSTOMER_GEN_FIELD_LIMIT,
|
||||||
|
type CustomerField,
|
||||||
|
type CustomerFieldListItem,
|
||||||
|
type CustomerFieldListQuery,
|
||||||
|
ErrorCode,
|
||||||
|
type ListResponse,
|
||||||
|
ulid,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||||
|
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
||||||
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
import { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||||
|
import { type CustomerFieldRow, toCustomerField } from './client.mapper';
|
||||||
|
|
||||||
|
const FIELD_INCLUDE = {
|
||||||
|
applicant: { select: { name: true } },
|
||||||
|
approver: { select: { name: true } },
|
||||||
|
} satisfies Prisma.CustomerFieldInclude;
|
||||||
|
|
||||||
|
const FIELD_LIST_INCLUDE = {
|
||||||
|
...FIELD_INCLUDE,
|
||||||
|
customer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
shortName: true,
|
||||||
|
industry: { include: { parent: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.CustomerFieldInclude;
|
||||||
|
|
||||||
|
function industryPath(customer: {
|
||||||
|
industry: { name: string; parent: { name: string } | null };
|
||||||
|
}): string {
|
||||||
|
return customer.industry.parent === null
|
||||||
|
? customer.industry.name
|
||||||
|
: `${customer.industry.parent.name} / ${customer.industry.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户级字段(A3-BE-04):基础字段走 customers.custom_fields(≤10,免审批);
|
||||||
|
* 本表只承载进入生成 / 需要审批的字段(≤5)。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CustomerFieldsService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
|
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
query: CustomerFieldListQuery,
|
||||||
|
scopeInput: ScopeContext | null,
|
||||||
|
): Promise<ListResponse<CustomerFieldListItem>> {
|
||||||
|
const scope = requireScopeContext(scopeInput);
|
||||||
|
const rows = await this.prisma.customerField.findMany({
|
||||||
|
where: {
|
||||||
|
customer: {
|
||||||
|
deletedAt: null,
|
||||||
|
...(scope.customerIds === null ? {} : { id: { in: [...scope.customerIds] } }),
|
||||||
|
},
|
||||||
|
...(query.customer_id === undefined ? {} : { customerId: query.customer_id }),
|
||||||
|
...(query.status === 'all'
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
status:
|
||||||
|
query.status === 'pending'
|
||||||
|
? 'PENDING'
|
||||||
|
: query.status === 'active'
|
||||||
|
? 'ACTIVE'
|
||||||
|
: query.status === 'rejected'
|
||||||
|
? 'REJECTED'
|
||||||
|
: 'DISABLED',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
include: FIELD_LIST_INCLUDE,
|
||||||
|
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
|
||||||
|
take: query.limit + 1,
|
||||||
|
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
|
||||||
|
});
|
||||||
|
const page = rows.slice(0, query.limit);
|
||||||
|
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
|
||||||
|
return {
|
||||||
|
items: page.map((row) => ({
|
||||||
|
...toCustomerField(row as CustomerFieldRow),
|
||||||
|
customerName: row.customer.name,
|
||||||
|
customerShortName: row.customer.shortName,
|
||||||
|
industryPath: industryPath(row.customer),
|
||||||
|
})),
|
||||||
|
next_cursor: next,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listByCustomer(
|
||||||
|
customerId: string,
|
||||||
|
scopeInput: ScopeContext | null,
|
||||||
|
): Promise<CustomerField[]> {
|
||||||
|
const scope = requireScopeContext(scopeInput);
|
||||||
|
if (!isCustomerVisible(scope, customerId)) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||||
|
}
|
||||||
|
const rows = await this.prisma.customerField.findMany({
|
||||||
|
where: { customerId, customer: { deletedAt: null } },
|
||||||
|
include: FIELD_INCLUDE,
|
||||||
|
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
|
||||||
|
});
|
||||||
|
return rows.map((row) => toCustomerField(row as CustomerFieldRow));
|
||||||
|
}
|
||||||
|
|
||||||
|
async apply(
|
||||||
|
customerId: string,
|
||||||
|
input: ApplyCustomerFieldInput,
|
||||||
|
actorId: string,
|
||||||
|
scopeInput: ScopeContext | null,
|
||||||
|
): Promise<CustomerField> {
|
||||||
|
const scope = requireScopeContext(scopeInput);
|
||||||
|
if (!isCustomerVisible(scope, customerId)) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||||
|
}
|
||||||
|
const customer = await this.prisma.customer.findFirst({
|
||||||
|
where: { id: customerId, deletedAt: null },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
});
|
||||||
|
if (customer === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||||
|
}
|
||||||
|
if (customer.status !== 'ACTIVE') {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||||
|
message: customer.status === 'PAUSED' ? '客户已暂停,字段只读' : '客户已结束,字段只读归档',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const duplicate = await this.prisma.customerField.findUnique({
|
||||||
|
where: { customerId_code: { customerId, code: input.code } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (duplicate !== null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
message: '该客户下字段编码已存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.entersGeneration) {
|
||||||
|
const used = await this.genUsedCount(customerId);
|
||||||
|
await this.assertGenFieldLimit(used);
|
||||||
|
}
|
||||||
|
const id = ulid();
|
||||||
|
await this.prisma.customerField.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
customerId,
|
||||||
|
name: input.name,
|
||||||
|
code: input.code,
|
||||||
|
type: input.type,
|
||||||
|
options: input.options,
|
||||||
|
purpose: input.purpose,
|
||||||
|
entersGeneration: input.entersGeneration,
|
||||||
|
status: input.entersGeneration ? 'PENDING' : 'ACTIVE',
|
||||||
|
applicantUserId: actorId,
|
||||||
|
...(input.entersGeneration ? {} : { approvedAt: new Date(), approverUserId: actorId }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const row = await this.prisma.customerField.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: FIELD_INCLUDE,
|
||||||
|
});
|
||||||
|
return toCustomerField(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(
|
||||||
|
id: string,
|
||||||
|
input: ApproveCustomerFieldInput,
|
||||||
|
actorId: string,
|
||||||
|
scopeInput: ScopeContext | null,
|
||||||
|
): Promise<CustomerField> {
|
||||||
|
const scope = requireScopeContext(scopeInput);
|
||||||
|
const existing = await this.prisma.customerField.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { customer: { select: { id: true, deletedAt: true } } },
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- 保留显式 null 分支,跨客户统一 404
|
||||||
|
if (existing === null || existing.customer.deletedAt !== null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' });
|
||||||
|
}
|
||||||
|
if (!isCustomerVisible(scope, existing.customerId)) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' });
|
||||||
|
}
|
||||||
|
if (existing.status !== 'PENDING') {
|
||||||
|
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '该申请已处理' });
|
||||||
|
}
|
||||||
|
if (input.decision === 'approve' && existing.entersGeneration) {
|
||||||
|
const used = await this.genUsedCount(existing.customerId, id);
|
||||||
|
await this.assertGenFieldLimit(used, '审批后仍超限');
|
||||||
|
}
|
||||||
|
await this.prisma.customerField.update({
|
||||||
|
where: { id },
|
||||||
|
data:
|
||||||
|
input.decision === 'approve'
|
||||||
|
? {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
approverUserId: actorId,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
rejectReason: null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
approverUserId: actorId,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
rejectReason: input.reason ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const row = await this.prisma.customerField.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: FIELD_INCLUDE,
|
||||||
|
});
|
||||||
|
return toCustomerField(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进入生成字段上限读 system_configs(R-26);shared 常量仅作读失败兜底(默认 5) */
|
||||||
|
private async assertGenFieldLimit(used: number, hint = '请先停用或驳回其他字段'): Promise<void> {
|
||||||
|
const limit = await this.systemConfig.getNumber(
|
||||||
|
'limit.customer.gen_field',
|
||||||
|
CUSTOMER_GEN_FIELD_LIMIT,
|
||||||
|
);
|
||||||
|
if (used >= limit) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: `进入生成字段已达 ${String(limit)} 个上限,${hint}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async genUsedCount(customerId: string, excludeId?: string): Promise<number> {
|
||||||
|
return this.prisma.customerField.count({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
entersGeneration: true,
|
||||||
|
status: { not: 'REJECTED' },
|
||||||
|
...(excludeId === undefined ? {} : { id: { not: excludeId } }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
type CreateCustomerGroupInput,
|
||||||
|
createCustomerGroupInputSchema,
|
||||||
|
type CustomerGroupDetail,
|
||||||
|
type CustomerGroupListQuery,
|
||||||
|
customerGroupListQuerySchema,
|
||||||
|
type CustomerGroupSummary,
|
||||||
|
type ListResponse,
|
||||||
|
Permission,
|
||||||
|
type UpdateCustomerGroupInput,
|
||||||
|
updateCustomerGroupInputSchema,
|
||||||
|
type UpdateCustomerGroupMembersInput,
|
||||||
|
updateCustomerGroupMembersInputSchema,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||||
|
import { Audit, RequirePermissions } from '../../common/security/decorators';
|
||||||
|
import { CustomerGroupsService } from './customer-groups.service';
|
||||||
|
|
||||||
|
@ApiTags('client-customer-groups')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('customer-groups')
|
||||||
|
export class CustomerGroupsController {
|
||||||
|
constructor(@Inject(CustomerGroupsService) private readonly groups: CustomerGroupsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||||
|
@ApiOperation({ summary: '客户组列表(授权与数据范围最小单位)' })
|
||||||
|
list(
|
||||||
|
@Query(new ZodValidationPipe(customerGroupListQuerySchema)) query: CustomerGroupListQuery,
|
||||||
|
): Promise<ListResponse<CustomerGroupSummary>> {
|
||||||
|
return this.groups.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||||
|
@ApiOperation({ summary: '客户组详情与成员' })
|
||||||
|
detail(@Param('id') id: string): Promise<CustomerGroupDetail> {
|
||||||
|
return this.groups.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||||
|
@Audit('client.customer_group.create')
|
||||||
|
@ApiOperation({ summary: '新建客户组' })
|
||||||
|
create(
|
||||||
|
@Body(new ZodValidationPipe(createCustomerGroupInputSchema)) input: CreateCustomerGroupInput,
|
||||||
|
): Promise<CustomerGroupDetail> {
|
||||||
|
return this.groups.create(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||||
|
@Audit('client.customer_group.update')
|
||||||
|
@ApiOperation({ summary: '更新客户组名称 / 说明' })
|
||||||
|
update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body(new ZodValidationPipe(updateCustomerGroupInputSchema)) input: UpdateCustomerGroupInput,
|
||||||
|
): Promise<CustomerGroupDetail> {
|
||||||
|
return this.groups.update(id, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||||
|
@Audit('client.customer_group.delete')
|
||||||
|
@ApiOperation({ summary: '删除客户组(被角色数据范围引用时拒绝)' })
|
||||||
|
async remove(@Param('id') id: string): Promise<void> {
|
||||||
|
await this.groups.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/members')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||||
|
@Audit('client.customer_group.members')
|
||||||
|
@ApiOperation({ summary: '批量添加 / 移除成员(幂等)' })
|
||||||
|
updateMembers(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body(new ZodValidationPipe(updateCustomerGroupMembersInputSchema))
|
||||||
|
input: UpdateCustomerGroupMembersInput,
|
||||||
|
): Promise<CustomerGroupDetail> {
|
||||||
|
return this.groups.updateMembers(id, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import {
|
||||||
|
type CreateCustomerGroupInput,
|
||||||
|
type CustomerGroupDetail,
|
||||||
|
type CustomerGroupListQuery,
|
||||||
|
type CustomerGroupSummary,
|
||||||
|
ErrorCode,
|
||||||
|
type ListResponse,
|
||||||
|
ulid,
|
||||||
|
type UpdateCustomerGroupInput,
|
||||||
|
type UpdateCustomerGroupMembersInput,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
import {
|
||||||
|
type CustomerGroupRow,
|
||||||
|
toCustomerGroupDetail,
|
||||||
|
toCustomerGroupSummary,
|
||||||
|
} from './client.mapper';
|
||||||
|
|
||||||
|
const GROUP_INCLUDE = {
|
||||||
|
_count: { select: { members: true } },
|
||||||
|
} satisfies Prisma.CustomerGroupInclude;
|
||||||
|
|
||||||
|
const GROUP_MEMBER_INCLUDE = {
|
||||||
|
customer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
shortName: true,
|
||||||
|
status: true,
|
||||||
|
industry: { include: { parent: true } },
|
||||||
|
owner: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.CustomerGroupMemberInclude;
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 客户组(A3-BE-03):授权与数据范围最小单位;成员增删幂等。 */
|
||||||
|
@Injectable()
|
||||||
|
export class CustomerGroupsService {
|
||||||
|
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(query: CustomerGroupListQuery): Promise<ListResponse<CustomerGroupSummary>> {
|
||||||
|
const rows = await this.prisma.customerGroup.findMany({
|
||||||
|
where:
|
||||||
|
query.keyword === undefined
|
||||||
|
? {}
|
||||||
|
: { name: { contains: query.keyword, mode: 'insensitive' } },
|
||||||
|
include: GROUP_INCLUDE,
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
take: query.limit + 1,
|
||||||
|
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
|
||||||
|
});
|
||||||
|
const page = rows.slice(0, query.limit);
|
||||||
|
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
|
||||||
|
return {
|
||||||
|
items: page.map((row) => toCustomerGroupSummary(row as CustomerGroupRow)),
|
||||||
|
next_cursor: next,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: string): Promise<CustomerGroupDetail> {
|
||||||
|
const row = await this.findByIdOrThrow(id);
|
||||||
|
const members = await this.prisma.customerGroupMember.findMany({
|
||||||
|
where: { groupId: id },
|
||||||
|
include: GROUP_MEMBER_INCLUDE,
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
return toCustomerGroupDetail(row, members);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateCustomerGroupInput): Promise<CustomerGroupDetail> {
|
||||||
|
await this.assertNameUnique(input.name, null);
|
||||||
|
const id = ulid();
|
||||||
|
try {
|
||||||
|
await this.prisma.customerGroup.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '客户组名称已存在' });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (input.customerIds !== undefined && input.customerIds.length > 0) {
|
||||||
|
await this.addMembers(id, input.customerIds);
|
||||||
|
}
|
||||||
|
return this.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, input: UpdateCustomerGroupInput): Promise<CustomerGroupDetail> {
|
||||||
|
await this.findByIdOrThrow(id);
|
||||||
|
if (input.name !== undefined) await this.assertNameUnique(input.name, id);
|
||||||
|
await this.prisma.customerGroup.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(input.name === undefined ? {} : { name: input.name }),
|
||||||
|
...(input.description === undefined ? {} : { description: input.description }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await this.findByIdOrThrow(id);
|
||||||
|
const referenced = await this.prisma.dataScope.count({ where: { targetId: id } });
|
||||||
|
if (referenced > 0) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||||
|
message: '该客户组已被角色数据范围引用,无法删除;请先调整角色范围',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.prisma.customerGroup.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMembers(
|
||||||
|
id: string,
|
||||||
|
input: UpdateCustomerGroupMembersInput,
|
||||||
|
): Promise<CustomerGroupDetail> {
|
||||||
|
await this.findByIdOrThrow(id);
|
||||||
|
if (input.addCustomerIds.length > 0) await this.assertCustomersExist(input.addCustomerIds);
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.customerGroupMember.deleteMany({
|
||||||
|
where: { groupId: id, customerId: { in: [...new Set(input.removeCustomerIds)] } },
|
||||||
|
}),
|
||||||
|
this.prisma.customerGroupMember.createMany({
|
||||||
|
data: [...new Set(input.addCustomerIds)].map((customerId) => ({
|
||||||
|
groupId: id,
|
||||||
|
customerId,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
}),
|
||||||
|
this.prisma.customerGroup.update({ where: { id }, data: { updatedAt: new Date() } }),
|
||||||
|
]);
|
||||||
|
return this.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async addMembers(id: string, customerIds: readonly string[]): Promise<void> {
|
||||||
|
await this.assertCustomersExist(customerIds);
|
||||||
|
await this.prisma.customerGroupMember.createMany({
|
||||||
|
data: [...new Set(customerIds)].map((customerId) => ({ groupId: id, customerId })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCustomersExist(customerIds: readonly string[]): Promise<void> {
|
||||||
|
const found = await this.prisma.customer.findMany({
|
||||||
|
where: { id: { in: [...new Set(customerIds)] }, deletedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (found.length !== new Set(customerIds).size) {
|
||||||
|
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '存在不存在的客户' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findByIdOrThrow(id: string): Promise<CustomerGroupRow> {
|
||||||
|
const row = await this.prisma.customerGroup.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: GROUP_INCLUDE,
|
||||||
|
});
|
||||||
|
if (row === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户组不存在' });
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertNameUnique(name: string, excludeId: string | null): Promise<void> {
|
||||||
|
const duplicate = await this.prisma.customerGroup.findFirst({
|
||||||
|
where: { name, ...(excludeId === null ? {} : { id: { not: excludeId } }) },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (duplicate !== null) {
|
||||||
|
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '客户组名称已存在' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import {
|
||||||
|
type CreateCustomerInput,
|
||||||
|
createCustomerInputSchema,
|
||||||
|
type CustomerDetail,
|
||||||
|
type CustomerListQuery,
|
||||||
|
customerListQuerySchema,
|
||||||
|
type CustomerSummary,
|
||||||
|
ErrorCode,
|
||||||
|
type ListResponse,
|
||||||
|
Permission,
|
||||||
|
type SetCustomerGroupsInput,
|
||||||
|
setCustomerGroupsInputSchema,
|
||||||
|
type SetCustomerStatusInput,
|
||||||
|
setCustomerStatusInputSchema,
|
||||||
|
type TransferCustomerInput,
|
||||||
|
transferCustomerInputSchema,
|
||||||
|
type TransferCustomersInput,
|
||||||
|
transferCustomersInputSchema,
|
||||||
|
type UpdateCustomerInput,
|
||||||
|
updateCustomerInputSchema,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||||
|
import type { AuthContext } from '../../common/security/auth.types';
|
||||||
|
import {
|
||||||
|
Audit,
|
||||||
|
CurrentScope,
|
||||||
|
CurrentUser,
|
||||||
|
RequirePermissions,
|
||||||
|
} from '../../common/security/decorators';
|
||||||
|
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||||
|
import { CustomersService } from './customers.service';
|
||||||
|
|
||||||
|
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||||
|
if (auth === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('client-customers')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('customers')
|
||||||
|
export class CustomersController {
|
||||||
|
constructor(@Inject(CustomersService) private readonly customers: CustomersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||||
|
@ApiOperation({ summary: '客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选)' })
|
||||||
|
list(
|
||||||
|
@Query(new ZodValidationPipe(customerListQuerySchema)) query: CustomerListQuery,
|
||||||
|
@CurrentScope() scope: ScopeContext | null,
|
||||||
|
): Promise<ListResponse<CustomerSummary>> {
|
||||||
|
return this.customers.list(query, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||||
|
@ApiOperation({ summary: '客户详情(跨客户访问统一 404)' })
|
||||||
|
detail(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentScope() scope: ScopeContext | null,
|
||||||
|
): Promise<CustomerDetail> {
|
||||||
|
return this.customers.detail(id, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||||
|
@Audit('client.customer.create')
|
||||||
|
@ApiOperation({ summary: '新建客户(简称必填;行业必须为末级行业)' })
|
||||||
|
create(
|
||||||
|
@Body(new ZodValidationPipe(createCustomerInputSchema)) input: CreateCustomerInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
@CurrentScope() scope: ScopeContext | null,
|
||||||
|
): Promise<CustomerDetail> {
|
||||||
|
return this.customers.create(input, requireAuth(auth).userId, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||||
|
@Audit('client.customer.update')
|
||||||
|
@ApiOperation({ summary: '更新客户档案(所属行业不可改;暂停 / 结束只读)' })
|
||||||
|
update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body(new ZodValidationPipe(updateCustomerInputSchema)) input: UpdateCustomerInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
@CurrentScope() scope: ScopeContext | null,
|
||||||
|
): Promise<CustomerDetail> {
|
||||||
|
return this.customers.update(id, input, scope, requireAuth(auth).permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||||
|
@Audit('client.customer.status')
|
||||||
|
@ApiOperation({ summary: '客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆)' })
|
||||||
|
setStatus(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body(new ZodValidationPipe(setCustomerStatusInputSchema)) input: SetCustomerStatusInput,
|
||||||
|
@CurrentScope() scope: ScopeContext | null,
|
||||||
|
): Promise<CustomerDetail> {
|
||||||
|
return this.customers.setStatus(id, input, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/transfer')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_TRANSFER)
|
||||||
|
@Audit('client.customer.transfer')
|
||||||
|
@ApiOperation({ summary: '变更客户负责人(写审计)' })
|
||||||
|
transfer(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body(new ZodValidationPipe(transferCustomerInputSchema)) input: TransferCustomerInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
@CurrentScope() scope: ScopeContext | null,
|
||||||
|
): Promise<CustomerDetail> {
|
||||||
|
return this.customers.transfer(id, input, scope, requireAuth(auth).permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/groups')
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE, Permission.CUSTOMER_MANAGE)
|
||||||
|
@Audit('client.customer.groups')
|
||||||
|
@ApiOperation({ summary: '设置客户所属客户组(覆盖式)' })
|
||||||
|
setGroups(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body(new ZodValidationPipe(setCustomerGroupsInputSchema)) input: SetCustomerGroupsInput,
|
||||||
|
@CurrentScope() scope: ScopeContext | null,
|
||||||
|
): Promise<CustomerDetail> {
|
||||||
|
return this.customers.setGroups(id, input, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/apply-industry-pack')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||||
|
@Audit('client.customer.apply_industry_pack')
|
||||||
|
@ApiOperation({ summary: '套用行业资产包(B0 批次提供;当前返回业务提示)' })
|
||||||
|
applyIndustryPack(): never {
|
||||||
|
return this.customers.applyIndustryPack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户停用前的客户转交:保留 A1 路径 /users/{id}/transfer-customers,由 client 模块落地 */
|
||||||
|
@ApiTags('client-customers')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('users')
|
||||||
|
export class CustomerTransferController {
|
||||||
|
constructor(@Inject(CustomersService) private readonly customers: CustomersService) {}
|
||||||
|
|
||||||
|
@Post(':id/transfer-customers')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_TRANSFER, Permission.USER_MANAGE)
|
||||||
|
@Audit('identity.user.transfer_customers')
|
||||||
|
@ApiOperation({ summary: '把用户名下客户转交给另一负责人' })
|
||||||
|
async transfer(
|
||||||
|
@Param('id') fromUserId: string,
|
||||||
|
@Body(new ZodValidationPipe(transferCustomersInputSchema)) input: TransferCustomersInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<{ transferred: number }> {
|
||||||
|
return this.customers.transferByUser(
|
||||||
|
fromUserId,
|
||||||
|
input.toUserId,
|
||||||
|
input.customerIds,
|
||||||
|
requireAuth(auth).permissions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 客户状态机 / 权限与字段上限集中校验,拆分会丢失事务边界 */
|
||||||
|
import {
|
||||||
|
type CreateCustomerInput,
|
||||||
|
CUSTOMER_BASE_FIELD_LIMIT,
|
||||||
|
type CustomerDetail,
|
||||||
|
type CustomerListQuery,
|
||||||
|
type CustomerSummary,
|
||||||
|
ErrorCode,
|
||||||
|
type ListResponse,
|
||||||
|
Permission,
|
||||||
|
type SetCustomerGroupsInput,
|
||||||
|
type SetCustomerStatusInput,
|
||||||
|
type TransferCustomerInput,
|
||||||
|
ulid,
|
||||||
|
type UpdateCustomerInput,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||||
|
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
||||||
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
import { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||||
|
import { type CustomerSummaryRow, toCustomerDetail, toCustomerSummary } from './client.mapper';
|
||||||
|
import { IndustriesService } from './industries.service';
|
||||||
|
|
||||||
|
const CUSTOMER_INCLUDE = {
|
||||||
|
industry: { include: { parent: true } },
|
||||||
|
owner: { select: { name: true } },
|
||||||
|
groupMembers: { include: { group: true } },
|
||||||
|
fields: { select: { entersGeneration: true, status: true } },
|
||||||
|
} satisfies Prisma.CustomerInclude;
|
||||||
|
|
||||||
|
const CUSTOMER_DETAIL_INCLUDE = {
|
||||||
|
...CUSTOMER_INCLUDE,
|
||||||
|
fields: {
|
||||||
|
include: { applicant: { select: { name: true } }, approver: { select: { name: true } } },
|
||||||
|
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
|
||||||
|
},
|
||||||
|
} satisfies Prisma.CustomerInclude;
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeWhere(scope: ScopeContext): Prisma.CustomerWhereInput {
|
||||||
|
return scope.customerIds === null ? {} : { id: { in: [...scope.customerIds] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPermission(permissions: readonly string[], code: string): boolean {
|
||||||
|
return permissions.includes(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户档案(A3-BE-02 / A3-BE-05):状态机、负责人 / 对接人、交付偏好、
|
||||||
|
* 客户组归属、客户转交;所有读写都带 ScopeContext 客户维度。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CustomersService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
|
@Inject(IndustriesService) private readonly industries: IndustriesService,
|
||||||
|
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
query: CustomerListQuery,
|
||||||
|
scopeInput: ScopeContext | null,
|
||||||
|
): Promise<ListResponse<CustomerSummary>> {
|
||||||
|
const scope = requireScopeContext(scopeInput);
|
||||||
|
const industryIds =
|
||||||
|
query.industry_id === undefined
|
||||||
|
? undefined
|
||||||
|
: await this.industrySubtreeIds(query.industry_id);
|
||||||
|
const rows = await this.prisma.customer.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
...scopeWhere(scope),
|
||||||
|
...(query.keyword === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: query.keyword, mode: 'insensitive' } },
|
||||||
|
{ shortName: { contains: query.keyword, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
...(industryIds === undefined ? {} : { industryId: { in: industryIds } }),
|
||||||
|
...(query.status === 'all'
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
status:
|
||||||
|
query.status === 'active'
|
||||||
|
? 'ACTIVE'
|
||||||
|
: query.status === 'paused'
|
||||||
|
? 'PAUSED'
|
||||||
|
: 'ENDED',
|
||||||
|
}),
|
||||||
|
...(query.owner_user_id === undefined ? {} : { ownerUserId: query.owner_user_id }),
|
||||||
|
...(query.group_id === undefined
|
||||||
|
? {}
|
||||||
|
: { groupMembers: { some: { groupId: query.group_id } } }),
|
||||||
|
},
|
||||||
|
include: CUSTOMER_INCLUDE,
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: query.limit + 1,
|
||||||
|
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
|
||||||
|
});
|
||||||
|
const page = rows.slice(0, query.limit);
|
||||||
|
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
|
||||||
|
return {
|
||||||
|
items: page.map((row) => toCustomerSummary(row as CustomerSummaryRow)),
|
||||||
|
next_cursor: next,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: string, scopeInput: ScopeContext | null): Promise<CustomerDetail> {
|
||||||
|
return this.findVisibleOrThrow(id, requireScopeContext(scopeInput));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
input: CreateCustomerInput,
|
||||||
|
actorId: string,
|
||||||
|
scopeInput: ScopeContext | null,
|
||||||
|
): Promise<CustomerDetail> {
|
||||||
|
const scope = requireScopeContext(scopeInput);
|
||||||
|
await this.industries.assertLeafIndustry(input.industryId);
|
||||||
|
await this.ensureOwner(input.ownerUserId);
|
||||||
|
const baseFields = input.baseFields ?? [];
|
||||||
|
await this.assertBaseFieldLimit(baseFields.length);
|
||||||
|
const id = ulid();
|
||||||
|
try {
|
||||||
|
await this.prisma.customer.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
industryId: input.industryId,
|
||||||
|
name: input.name,
|
||||||
|
shortName: input.shortName,
|
||||||
|
ownerUserId: input.ownerUserId,
|
||||||
|
contactName: input.contactName ?? null,
|
||||||
|
contactPhone: input.contactPhone ?? null,
|
||||||
|
preferences: input.preferences ?? {},
|
||||||
|
customFields: baseFields,
|
||||||
|
remark: input.remark ?? null,
|
||||||
|
createdBy: actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
message: '该末级行业下已存在同名客户',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return this.detail(id, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
input: UpdateCustomerInput,
|
||||||
|
scopeInput: ScopeContext | null,
|
||||||
|
permissions: readonly string[],
|
||||||
|
): Promise<CustomerDetail> {
|
||||||
|
const scope = requireScopeContext(scopeInput);
|
||||||
|
const existing = await this.findRow(id);
|
||||||
|
this.assertVisible(scope, id);
|
||||||
|
if (existing.status !== 'ACTIVE') {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||||
|
message: existing.status === 'PAUSED' ? '客户已暂停,档案只读' : '客户已结束,数据只读归档',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.ownerUserId !== undefined &&
|
||||||
|
input.ownerUserId !== existing.ownerUserId &&
|
||||||
|
!hasPermission(permissions, Permission.CUSTOMER_TRANSFER)
|
||||||
|
) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PERMISSION_DENIED,
|
||||||
|
message: '变更负责人需要 customer:transfer 权限',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.ownerUserId !== undefined && input.ownerUserId !== existing.ownerUserId) {
|
||||||
|
await this.ensureOwner(input.ownerUserId);
|
||||||
|
}
|
||||||
|
if (input.baseFields !== undefined) await this.assertBaseFieldLimit(input.baseFields.length);
|
||||||
|
if (input.name !== undefined && input.name !== existing.name) {
|
||||||
|
const duplicate = await this.prisma.customer.findFirst({
|
||||||
|
where: {
|
||||||
|
industryId: existing.industryId,
|
||||||
|
name: input.name,
|
||||||
|
id: { not: id },
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (duplicate !== null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
message: '该末级行业下已存在同名客户',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.prisma.customer.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(input.name === undefined ? {} : { name: input.name }),
|
||||||
|
...(input.shortName === undefined ? {} : { shortName: input.shortName }),
|
||||||
|
...(input.ownerUserId === undefined ? {} : { ownerUserId: input.ownerUserId }),
|
||||||
|
...(input.contactName === undefined ? {} : { contactName: input.contactName }),
|
||||||
|
...(input.contactPhone === undefined ? {} : { contactPhone: input.contactPhone }),
|
||||||
|
...(input.preferences === undefined ? {} : { preferences: input.preferences }),
|
||||||
|
...(input.baseFields === undefined ? {} : { customFields: input.baseFields }),
|
||||||
|
...(input.remark === undefined ? {} : { remark: input.remark }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.detail(id, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStatus(
|
||||||
|
id: string,
|
||||||
|
input: SetCustomerStatusInput,
|
||||||
|
scopeInput: ScopeContext | null,
|
||||||
|
): Promise<CustomerDetail> {
|
||||||
|
const scope = requireScopeContext(scopeInput);
|
||||||
|
const existing = await this.findRow(id);
|
||||||
|
this.assertVisible(scope, id);
|
||||||
|
if (existing.status === 'ENDED') {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||||
|
message: '客户已结束,不能再变更状态',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (existing.status === input.status) {
|
||||||
|
return this.detail(id, scope);
|
||||||
|
}
|
||||||
|
const target =
|
||||||
|
input.status === 'active' ? 'ACTIVE' : input.status === 'paused' ? 'PAUSED' : 'ENDED';
|
||||||
|
await this.prisma.customer.update({ where: { id }, data: { status: target } });
|
||||||
|
return this.detail(id, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async transfer(
|
||||||
|
id: string,
|
||||||
|
input: TransferCustomerInput,
|
||||||
|
scopeInput: ScopeContext | null,
|
||||||
|
permissions: readonly string[],
|
||||||
|
): Promise<CustomerDetail> {
|
||||||
|
const scope = requireScopeContext(scopeInput);
|
||||||
|
if (!hasPermission(permissions, Permission.CUSTOMER_TRANSFER)) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
|
||||||
|
message: '缺少客户转交权限',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const existing = await this.findRow(id);
|
||||||
|
this.assertVisible(scope, id);
|
||||||
|
if (existing.status === 'ENDED') {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||||
|
message: '客户已结束,不能转交',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.ensureOwner(input.ownerUserId);
|
||||||
|
await this.prisma.customer.update({ where: { id }, data: { ownerUserId: input.ownerUserId } });
|
||||||
|
return this.detail(id, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户停用前批量转交:只转交 from 用户名下、且在指定列表中的客户 */
|
||||||
|
async transferByUser(
|
||||||
|
fromUserId: string,
|
||||||
|
toUserId: string,
|
||||||
|
customerIds: readonly string[] | undefined,
|
||||||
|
permissions: readonly string[],
|
||||||
|
): Promise<{ transferred: number }> {
|
||||||
|
if (
|
||||||
|
!hasPermission(permissions, Permission.CUSTOMER_TRANSFER) &&
|
||||||
|
!hasPermission(permissions, Permission.USER_MANAGE)
|
||||||
|
) {
|
||||||
|
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '缺少客户转交权限' });
|
||||||
|
}
|
||||||
|
await this.ensureOwner(toUserId);
|
||||||
|
const result = await this.prisma.customer.updateMany({
|
||||||
|
where: {
|
||||||
|
ownerUserId: fromUserId,
|
||||||
|
deletedAt: null,
|
||||||
|
status: { not: 'ENDED' },
|
||||||
|
...(customerIds === undefined ? {} : { id: { in: [...customerIds] } }),
|
||||||
|
},
|
||||||
|
data: { ownerUserId: toUserId },
|
||||||
|
});
|
||||||
|
return { transferred: result.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setGroups(
|
||||||
|
id: string,
|
||||||
|
input: SetCustomerGroupsInput,
|
||||||
|
scopeInput: ScopeContext | null,
|
||||||
|
): Promise<CustomerDetail> {
|
||||||
|
const scope = requireScopeContext(scopeInput);
|
||||||
|
await this.findRow(id);
|
||||||
|
this.assertVisible(scope, id);
|
||||||
|
if (input.groupIds.length > 0) {
|
||||||
|
const found = await this.prisma.customerGroup.findMany({
|
||||||
|
where: { id: { in: input.groupIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (found.length !== new Set(input.groupIds).size) {
|
||||||
|
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '客户组不存在' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.customerGroupMember.deleteMany({ where: { customerId: id } }),
|
||||||
|
this.prisma.customerGroupMember.createMany({
|
||||||
|
data: [...new Set(input.groupIds)].map((groupId) => ({ groupId, customerId: id })),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return this.detail(id, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyIndustryPack(): never {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
message: '行业资产包将在 B0 批次提供;当前可先完成客户创建',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findRow(id: string): Promise<{
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
industryId: string;
|
||||||
|
name: string;
|
||||||
|
}> {
|
||||||
|
const row = await this.prisma.customer.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
select: { id: true, status: true, ownerUserId: true, industryId: true, name: true },
|
||||||
|
});
|
||||||
|
if (row === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findVisibleOrThrow(id: string, scope: ScopeContext): Promise<CustomerDetail> {
|
||||||
|
if (!isCustomerVisible(scope, id)) {
|
||||||
|
// A3-BE-05:跨客户访问统一 404,不暴露存在性
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||||
|
}
|
||||||
|
const row = await this.prisma.customer.findFirst({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
include: CUSTOMER_DETAIL_INCLUDE,
|
||||||
|
});
|
||||||
|
if (row === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||||
|
}
|
||||||
|
return toCustomerDetail(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertVisible(scope: ScopeContext, id: string): void {
|
||||||
|
if (!isCustomerVisible(scope, id)) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureOwner(userId: string): Promise<void> {
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: { id: userId, status: 'ACTIVE' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (user === null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '客户负责人不存在或已停用',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 基础字段上限读 system_configs(R-26);shared 常量仅作读失败兜底(默认 10) */
|
||||||
|
private async assertBaseFieldLimit(count: number): Promise<void> {
|
||||||
|
const limit = await this.systemConfig.getNumber(
|
||||||
|
'limit.customer.base_field',
|
||||||
|
CUSTOMER_BASE_FIELD_LIMIT,
|
||||||
|
);
|
||||||
|
if (count > limit) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: `基础字段最多 ${String(limit)} 个,当前 ${String(count)} 个`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async industrySubtreeIds(industryId: string): Promise<string[]> {
|
||||||
|
const industry = await this.prisma.industry.findUnique({
|
||||||
|
where: { id: industryId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (industry === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '行业不存在' });
|
||||||
|
}
|
||||||
|
const children = await this.prisma.industry.findMany({
|
||||||
|
where: { parentId: industryId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return [industryId, ...children.map((child) => child.id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供其他模块复用的归属校验(A3-BE-05):不泄露跨客户资源存在性 */
|
||||||
|
async assertCustomerVisible(customerId: string, scope: ScopeContext): Promise<void> {
|
||||||
|
if (!isCustomerVisible(scope, customerId)) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||||
|
}
|
||||||
|
const count = await this.prisma.customer.count({ where: { id: customerId, deletedAt: null } });
|
||||||
|
if (count === 0) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
type CreateIndustryInput,
|
||||||
|
createIndustryInputSchema,
|
||||||
|
type IndustryDetail,
|
||||||
|
type IndustryListQuery,
|
||||||
|
industryListQuerySchema,
|
||||||
|
type IndustryTreeItem,
|
||||||
|
type ListResponse,
|
||||||
|
Permission,
|
||||||
|
type SetIndustryStatusInput,
|
||||||
|
setIndustryStatusInputSchema,
|
||||||
|
type UpdateIndustryInput,
|
||||||
|
updateIndustryInputSchema,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||||
|
import { Audit, RequirePermissions } from '../../common/security/decorators';
|
||||||
|
import { IndustriesService } from './industries.service';
|
||||||
|
|
||||||
|
@ApiTags('client-industries')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('industries')
|
||||||
|
export class IndustriesController {
|
||||||
|
constructor(@Inject(IndustriesService) private readonly industries: IndustriesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||||
|
@ApiOperation({ summary: '两级行业树(一级 → 二级)' })
|
||||||
|
list(
|
||||||
|
@Query(new ZodValidationPipe(industryListQuerySchema)) query: IndustryListQuery,
|
||||||
|
): Promise<ListResponse<IndustryTreeItem>> {
|
||||||
|
return this.industries.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||||
|
@ApiOperation({ summary: '行业详情与引用(子行业 / 客户 / 资产)' })
|
||||||
|
detail(@Param('id') id: string): Promise<IndustryDetail> {
|
||||||
|
return this.industries.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||||
|
@Audit('client.industry.create')
|
||||||
|
@ApiOperation({ summary: '新建一级 / 二级行业' })
|
||||||
|
create(
|
||||||
|
@Body(new ZodValidationPipe(createIndustryInputSchema)) input: CreateIndustryInput,
|
||||||
|
): Promise<IndustryDetail> {
|
||||||
|
return this.industries.create(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||||
|
@Audit('client.industry.update')
|
||||||
|
@ApiOperation({ summary: '更新行业名称 / 排序 / 备注(层级与上级不可变)' })
|
||||||
|
update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body(new ZodValidationPipe(updateIndustryInputSchema)) input: UpdateIndustryInput,
|
||||||
|
): Promise<IndustryDetail> {
|
||||||
|
return this.industries.update(id, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||||
|
@Audit('client.industry.status')
|
||||||
|
@ApiOperation({ summary: '停用 / 启用行业(有启用子行业时禁止停用)' })
|
||||||
|
setStatus(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body(new ZodValidationPipe(setIndustryStatusInputSchema)) input: SetIndustryStatusInput,
|
||||||
|
): Promise<IndustryDetail> {
|
||||||
|
return this.industries.setStatus(id, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||||
|
@Audit('client.industry.delete')
|
||||||
|
@ApiOperation({ summary: '删除行业(仅无子行业 / 客户 / 资产时)' })
|
||||||
|
async remove(@Param('id') id: string): Promise<void> {
|
||||||
|
await this.industries.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import {
|
||||||
|
type CreateIndustryInput,
|
||||||
|
ErrorCode,
|
||||||
|
type IndustryDetail,
|
||||||
|
type IndustryListQuery,
|
||||||
|
type IndustryReferences,
|
||||||
|
type IndustryTreeItem,
|
||||||
|
type ListResponse,
|
||||||
|
type SetIndustryStatusInput,
|
||||||
|
ulid,
|
||||||
|
type UpdateIndustryInput,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
import { type IndustryRow, toIndustryNode } from './client.mapper';
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行业目录(A3-BE-01 / ADR-002):两级树、名称唯一、停用 / 删除引用保护。
|
||||||
|
* 资产数在 B1 资产表落地前固定为 0;接口已保留字段,后续接入不改契约。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class IndustriesService {
|
||||||
|
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(query: IndustryListQuery): Promise<ListResponse<IndustryTreeItem>> {
|
||||||
|
const rows = await this.prisma.industry.findMany({
|
||||||
|
where: {
|
||||||
|
...(query.keyword === undefined
|
||||||
|
? {}
|
||||||
|
: { name: { contains: query.keyword, mode: 'insensitive' } }),
|
||||||
|
...(query.status === 'all'
|
||||||
|
? {}
|
||||||
|
: { status: query.status === 'active' ? 'ACTIVE' : 'DISABLED' }),
|
||||||
|
...(query.parent_id === undefined ? {} : { parentId: query.parent_id }),
|
||||||
|
},
|
||||||
|
orderBy: [{ level: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
const counts = await this.customerCounts();
|
||||||
|
const roots = rows.filter((row) => row.level === 1);
|
||||||
|
const childrenByParent = new Map<string, IndustryRow[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.level !== 2 || row.parentId === null) continue;
|
||||||
|
const list = childrenByParent.get(row.parentId) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
childrenByParent.set(row.parentId, list);
|
||||||
|
}
|
||||||
|
const items = roots.map((root) => {
|
||||||
|
const children = (childrenByParent.get(root.id) ?? []).map((child) =>
|
||||||
|
toIndustryNode(child, counts.get(child.id) ?? 0),
|
||||||
|
);
|
||||||
|
const own = counts.get(root.id) ?? 0;
|
||||||
|
const total = children.reduce((sum, child) => sum + child.customerCount, own);
|
||||||
|
return {
|
||||||
|
...toIndustryNode(root, total),
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { items, next_cursor: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(id: string): Promise<IndustryDetail> {
|
||||||
|
const row = await this.findOrThrow(id);
|
||||||
|
const parent =
|
||||||
|
row.parentId === null
|
||||||
|
? null
|
||||||
|
: await this.prisma.industry.findUnique({ where: { id: row.parentId } });
|
||||||
|
const references = await this.references(id);
|
||||||
|
return {
|
||||||
|
...toIndustryNode(row, references.customerCount),
|
||||||
|
parentName: parent?.name ?? null,
|
||||||
|
references,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async references(id: string): Promise<IndustryReferences> {
|
||||||
|
const children = await this.prisma.industry.findMany({
|
||||||
|
where: { parentId: id },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
});
|
||||||
|
const customerWhere: Prisma.CustomerWhereInput = {
|
||||||
|
deletedAt: null,
|
||||||
|
OR: [{ industryId: id }, { industry: { parentId: id } }],
|
||||||
|
};
|
||||||
|
const [customerCount, customers] = await Promise.all([
|
||||||
|
this.prisma.customer.count({ where: customerWhere }),
|
||||||
|
this.prisma.customer.findMany({
|
||||||
|
where: customerWhere,
|
||||||
|
select: { id: true, name: true, status: true },
|
||||||
|
take: 20,
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
childCount: children.length,
|
||||||
|
activeChildCount: children.filter((child) => child.status === 'ACTIVE').length,
|
||||||
|
customerCount,
|
||||||
|
customers: customers.map((customer) => ({
|
||||||
|
id: customer.id,
|
||||||
|
name: customer.name,
|
||||||
|
status:
|
||||||
|
customer.status === 'PAUSED'
|
||||||
|
? 'paused'
|
||||||
|
: customer.status === 'ENDED'
|
||||||
|
? 'ended'
|
||||||
|
: 'active',
|
||||||
|
})),
|
||||||
|
assetCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateIndustryInput): Promise<IndustryDetail> {
|
||||||
|
const level = input.parentId === undefined || input.parentId === null ? 1 : 2;
|
||||||
|
if (level === 2 && input.parentId !== undefined && input.parentId !== null) {
|
||||||
|
const parent = await this.findOrThrow(input.parentId);
|
||||||
|
if (parent.level !== 1) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '行业最多两级,二级行业下不能再建子行业',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.assertNameUnique(input.name, level === 1 ? null : (input.parentId ?? null), null);
|
||||||
|
const id = ulid();
|
||||||
|
try {
|
||||||
|
await this.prisma.industry.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
parentId: level === 1 ? null : (input.parentId ?? null),
|
||||||
|
level,
|
||||||
|
name: input.name,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
remark: input.remark,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
message: level === 1 ? '行业名称已存在' : '该上级行业下已存在同名行业',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return this.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, input: UpdateIndustryInput): Promise<IndustryDetail> {
|
||||||
|
const existing = await this.findOrThrow(id);
|
||||||
|
if (input.name !== undefined && input.name !== existing.name) {
|
||||||
|
await this.assertNameUnique(input.name, existing.parentId, id);
|
||||||
|
}
|
||||||
|
await this.prisma.industry.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(input.name === undefined ? {} : { name: input.name }),
|
||||||
|
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
|
||||||
|
...(input.remark === undefined ? {} : { remark: input.remark }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStatus(id: string, input: SetIndustryStatusInput): Promise<IndustryDetail> {
|
||||||
|
const existing = await this.findOrThrow(id);
|
||||||
|
if (existing.status === 'DISABLED' && input.status === 'active') {
|
||||||
|
const parent =
|
||||||
|
existing.parentId === null
|
||||||
|
? null
|
||||||
|
: await this.prisma.industry.findUnique({ where: { id: existing.parentId } });
|
||||||
|
if (parent !== null && parent.status === 'DISABLED') {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||||
|
message: '上级行业已停用,请先启用上级行业',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.status === 'disabled') {
|
||||||
|
const activeChildren = await this.prisma.industry.count({
|
||||||
|
where: { parentId: id, status: 'ACTIVE' },
|
||||||
|
});
|
||||||
|
if (activeChildren > 0) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||||
|
message: `该行业下仍有 ${activeChildren} 个启用中的二级行业,请先停用或调整子级`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.prisma.industry.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: input.status === 'active' ? 'ACTIVE' : 'DISABLED' },
|
||||||
|
});
|
||||||
|
return this.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
const references = await this.references(id);
|
||||||
|
if (references.childCount > 0 || references.customerCount > 0 || references.assetCount > 0) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
message: `无法删除:该行业下有 ${references.childCount} 个二级行业、${references.customerCount} 个客户`,
|
||||||
|
details: references,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.prisma.industry.delete({ where: { id } });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2003') {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
message: '该行业仍被引用,无法删除,只能停用',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 客户归属校验:客户只能挂末级行业(A3-BE-02 / ADR-002 D2) */
|
||||||
|
async assertLeafIndustry(id: string): Promise<void> {
|
||||||
|
const industry = await this.findOrThrow(id);
|
||||||
|
if (industry.status === 'DISABLED') {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
message: '行业已停用,新建客户不可选择',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (industry.level === 1) {
|
||||||
|
const childCount = await this.prisma.industry.count({ where: { parentId: id } });
|
||||||
|
if (childCount > 0) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
message: '该一级行业下存在二级行业,客户只能归属到末级行业',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOrThrow(id: string): Promise<IndustryRow> {
|
||||||
|
const row = await this.prisma.industry.findUnique({ where: { id } });
|
||||||
|
if (row === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '行业不存在' });
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertNameUnique(
|
||||||
|
name: string,
|
||||||
|
parentId: string | null,
|
||||||
|
excludeId: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const duplicate = await this.prisma.industry.findFirst({
|
||||||
|
where: {
|
||||||
|
name,
|
||||||
|
parentId,
|
||||||
|
...(excludeId === null ? {} : { id: { not: excludeId } }),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (duplicate !== null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
message: parentId === null ? '行业名称已存在' : '该上级行业下已存在同名行业',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async customerCounts(): Promise<Map<string, number>> {
|
||||||
|
const grouped = await this.prisma.customer.groupBy({
|
||||||
|
by: ['industryId'],
|
||||||
|
where: { deletedAt: null },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
return new Map(grouped.map((item) => [item.industryId, item._count._all]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { CustomerDirectory } from '../../common/security/scope/customer-directory';
|
||||||
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真实客户目录(A3 替换 A2 的 EmptyCustomerDirectory)。
|
||||||
|
* 只读,供 ScopeResolver 解析六类数据范围;不参与业务写操作。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaCustomerDirectory implements CustomerDirectory {
|
||||||
|
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findOwnedCustomerIds(userId: string): Promise<string[]> {
|
||||||
|
const rows = await this.prisma.customer.findMany({
|
||||||
|
where: { ownerUserId: userId, deletedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return rows.map((row) => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCustomerGroupCustomerIds(customerGroupIds: readonly string[]): Promise<string[]> {
|
||||||
|
if (customerGroupIds.length === 0) return [];
|
||||||
|
const rows = await this.prisma.customerGroupMember.findMany({
|
||||||
|
where: { groupId: { in: [...customerGroupIds] }, customer: { deletedAt: null } },
|
||||||
|
select: { customerId: true },
|
||||||
|
});
|
||||||
|
return [...new Set(rows.map((row) => row.customerId))].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDepartmentCustomerIds(departmentIds: readonly string[]): Promise<string[]> {
|
||||||
|
if (departmentIds.length === 0) return [];
|
||||||
|
const rows = await this.prisma.customer.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
owner: { departmentId: { in: [...departmentIds] } },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return rows.map((row) => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCreatedCustomerIds(userId: string): Promise<string[]> {
|
||||||
|
const rows = await this.prisma.customer.findMany({
|
||||||
|
where: { createdBy: userId, deletedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return rows.map((row) => row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -616,7 +616,7 @@ describe('identity 集成(真实 PG / Redis)', () => {
|
|||||||
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
|
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/me:dataScopes 多角色并集;超管固定 all;A3 前客户集合为空数组', async () => {
|
it('/me:dataScopes 多角色并集;超管固定 all;A3 接入真实客户目录', async () => {
|
||||||
const adminLogin = await login(created.app, manager.account);
|
const adminLogin = await login(created.app, manager.account);
|
||||||
expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]);
|
expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]);
|
||||||
const adminMe = await callJson<MeBody>(created.app, 'GET', '/me', adminLogin.body.accessToken);
|
const adminMe = await callJson<MeBody>(created.app, 'GET', '/me', adminLogin.body.accessToken);
|
||||||
@@ -639,7 +639,9 @@ describe('identity 集成(真实 PG / Redis)', () => {
|
|||||||
const scopes = userLogin.body.me.dataScopes;
|
const scopes = userLogin.body.me.dataScopes;
|
||||||
expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']);
|
expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']);
|
||||||
for (const scope of scopes) {
|
for (const scope of scopes) {
|
||||||
expect(scope.customerIds).toEqual([]);
|
expect(Array.isArray(scope.customerIds)).toBe(true);
|
||||||
}
|
}
|
||||||
|
// A3 起 CustomerDirectory 返回真实客户;本用例用户没有负责 / 创建的客户
|
||||||
|
expect(scopes.find((scope) => scope.type === 'owned_customers')?.customerIds).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5,6 +5,7 @@ import { AppException } from '../../common/errors/app-exception';
|
|||||||
import type { UserAuthRecord } from '../../common/security/permission.service';
|
import type { UserAuthRecord } from '../../common/security/permission.service';
|
||||||
import type { RequestContextService } from '../../common/security/request-context';
|
import type { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import type { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||||
import { DepartmentsService } from './departments.service';
|
import { DepartmentsService } from './departments.service';
|
||||||
import { LoginFailureService } from './login-failure.service';
|
import { LoginFailureService } from './login-failure.service';
|
||||||
import { MeService } from './me.service';
|
import { MeService } from './me.service';
|
||||||
@@ -213,10 +214,15 @@ describe('LoginFailureService', () => {
|
|||||||
describe('UsersService', () => {
|
describe('UsersService', () => {
|
||||||
const passwords = { hash: vi.fn().mockResolvedValue('hashed') };
|
const passwords = { hash: vi.fn().mockResolvedValue('hashed') };
|
||||||
|
|
||||||
|
const systemConfig = {
|
||||||
|
getNumber: vi.fn((_key: string, fallback?: number) => Promise.resolve(fallback ?? 0)),
|
||||||
|
} as unknown as SystemConfigService;
|
||||||
|
|
||||||
function serviceOf(prisma: Record<string, unknown>): UsersService {
|
function serviceOf(prisma: Record<string, unknown>): UsersService {
|
||||||
return new UsersService(
|
return new UsersService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
passwords as unknown as PasswordService,
|
passwords as unknown as PasswordService,
|
||||||
|
systemConfig,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,8 +265,6 @@ describe('UsersService', () => {
|
|||||||
const disabled = userFixture({ status: 'DISABLED' });
|
const disabled = userFixture({ status: 'DISABLED' });
|
||||||
const service2 = serviceOf({ user: { findUnique: vi.fn().mockResolvedValue(disabled) } });
|
const service2 = serviceOf({ user: { findUnique: vi.fn().mockResolvedValue(disabled) } });
|
||||||
await expectRejected(service2.disable('u1', {}, 'u2', []), ErrorCode.STATE_NOT_ALLOWED);
|
await expectRejected(service2.disable('u1', {}, 'u2', []), ErrorCode.STATE_NOT_ALLOWED);
|
||||||
|
|
||||||
expect(() => service.transferCustomers()).toThrow(AppException);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('停用成功会同步吊销 refresh token;启用恢复状态', async () => {
|
it('停用成功会同步吊销 refresh token;启用恢复状态', async () => {
|
||||||
@@ -600,6 +604,9 @@ describe('MeService / RolesService', () => {
|
|||||||
const service = new RolesService(
|
const service = new RolesService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
{ get: () => null, patchAudit: vi.fn() } as unknown as RequestContextService,
|
{ get: () => null, patchAudit: vi.fn() } as unknown as RequestContextService,
|
||||||
|
{
|
||||||
|
getNumber: vi.fn((_key: string, fallback?: number) => Promise.resolve(fallback ?? 0)),
|
||||||
|
} as unknown as SystemConfigService,
|
||||||
);
|
);
|
||||||
const result = await service.list({
|
const result = await service.list({
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
import type { RequestContextService } from '../../common/security/request-context';
|
import type { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import type { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||||
import { RolesService } from './roles.service';
|
import { RolesService } from './roles.service';
|
||||||
|
|
||||||
const NOW = new Date('2026-11-03T02:00:00.000Z');
|
const NOW = new Date('2026-11-03T02:00:00.000Z');
|
||||||
@@ -102,10 +103,14 @@ function harness(): Harness {
|
|||||||
get: vi.fn().mockReturnValue({ traceId: 'trace-1' }),
|
get: vi.fn().mockReturnValue({ traceId: 'trace-1' }),
|
||||||
patchAudit: vi.fn(),
|
patchAudit: vi.fn(),
|
||||||
};
|
};
|
||||||
|
const systemConfig = {
|
||||||
|
getNumber: vi.fn((_key: string, fallback?: number) => Promise.resolve(fallback ?? 0)),
|
||||||
|
} as unknown as SystemConfigService;
|
||||||
return {
|
return {
|
||||||
service: new RolesService(
|
service: new RolesService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
requestContext as unknown as RequestContextService,
|
requestContext as unknown as RequestContextService,
|
||||||
|
systemConfig,
|
||||||
),
|
),
|
||||||
prisma,
|
prisma,
|
||||||
requestContext,
|
requestContext,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { AppException } from '../../common/errors/app-exception';
|
|||||||
import { RequestContextService } from '../../common/security/request-context';
|
import { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
import { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||||
import { iso, maskPhone } from './user.mapper';
|
import { iso, maskPhone } from './user.mapper';
|
||||||
|
|
||||||
const ROLE_LIST_INCLUDE = {
|
const ROLE_LIST_INCLUDE = {
|
||||||
@@ -238,6 +239,7 @@ export class RolesService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
|
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(query: RoleListQuery): Promise<ListResponse<RoleListItem>> {
|
async list(query: RoleListQuery): Promise<ListResponse<RoleListItem>> {
|
||||||
@@ -694,22 +696,27 @@ export class RolesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 软提示(不阻断):角色数量 / 超管人数超过 PRD 建议值;A4 迁 system_configs */
|
/** 软提示(不阻断):角色 / 超管人数超过建议值;阈值读 system_configs(R-26),常量仅作读失败兜底 */
|
||||||
private async buildWarnings(roleCode: string): Promise<string[]> {
|
private async buildWarnings(roleCode: string): Promise<string[]> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
const roleSoftLimit = await this.systemConfig.getNumber('limit.role.max', ROLE_SOFT_LIMIT);
|
||||||
const activeRoleCount = await this.prisma.role.count({ where: { status: 'ACTIVE' } });
|
const activeRoleCount = await this.prisma.role.count({ where: { status: 'ACTIVE' } });
|
||||||
if (activeRoleCount > ROLE_SOFT_LIMIT) {
|
if (activeRoleCount > roleSoftLimit) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(ROLE_SOFT_LIMIT)},建议合并角色`,
|
`启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(roleSoftLimit)},建议合并角色`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (roleCode === 'super_admin') {
|
if (roleCode === 'super_admin') {
|
||||||
|
const superAdminSoftLimit = await this.systemConfig.getNumber(
|
||||||
|
'limit.super_admin.max',
|
||||||
|
SUPER_ADMIN_SOFT_LIMIT,
|
||||||
|
);
|
||||||
const superAdminCount = await this.prisma.user.count({
|
const superAdminCount = await this.prisma.user.count({
|
||||||
where: { status: 'ACTIVE', roles: { some: { role: { code: 'super_admin' } } } },
|
where: { status: 'ACTIVE', roles: { some: { role: { code: 'super_admin' } } } },
|
||||||
});
|
});
|
||||||
if (superAdminCount > SUPER_ADMIN_SOFT_LIMIT) {
|
if (superAdminCount > superAdminSoftLimit) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人`,
|
`超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(superAdminSoftLimit)} 人`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
ErrorCode,
|
ErrorCode,
|
||||||
type ListResponse,
|
type ListResponse,
|
||||||
Permission,
|
Permission,
|
||||||
transferCustomersInputSchema,
|
|
||||||
type UpdateUserInput,
|
type UpdateUserInput,
|
||||||
updateUserInputSchema,
|
updateUserInputSchema,
|
||||||
type UserDetail,
|
type UserDetail,
|
||||||
@@ -110,15 +109,4 @@ export class UsersController {
|
|||||||
): Promise<UserDetail> {
|
): Promise<UserDetail> {
|
||||||
return this.users.enable(id, requireAuth(auth).permissions);
|
return this.users.enable(id, requireAuth(auth).permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/transfer-customers')
|
|
||||||
@HttpCode(200)
|
|
||||||
@RequirePermissions(Permission.USER_MANAGE)
|
|
||||||
@Audit('identity.user.transfer_customers')
|
|
||||||
@ApiOperation({ summary: '客户转交(占位)', description: 'A3 客户模块落地前返回 42201' })
|
|
||||||
transferCustomers(
|
|
||||||
@Body(new ZodValidationPipe(transferCustomersInputSchema)) _input: unknown,
|
|
||||||
): never {
|
|
||||||
return this.users.transferCustomers();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
import { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||||
import { PasswordService } from './password.service';
|
import { PasswordService } from './password.service';
|
||||||
import { toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
|
import { toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ export class UsersService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
@Inject(PasswordService) private readonly passwords: PasswordService,
|
@Inject(PasswordService) private readonly passwords: PasswordService,
|
||||||
|
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(query: UserListQuery): Promise<ListResponse<UserSummary>> {
|
async list(query: UserListQuery): Promise<ListResponse<UserSummary>> {
|
||||||
@@ -180,17 +182,9 @@ export class UsersService {
|
|||||||
return this.detail(id, permissions);
|
return this.detail(id, permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 客户转交占位:A3 客户模块落地前按契约返回 42201(不自造 5xx 错误码) */
|
|
||||||
transferCustomers(): never {
|
|
||||||
throw new AppException({
|
|
||||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
|
||||||
message: '客户转交将在客户模块(A3)提供',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 超管人数软上限(PRD §5.4.1 建议 ≤ 2 人):只记录告警,不阻断保存;
|
* 超管人数软上限(PRD §5.4.1):只记录告警,不阻断保存;
|
||||||
* 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。
|
* 前端「超管授予二次确认」是主要提示手段。阈值读 system_configs(R-26),常量仅作读失败兜底。
|
||||||
*/
|
*/
|
||||||
private async warnIfSuperAdminExceeded(roleIds: readonly string[]): Promise<void> {
|
private async warnIfSuperAdminExceeded(roleIds: readonly string[]): Promise<void> {
|
||||||
const superAdminRole = await this.prisma.role.findUnique({
|
const superAdminRole = await this.prisma.role.findUnique({
|
||||||
@@ -200,12 +194,16 @@ export class UsersService {
|
|||||||
if (superAdminRole === null || !roleIds.includes(superAdminRole.id)) {
|
if (superAdminRole === null || !roleIds.includes(superAdminRole.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const superAdminSoftLimit = await this.systemConfig.getNumber(
|
||||||
|
'limit.super_admin.max',
|
||||||
|
SUPER_ADMIN_SOFT_LIMIT,
|
||||||
|
);
|
||||||
const count = await this.prisma.user.count({
|
const count = await this.prisma.user.count({
|
||||||
where: { status: 'ACTIVE', roles: { some: { roleId: superAdminRole.id } } },
|
where: { status: 'ACTIVE', roles: { some: { roleId: superAdminRole.id } } },
|
||||||
});
|
});
|
||||||
if (count > SUPER_ADMIN_SOFT_LIMIT) {
|
if (count > superAdminSoftLimit) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`超级管理员 ${String(count)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人(仅提示,不阻断)`,
|
`超级管理员 ${String(count)} 人已超过建议上限 ${String(superAdminSoftLimit)} 人(仅提示,不阻断)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { ErrorCode, formatNamingDate, NAMING_MAX_LENGTH } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import type { SystemConfigService } from '../system-config/system-config.service';
|
||||||
|
import { type NamingNameContext, NamingService } from './naming.service';
|
||||||
|
|
||||||
|
const SCRIPT_PATTERN = '{行业}-{客户}-{脚本方向}-{日期}-{序号}';
|
||||||
|
const IMAGE_PATTERN = '{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}';
|
||||||
|
const VIDEO_PATTERN = '{行业}_{客户}_{日期}_{序号}';
|
||||||
|
|
||||||
|
const BASE_CONTEXT: NamingNameContext = {
|
||||||
|
industry: '电商',
|
||||||
|
customer: '花间集',
|
||||||
|
direction: '口播',
|
||||||
|
date: '20261104',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HarnessOptions {
|
||||||
|
script?: string;
|
||||||
|
image?: string;
|
||||||
|
video?: string;
|
||||||
|
maxLength?: number;
|
||||||
|
duplicateStart?: number;
|
||||||
|
patternError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(options: HarnessOptions = {}) {
|
||||||
|
const patterns: Record<string, string> = {
|
||||||
|
script: options.script ?? SCRIPT_PATTERN,
|
||||||
|
image: options.image ?? IMAGE_PATTERN,
|
||||||
|
video: options.video ?? VIDEO_PATTERN,
|
||||||
|
};
|
||||||
|
const getNamingPattern = vi.fn((kind: string) => {
|
||||||
|
if (options.patternError === true) return Promise.reject(new Error('配置读取失败'));
|
||||||
|
return Promise.resolve(patterns[kind] ?? SCRIPT_PATTERN);
|
||||||
|
});
|
||||||
|
const getNumber = vi.fn((key: string, fallback?: number) => {
|
||||||
|
if (key === 'naming.max_length') {
|
||||||
|
return Promise.resolve(options.maxLength ?? fallback ?? NAMING_MAX_LENGTH);
|
||||||
|
}
|
||||||
|
return Promise.resolve(options.duplicateStart ?? fallback ?? 2);
|
||||||
|
});
|
||||||
|
const configs = { getNamingPattern, getNumber } as unknown as SystemConfigService;
|
||||||
|
return { service: new NamingService(configs), getNamingPattern, getNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||||
|
return promise.then(
|
||||||
|
() => expect.unreachable('应抛出异常'),
|
||||||
|
(error: unknown) => {
|
||||||
|
expect(error).toBeInstanceOf(AppException);
|
||||||
|
expect((error as AppException).code).toBe(code);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NamingService · 默认模板', () => {
|
||||||
|
it('脚本:占位符替换 + 序号补零为 01,日期可显式传入', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expect(service.resolveScriptName(BASE_CONTEXT)).resolves.toBe(
|
||||||
|
'电商-花间集-口播-20261104-01',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未传日期时取生成当天(YYYYMMDD)', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
const name = await service.resolveScriptName({
|
||||||
|
industry: '电商',
|
||||||
|
customer: '花间集',
|
||||||
|
direction: '口播',
|
||||||
|
});
|
||||||
|
expect(name).toContain(formatNamingDate(new Date()));
|
||||||
|
expect(name).toMatch(/^电商-花间集-口播-\d{8}-01$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('图片:追加规格后缀;视频:下划线分隔', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expect(service.resolveImageName({ ...BASE_CONTEXT, spec: '1080x1920' })).resolves.toBe(
|
||||||
|
'电商-花间集-口播-20261104-01-1080x1920',
|
||||||
|
);
|
||||||
|
await expect(service.resolveVideoName(BASE_CONTEXT)).resolves.toBe('电商_花间集_20261104_01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('批次序号可传入(数值补零为两位)', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expect(service.resolveScriptName({ ...BASE_CONTEXT, sequence: 5 })).resolves.toBe(
|
||||||
|
'电商-花间集-口播-20261104-05',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NamingService · 同名序号(R-01)', () => {
|
||||||
|
it('默认从 -02 起连续递增,已占用则继续 +1', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
const base = '电商-花间集-口播-20261104-01';
|
||||||
|
await expect(service.resolveScriptName(BASE_CONTEXT, [base])).resolves.toBe(
|
||||||
|
'电商-花间集-口播-20261104-02',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.resolveScriptName(BASE_CONTEXT, [base, '电商-花间集-口播-20261104-02']),
|
||||||
|
).resolves.toBe('电商-花间集-口播-20261104-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无同名冲突时保持原名(不追加序号)', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expect(service.resolveScriptName(BASE_CONTEXT, ['其它名称'])).resolves.toBe(
|
||||||
|
'电商-花间集-口播-20261104-01',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('naming.duplicate_start 可配置起始序号(配置 3 时从 -03 起)', async () => {
|
||||||
|
const { service } = harness({ duplicateStart: 3 });
|
||||||
|
await expect(
|
||||||
|
service.resolveScriptName(BASE_CONTEXT, ['电商-花间集-口播-20261104-01']),
|
||||||
|
).resolves.toBe('电商-花间集-口播-20261104-03');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NamingService · 校验与业务错误', () => {
|
||||||
|
it('缺失占位符值返回 40001(缺 {客户})', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expectRejected(
|
||||||
|
service.resolveScriptName({ industry: '电商', direction: '口播', date: '20261104' }),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('解析结果含非法字符返回 42201', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expectRejected(
|
||||||
|
service.resolveScriptName({ ...BASE_CONTEXT, customer: '花间集*' }),
|
||||||
|
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超过 naming.max_length 返回 42201;正常名称长度 ≤ 100', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expectRejected(
|
||||||
|
service.resolveScriptName({ ...BASE_CONTEXT, customer: '花'.repeat(110) }),
|
||||||
|
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
);
|
||||||
|
const name = await service.resolveScriptName(BASE_CONTEXT);
|
||||||
|
expect(name.length).toBeLessThanOrEqual(NAMING_MAX_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('配置读取失败时向上抛出(由调用方决定重试 / 降级)', async () => {
|
||||||
|
const { service } = harness({ patternError: true });
|
||||||
|
await expect(service.resolveScriptName(BASE_CONTEXT)).rejects.toThrow('配置读取失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('读取参数使用 naming.* 配置键', async () => {
|
||||||
|
const { service, getNamingPattern, getNumber } = harness();
|
||||||
|
await service.resolveScriptName(BASE_CONTEXT, []);
|
||||||
|
expect(getNamingPattern).toHaveBeenCalledWith('script');
|
||||||
|
expect(getNumber).toHaveBeenCalledWith('naming.max_length', NAMING_MAX_LENGTH);
|
||||||
|
expect(getNumber).toHaveBeenCalledWith('naming.duplicate_start', 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
dedupeName,
|
||||||
|
ErrorCode,
|
||||||
|
formatNamingDate,
|
||||||
|
formatNamingSequence,
|
||||||
|
NAMING_MAX_LENGTH,
|
||||||
|
type NamingContext,
|
||||||
|
type NamingTokenKey,
|
||||||
|
resolveNamingTemplate,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import { SystemConfigService } from '../system-config/system-config.service';
|
||||||
|
|
||||||
|
/** 调用方只传自己知道的占位符;未传的占位符由 resolveNamingTemplate 报「缺少值」 */
|
||||||
|
export type NamingNameContext = Partial<Record<NamingTokenKey, string | number>>;
|
||||||
|
|
||||||
|
export type NamingKind = 'script' | 'image' | 'video';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命名规则服务(A4-BE-03 / PRD R-01)。
|
||||||
|
*
|
||||||
|
* 读 `naming.{script|image|video}.pattern` + `naming.max_length` + `naming.duplicate_start`,
|
||||||
|
* 调用 shared 的 `resolveNamingTemplate` / `dedupeName` 生成最终文件名;
|
||||||
|
* 占位符缺失、非法字符、超长、同名过多统一转业务异常(40001 / 42201)。
|
||||||
|
* 供脚本、图片、视频、导出共用,避免各模块各写一套命名。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class NamingService {
|
||||||
|
constructor(@Inject(SystemConfigService) private readonly configs: SystemConfigService) {}
|
||||||
|
|
||||||
|
resolveScriptName(
|
||||||
|
context: NamingNameContext,
|
||||||
|
existingNames?: readonly string[],
|
||||||
|
): Promise<string> {
|
||||||
|
return this.resolve('script', context, existingNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveImageName(context: NamingNameContext, existingNames?: readonly string[]): Promise<string> {
|
||||||
|
return this.resolve('image', context, existingNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveVideoName(context: NamingNameContext, existingNames?: readonly string[]): Promise<string> {
|
||||||
|
return this.resolve('video', context, existingNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolve(
|
||||||
|
kind: NamingKind,
|
||||||
|
input: NamingNameContext,
|
||||||
|
existingNames: readonly string[] | undefined,
|
||||||
|
): Promise<string> {
|
||||||
|
const [pattern, maxLength, duplicateStart] = await Promise.all([
|
||||||
|
this.configs.getNamingPattern(kind),
|
||||||
|
this.configs.getNumber('naming.max_length', NAMING_MAX_LENGTH),
|
||||||
|
this.configs.getNumber('naming.duplicate_start', 2),
|
||||||
|
]);
|
||||||
|
const resolved = this.resolvePattern(pattern, this.contextOf(input), maxLength);
|
||||||
|
const existing = new Set(existingNames ?? []);
|
||||||
|
if (existing.size === 0) return resolved;
|
||||||
|
return this.dedupe(resolved, existing, duplicateStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缺省值:日期取生成当天、序号取 01;其余占位符缺失交给 shared 报错 */
|
||||||
|
private contextOf(input: NamingNameContext, now: Date = new Date()): NamingContext {
|
||||||
|
const rawSequence = input.sequence ?? 1;
|
||||||
|
const sequence =
|
||||||
|
typeof rawSequence === 'number' ? formatNamingSequence(rawSequence) : rawSequence;
|
||||||
|
return {
|
||||||
|
industry: input.industry ?? '',
|
||||||
|
customer: input.customer ?? '',
|
||||||
|
direction: input.direction ?? '',
|
||||||
|
date: input.date === undefined ? formatNamingDate(now) : String(input.date),
|
||||||
|
sequence,
|
||||||
|
spec: input.spec ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePattern(pattern: string, context: NamingContext, maxLength: number): string {
|
||||||
|
try {
|
||||||
|
return resolveNamingTemplate(pattern, context, { maxLength });
|
||||||
|
} catch (error) {
|
||||||
|
throw this.toAppException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dedupe(name: string, existing: ReadonlySet<string>, start: number): string {
|
||||||
|
let candidate: string;
|
||||||
|
try {
|
||||||
|
candidate = dedupeName(name, existing);
|
||||||
|
} catch (error) {
|
||||||
|
throw this.toAppException(error);
|
||||||
|
}
|
||||||
|
return this.applyDuplicateStart(candidate, name, existing, start);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `dedupeName` 固定从 -02 起;`naming.duplicate_start` 可配 2–9。
|
||||||
|
* 配置大于 2 时,把候选序号抬到配置起点,保证「同名序号起始」生效。
|
||||||
|
*/
|
||||||
|
private applyDuplicateStart(
|
||||||
|
candidate: string,
|
||||||
|
base: string,
|
||||||
|
existing: ReadonlySet<string>,
|
||||||
|
start: number,
|
||||||
|
): string {
|
||||||
|
if (start <= 2 || candidate === base) return candidate;
|
||||||
|
const dotIndex = candidate.lastIndexOf('.');
|
||||||
|
const stem = dotIndex > 0 ? candidate.slice(0, dotIndex) : candidate;
|
||||||
|
const extension = dotIndex > 0 ? candidate.slice(dotIndex) : '';
|
||||||
|
const dashIndex = stem.lastIndexOf('-');
|
||||||
|
if (dashIndex <= 0) return candidate;
|
||||||
|
const digits = stem.slice(dashIndex + 1);
|
||||||
|
if (!/^\d{2,}$/.test(digits)) return candidate;
|
||||||
|
const sequence = Number(digits);
|
||||||
|
if (sequence >= start) return candidate;
|
||||||
|
const root = stem.slice(0, dashIndex);
|
||||||
|
for (let next = start; next <= 999; next += 1) {
|
||||||
|
const nextName = `${root}-${String(next).padStart(2, '0')}${extension}`;
|
||||||
|
if (!existing.has(nextName)) return nextName;
|
||||||
|
}
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
message: `同名文件过多,无法为「${base}」生成序号`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toAppException(error: unknown): AppException {
|
||||||
|
if (error instanceof AppException) return error;
|
||||||
|
const message = error instanceof Error ? error.message : '命名生成失败';
|
||||||
|
if (message.includes('占位符缺少值')) {
|
||||||
|
return new AppException({ code: ErrorCode.PARAM_INVALID, message });
|
||||||
|
}
|
||||||
|
return new AppException({
|
||||||
|
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
message: `命名规则校验未通过:${message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { NamingService } from './naming/naming.service';
|
||||||
|
import { SystemConfigController } from './system-config/system-config.controller';
|
||||||
|
import { SystemConfigService } from './system-config/system-config.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* platform 模块:健康检查、系统配置、审计、AI 网关等平台能力的归属模块(技术架构 §3)。
|
* platform 模块:健康检查、系统配置、命名规则、审计、AI 网关等平台能力的归属模块
|
||||||
* A0 只落地健康检查;其余能力按里程碑在本模块内新增。
|
* (技术架构 §3)。@Global 导出 SystemConfigService / NamingService,供 identity / client /
|
||||||
|
* 后续 script / media / delivery 直接构造器注入,避免跨模块重复 import(R-26 阈值同源)。
|
||||||
*/
|
*/
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HealthModule],
|
imports: [HealthModule],
|
||||||
exports: [HealthModule],
|
controllers: [SystemConfigController],
|
||||||
|
providers: [SystemConfigService, NamingService],
|
||||||
|
exports: [HealthModule, SystemConfigService, NamingService],
|
||||||
})
|
})
|
||||||
export class PlatformModule {}
|
export class PlatformModule {}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
type EffectiveSystemConfigsResponse,
|
||||||
|
ErrorCode,
|
||||||
|
Permission,
|
||||||
|
type SystemConfigHistoryQuery,
|
||||||
|
systemConfigHistoryQuerySchema,
|
||||||
|
type SystemConfigHistoryResponse,
|
||||||
|
type SystemConfigListResponse,
|
||||||
|
type UpdateSystemConfigsInput,
|
||||||
|
updateSystemConfigsInputSchema,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Body, Controller, Get, Inject, Put, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import { ZodValidationPipe } from '../../../common/pipes/zod-validation.pipe';
|
||||||
|
import type { AuthContext } from '../../../common/security/auth.types';
|
||||||
|
import { Audit, CurrentUser, RequirePermissions } from '../../../common/security/decorators';
|
||||||
|
import { SystemConfigService } from './system-config.service';
|
||||||
|
|
||||||
|
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||||
|
if (auth === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置(A4-BE-02 / PRD R-26):
|
||||||
|
* - `/system/configs` 全量读写需要 `system:configure`;
|
||||||
|
* - `/system/configs/effective` 仅要求登录,供前端读取上限(阈值单一来源)。
|
||||||
|
*/
|
||||||
|
@ApiTags('platform-system-configs')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('system')
|
||||||
|
export class SystemConfigController {
|
||||||
|
constructor(@Inject(SystemConfigService) private readonly configs: SystemConfigService) {}
|
||||||
|
|
||||||
|
@Get('configs')
|
||||||
|
@RequirePermissions(Permission.SYSTEM_CONFIGURE)
|
||||||
|
@ApiOperation({ summary: '全量系统配置(定义元数据 + 当前值 / 版本)' })
|
||||||
|
list(): Promise<SystemConfigListResponse> {
|
||||||
|
return this.configs.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('configs/effective')
|
||||||
|
@ApiOperation({ summary: '生效配置(仅登录;只返回 public 项,前端上限读取入口)' })
|
||||||
|
effective(): Promise<EffectiveSystemConfigsResponse> {
|
||||||
|
return this.configs.getEffective();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('configs')
|
||||||
|
@RequirePermissions(Permission.SYSTEM_CONFIGURE)
|
||||||
|
@Audit('platform.system_config.update')
|
||||||
|
@ApiOperation({ summary: '批量保存配置(乐观锁 expectedVersion;写版本历史与审计)' })
|
||||||
|
update(
|
||||||
|
@Body(new ZodValidationPipe(updateSystemConfigsInputSchema)) input: UpdateSystemConfigsInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<SystemConfigListResponse> {
|
||||||
|
return this.configs.update(input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('configs/history')
|
||||||
|
@RequirePermissions(Permission.SYSTEM_CONFIGURE)
|
||||||
|
@ApiOperation({ summary: '配置变更记录(按变更时间倒序;操作人为用户姓名)' })
|
||||||
|
history(
|
||||||
|
@Query(new ZodValidationPipe(systemConfigHistoryQuerySchema))
|
||||||
|
query: SystemConfigHistoryQuery,
|
||||||
|
): Promise<SystemConfigHistoryResponse> {
|
||||||
|
return this.configs.history(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import {
|
||||||
|
API_PREFIX,
|
||||||
|
ErrorCode,
|
||||||
|
Permission,
|
||||||
|
SYSTEM_CONFIG_DEFINITIONS,
|
||||||
|
systemConfigDefinition,
|
||||||
|
ulid,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createApp, type CreatedApiApp } from '../../../bootstrap';
|
||||||
|
import { loadDotenv } from '../../../config/load-dotenv';
|
||||||
|
|
||||||
|
const PASSWORD_HASH =
|
||||||
|
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||||
|
const PASSWORD = 'lingxi2026';
|
||||||
|
const PREFIX = 'itqa4c.';
|
||||||
|
const REASON = 'ITQA4 集成测试';
|
||||||
|
const CONFIG_KEY = 'limit.page.max';
|
||||||
|
const API = API_PREFIX;
|
||||||
|
|
||||||
|
loadDotenv();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface TokenBody {
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonResult<T> {
|
||||||
|
status: number;
|
||||||
|
body: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemConfigBody {
|
||||||
|
key: string;
|
||||||
|
group: string;
|
||||||
|
label: string;
|
||||||
|
value: string | number | boolean;
|
||||||
|
valueType: string;
|
||||||
|
version: number;
|
||||||
|
updatedBy: string | null;
|
||||||
|
editable: boolean;
|
||||||
|
isPublic: boolean;
|
||||||
|
min: number | null;
|
||||||
|
max: number | null;
|
||||||
|
maxLength: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemConfigChangeBody {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
version: number;
|
||||||
|
oldValue: unknown;
|
||||||
|
newValue: unknown;
|
||||||
|
reason: string | null;
|
||||||
|
operator: string | null;
|
||||||
|
changedAt: string;
|
||||||
|
result: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OriginalConfig {
|
||||||
|
configValue: string | number | boolean;
|
||||||
|
version: number;
|
||||||
|
updatedBy: string | null;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let phoneCounter = 0;
|
||||||
|
let app: CreatedApiApp['app'];
|
||||||
|
let adminToken = '';
|
||||||
|
let readerToken = '';
|
||||||
|
let original: OriginalConfig | null = null;
|
||||||
|
const createdRoleIds: string[] = [];
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
|
function nextPhone(): string {
|
||||||
|
phoneCounter += 1;
|
||||||
|
return `134${String(phoneCounter).padStart(8, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRole(suffix: string, permissions: string[]): Promise<string> {
|
||||||
|
const id = ulid();
|
||||||
|
await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
code: `${PREFIX}role_${suffix}`,
|
||||||
|
name: `${PREFIX}角色-${suffix}`,
|
||||||
|
description: 'A4 系统配置集成测试角色',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
|
||||||
|
dataScopes: { create: [{ id: ulid(), scopeType: 'all', targetId: null }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdRoleIds.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(suffix: string, roleId: string): Promise<void> {
|
||||||
|
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||||||
|
const account = `${PREFIX}${suffix}`;
|
||||||
|
const id = ulid();
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
name: `A4 集成测试${suffix}`,
|
||||||
|
account,
|
||||||
|
email: `${account}@lingxi.cn`,
|
||||||
|
phone: nextPhone(),
|
||||||
|
passwordHash: PASSWORD_HASH,
|
||||||
|
departmentId: department.id,
|
||||||
|
mustChangePassword: false,
|
||||||
|
roles: { create: [{ roleId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdUserIds.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(account: string): Promise<string> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${API}/auth/login`,
|
||||||
|
payload: { account, password: PASSWORD },
|
||||||
|
});
|
||||||
|
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
||||||
|
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
|
||||||
|
}
|
||||||
|
return response.json<TokenBody>().accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<T>(
|
||||||
|
method: 'GET' | 'PUT',
|
||||||
|
url: string,
|
||||||
|
token?: string,
|
||||||
|
payload?: unknown,
|
||||||
|
): Promise<JsonResult<T>> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method,
|
||||||
|
url: `${API}${url}`,
|
||||||
|
...(token === undefined ? {} : { headers: { authorization: `Bearer ${token}` } }),
|
||||||
|
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||||
|
});
|
||||||
|
const body = response.body.length === 0 ? (null as T) : response.json<T>();
|
||||||
|
return { status: response.statusCode, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
function configOf(items: SystemConfigBody[], key: string): SystemConfigBody {
|
||||||
|
const item = items.find((config) => config.key === key);
|
||||||
|
if (item === undefined) throw new Error(`配置项缺失:${key}`);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function primitive(value: unknown): string | number | boolean {
|
||||||
|
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
||||||
|
? value
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupFixtures(): Promise<void> {
|
||||||
|
await prisma.user.deleteMany({ where: { account: { startsWith: PREFIX } } });
|
||||||
|
if (createdRoleIds.length > 0) {
|
||||||
|
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
|
||||||
|
}
|
||||||
|
await prisma.systemConfigVersion.deleteMany({
|
||||||
|
where: { configKey: CONFIG_KEY, reason: { startsWith: REASON } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanupFixtures();
|
||||||
|
const adminRole = await createRole('admin', [Permission.SYSTEM_CONFIGURE]);
|
||||||
|
await createUser('admin', adminRole);
|
||||||
|
const readerRole = await createRole('reader', [Permission.CUSTOMER_READ]);
|
||||||
|
await createUser('reader', readerRole);
|
||||||
|
|
||||||
|
const row = await prisma.systemConfig.findUnique({ where: { configKey: CONFIG_KEY } });
|
||||||
|
original =
|
||||||
|
row === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
configValue: primitive(row.configValue),
|
||||||
|
version: row.version,
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
description: row.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await createApp();
|
||||||
|
app = created.app;
|
||||||
|
await app.init();
|
||||||
|
adminToken = await login(`${PREFIX}admin`);
|
||||||
|
readerToken = await login(`${PREFIX}reader`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (original !== null) {
|
||||||
|
await prisma.systemConfig.update({
|
||||||
|
where: { configKey: CONFIG_KEY },
|
||||||
|
data: {
|
||||||
|
configValue: original.configValue,
|
||||||
|
version: original.version,
|
||||||
|
updatedBy: original.updatedBy,
|
||||||
|
description: original.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await cleanupFixtures();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
|
||||||
|
if (app) await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4 系统配置 · 读取与权限三态', () => {
|
||||||
|
it('全量配置:定义清单为准,返回阈值元数据 / 版本 / 公开标记', async () => {
|
||||||
|
const result = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(result.body.items).toHaveLength(SYSTEM_CONFIG_DEFINITIONS.length);
|
||||||
|
const limitPage = configOf(result.body.items, CONFIG_KEY);
|
||||||
|
expect(limitPage).toMatchObject({
|
||||||
|
group: 'limits',
|
||||||
|
valueType: 'number',
|
||||||
|
editable: true,
|
||||||
|
isPublic: true,
|
||||||
|
min: 20,
|
||||||
|
max: 500,
|
||||||
|
});
|
||||||
|
expect(limitPage.version).toBeGreaterThanOrEqual(1);
|
||||||
|
const naming = configOf(result.body.items, 'naming.script.pattern');
|
||||||
|
expect(naming.isPublic).toBe(false);
|
||||||
|
expect(naming.value).toBe(systemConfigDefinition('naming.script.pattern')?.defaultValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('权限三态:无权限 403、未登录 401、有权限 200', async () => {
|
||||||
|
const forbidden = await call<{ code: number }>('GET', '/system/configs', readerToken);
|
||||||
|
expect(forbidden.status).toBe(403);
|
||||||
|
expect(forbidden.body.code).toBe(ErrorCode.PERMISSION_DENIED);
|
||||||
|
|
||||||
|
const anonymous = await call<{ code: number }>('GET', '/system/configs');
|
||||||
|
expect(anonymous.status).toBe(401);
|
||||||
|
expect(anonymous.body.code).toBe(ErrorCode.UNAUTHENTICATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('生效配置:仅登录即可读取,只返回 public 项(前端上限入口 R-26)', async () => {
|
||||||
|
const result = await call<{ items: { key: string; value: string | number | boolean }[] }>(
|
||||||
|
'GET',
|
||||||
|
'/system/configs/effective',
|
||||||
|
readerToken,
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
const keys = result.body.items.map((item) => item.key);
|
||||||
|
expect(keys).toContain(CONFIG_KEY);
|
||||||
|
expect(keys).toContain('limit.customer.base_field');
|
||||||
|
expect(keys).not.toContain('naming.script.pattern');
|
||||||
|
expect(keys).not.toContain('platform.export.signed_url_ttl_seconds');
|
||||||
|
|
||||||
|
const anonymous = await call<{ code: number }>('GET', '/system/configs/effective');
|
||||||
|
expect(anonymous.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4 系统配置 · 保存 / 版本历史 / 审计', () => {
|
||||||
|
it('未定义 key、非法值均 40001', async () => {
|
||||||
|
const unknownKey = await call<{ code: number }>('PUT', '/system/configs', adminToken, {
|
||||||
|
changes: [{ key: 'unknown.config', value: 1 }],
|
||||||
|
});
|
||||||
|
expect(unknownKey.status).toBe(400);
|
||||||
|
expect(unknownKey.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const invalidValue = await call<{ code: number }>('PUT', '/system/configs', adminToken, {
|
||||||
|
changes: [{ key: CONFIG_KEY, value: 9999 }],
|
||||||
|
});
|
||||||
|
expect(invalidValue.status).toBe(400);
|
||||||
|
expect(invalidValue.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const noPermission = await call<{ code: number }>('PUT', '/system/configs', readerToken, {
|
||||||
|
changes: [{ key: CONFIG_KEY, value: 120 }],
|
||||||
|
});
|
||||||
|
expect(noPermission.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expectedVersion 不一致返回 40901', async () => {
|
||||||
|
const current = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
|
||||||
|
const staleVersion = configOf(current.body.items, CONFIG_KEY).version + 99;
|
||||||
|
const conflict = await call<{ code: number }>('PUT', '/system/configs', adminToken, {
|
||||||
|
changes: [{ key: CONFIG_KEY, value: 120, expectedVersion: staleVersion }],
|
||||||
|
});
|
||||||
|
expect(conflict.status).toBe(409);
|
||||||
|
expect(conflict.body.code).toBe(ErrorCode.VERSION_CONFLICT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('保存生效:version+1、写版本历史、生效配置同步、操作人解析为姓名', async () => {
|
||||||
|
const before = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
|
||||||
|
const current = configOf(before.body.items, CONFIG_KEY);
|
||||||
|
const targetValue = current.value === 120 ? 130 : 120;
|
||||||
|
const saved = await call<{ items: SystemConfigBody[] }>('PUT', '/system/configs', adminToken, {
|
||||||
|
changes: [
|
||||||
|
{ key: CONFIG_KEY, value: targetValue, expectedVersion: current.version, reason: REASON },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(saved.status).toBe(200);
|
||||||
|
const updated = configOf(saved.body.items, CONFIG_KEY);
|
||||||
|
expect(updated).toMatchObject({ value: targetValue, version: current.version + 1 });
|
||||||
|
|
||||||
|
const effective = await call<{ items: { key: string; value: unknown }[] }>(
|
||||||
|
'GET',
|
||||||
|
'/system/configs/effective',
|
||||||
|
readerToken,
|
||||||
|
);
|
||||||
|
expect(effective.body.items.find((item) => item.key === CONFIG_KEY)?.value).toBe(targetValue);
|
||||||
|
|
||||||
|
const history = await call<{ items: SystemConfigChangeBody[]; next_cursor: string | null }>(
|
||||||
|
'GET',
|
||||||
|
`/system/configs/history?config_key=${CONFIG_KEY}&limit=20`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(history.status).toBe(200);
|
||||||
|
expect(history.body.next_cursor).toBeNull();
|
||||||
|
const latest = history.body.items[0];
|
||||||
|
expect(latest).toMatchObject({
|
||||||
|
key: CONFIG_KEY,
|
||||||
|
label: systemConfigDefinition(CONFIG_KEY)?.label,
|
||||||
|
version: current.version + 1,
|
||||||
|
oldValue: current.value,
|
||||||
|
newValue: targetValue,
|
||||||
|
reason: REASON,
|
||||||
|
operator: 'A4 集成测试admin',
|
||||||
|
result: '已生效',
|
||||||
|
});
|
||||||
|
expect(Number.isNaN(new Date(latest?.changedAt ?? '').getTime())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('相同值跳过:版本号不变、不新增历史', async () => {
|
||||||
|
const before = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
|
||||||
|
const current = configOf(before.body.items, CONFIG_KEY);
|
||||||
|
const saved = await call<{ items: SystemConfigBody[] }>('PUT', '/system/configs', adminToken, {
|
||||||
|
changes: [{ key: CONFIG_KEY, value: current.value, expectedVersion: current.version }],
|
||||||
|
});
|
||||||
|
expect(saved.status).toBe(200);
|
||||||
|
expect(configOf(saved.body.items, CONFIG_KEY).version).toBe(current.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('恢复测试前的配置值', async () => {
|
||||||
|
const before = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
|
||||||
|
const current = configOf(before.body.items, CONFIG_KEY);
|
||||||
|
const originalValue = original?.configValue ?? 100;
|
||||||
|
const restored = await call<{ items: SystemConfigBody[] }>(
|
||||||
|
'PUT',
|
||||||
|
'/system/configs',
|
||||||
|
adminToken,
|
||||||
|
{
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
key: CONFIG_KEY,
|
||||||
|
value: originalValue,
|
||||||
|
expectedVersion: current.version,
|
||||||
|
reason: `${REASON}-恢复`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(restored.status).toBe(200);
|
||||||
|
expect(configOf(restored.body.items, CONFIG_KEY).value).toBe(originalValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('历史查询:无权限 403;limit 越界 40001', async () => {
|
||||||
|
const forbidden = await call<{ code: number }>('GET', '/system/configs/history', readerToken);
|
||||||
|
expect(forbidden.status).toBe(403);
|
||||||
|
|
||||||
|
const invalidLimit = await call<{ code: number }>(
|
||||||
|
'GET',
|
||||||
|
'/system/configs/history?limit=999',
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(invalidLimit.status).toBe(400);
|
||||||
|
expect(invalidLimit.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* 系统配置 Prisma 行 -> 共享 DTO 映射(A4-BE-02)。
|
||||||
|
*
|
||||||
|
* 元数据(标签 / 说明 / 影响面 / 类型 / 上下限 / 是否公开)唯一来源是
|
||||||
|
* `SYSTEM_CONFIG_DEFINITIONS`;`system_configs` 只提供当前值与版本。
|
||||||
|
* DB 缺行时用定义默认值 + version 1 兜底,不写库。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
type SystemConfig,
|
||||||
|
type SystemConfigChange,
|
||||||
|
type SystemConfigDefinition,
|
||||||
|
type SystemConfigValue,
|
||||||
|
systemConfigValueType,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
SystemConfig as PrismaSystemConfig,
|
||||||
|
SystemConfigVersion as PrismaSystemConfigVersion,
|
||||||
|
} from '@prisma/client';
|
||||||
|
|
||||||
|
/** 从未落库的配置:用 epoch 表示「未更新过」,避免伪造读取时间 */
|
||||||
|
const NEVER_UPDATED_AT = new Date(0).toISOString();
|
||||||
|
|
||||||
|
/** Prisma JsonB 只应存原始值;异常结构回退配置默认值,避免脏数据打穿前端 schema */
|
||||||
|
export function normalizeConfigValue(
|
||||||
|
raw: Prisma.JsonValue | undefined,
|
||||||
|
fallback: SystemConfigValue,
|
||||||
|
): SystemConfigValue {
|
||||||
|
if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSystemConfig(
|
||||||
|
definition: SystemConfigDefinition,
|
||||||
|
row: PrismaSystemConfig | undefined,
|
||||||
|
updatedByName?: string | null,
|
||||||
|
): SystemConfig {
|
||||||
|
return {
|
||||||
|
key: definition.key,
|
||||||
|
group: definition.group,
|
||||||
|
label: definition.label,
|
||||||
|
description: definition.description,
|
||||||
|
impact: definition.impact,
|
||||||
|
type: definition.type,
|
||||||
|
value: normalizeConfigValue(row?.configValue, definition.defaultValue),
|
||||||
|
valueType: systemConfigValueType(definition),
|
||||||
|
unit: definition.unit ?? null,
|
||||||
|
version: row?.version ?? 1,
|
||||||
|
updatedAt: row === undefined ? NEVER_UPDATED_AT : row.updatedAt.toISOString(),
|
||||||
|
updatedBy: updatedByName ?? row?.updatedBy ?? null,
|
||||||
|
editable: definition.editable,
|
||||||
|
isPublic: definition.public,
|
||||||
|
min: definition.min ?? null,
|
||||||
|
max: definition.max ?? null,
|
||||||
|
step: definition.step ?? null,
|
||||||
|
maxLength: definition.maxLength ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSystemConfigChange(
|
||||||
|
row: PrismaSystemConfigVersion,
|
||||||
|
label: string,
|
||||||
|
operator: string | null,
|
||||||
|
): SystemConfigChange {
|
||||||
|
return {
|
||||||
|
key: row.configKey,
|
||||||
|
label,
|
||||||
|
version: row.version,
|
||||||
|
oldValue: row.oldValue,
|
||||||
|
newValue: row.newValue,
|
||||||
|
reason: row.reason,
|
||||||
|
operator,
|
||||||
|
changedAt: row.changedAt.toISOString(),
|
||||||
|
result: '已生效',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { ErrorCode, SYSTEM_CONFIG_DEFINITIONS, systemConfigDefinition } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import type { RequestContextService } from '../../../common/security/request-context';
|
||||||
|
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||||
|
import { SystemConfigService } from './system-config.service';
|
||||||
|
|
||||||
|
const NOW = new Date('2026-11-04T02:00:00.000Z');
|
||||||
|
|
||||||
|
function configRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||||
|
configKey: 'platform.name',
|
||||||
|
configValue: '灵犀',
|
||||||
|
valueType: 'string',
|
||||||
|
description: '平台名称',
|
||||||
|
version: 1,
|
||||||
|
isEditable: true,
|
||||||
|
createdAt: NOW,
|
||||||
|
updatedAt: NOW,
|
||||||
|
updatedBy: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
service: SystemConfigService;
|
||||||
|
prisma: {
|
||||||
|
systemConfig: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
systemConfigVersion: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
user: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
$transaction: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
patchAudit: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(
|
||||||
|
prismaInput: Record<string, Record<string, ReturnType<typeof vi.fn>>> = {},
|
||||||
|
): Harness {
|
||||||
|
const systemConfig = {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
...prismaInput.systemConfig,
|
||||||
|
};
|
||||||
|
const systemConfigVersion = {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
...prismaInput.systemConfigVersion,
|
||||||
|
};
|
||||||
|
const user = { findMany: vi.fn().mockResolvedValue([]), ...prismaInput.user };
|
||||||
|
const $transaction = vi.fn(async (callback: unknown) => {
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
return (callback as (client: unknown) => Promise<unknown>)({
|
||||||
|
systemConfig,
|
||||||
|
systemConfigVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.all(callback as Promise<unknown>[]);
|
||||||
|
});
|
||||||
|
const patchAudit = vi.fn();
|
||||||
|
const service = new SystemConfigService(
|
||||||
|
{ systemConfig, systemConfigVersion, user, $transaction } as unknown as PrismaService,
|
||||||
|
{ patchAudit } as unknown as RequestContextService,
|
||||||
|
);
|
||||||
|
return { service, prisma: { systemConfig, systemConfigVersion, user, $transaction }, patchAudit };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||||
|
return promise.then(
|
||||||
|
() => expect.unreachable('应抛出异常'),
|
||||||
|
(error: unknown) => {
|
||||||
|
expect(error).toBeInstanceOf(AppException);
|
||||||
|
expect((error as AppException).code).toBe(code);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SystemConfigService · 列表与生效配置', () => {
|
||||||
|
it('以定义清单为元数据源合并当前值;DB 缺行用默认值 + version 1 兜底', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
const result = await service.list();
|
||||||
|
expect(result.items).toHaveLength(SYSTEM_CONFIG_DEFINITIONS.length);
|
||||||
|
const platformName = result.items.find((item) => item.key === 'platform.name');
|
||||||
|
expect(platformName).toMatchObject({
|
||||||
|
label: '平台名称',
|
||||||
|
value: '灵犀',
|
||||||
|
valueType: 'string',
|
||||||
|
version: 1,
|
||||||
|
updatedBy: null,
|
||||||
|
isPublic: true,
|
||||||
|
maxLength: 32,
|
||||||
|
min: null,
|
||||||
|
});
|
||||||
|
expect(platformName?.updatedAt).toBe(new Date(0).toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DB 有行时使用库内值 / 版本 / 更新人,元数据仍取定义', async () => {
|
||||||
|
const { service } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([configRow({ configValue: '灵犀 Pro', version: 4, updatedBy: 'u1' })]),
|
||||||
|
},
|
||||||
|
user: { findMany: vi.fn().mockResolvedValue([{ id: 'u1', name: '江岚' }]) },
|
||||||
|
});
|
||||||
|
const result = await service.list();
|
||||||
|
const platformName = result.items.find((item) => item.key === 'platform.name');
|
||||||
|
expect(platformName).toMatchObject({
|
||||||
|
value: '灵犀 Pro',
|
||||||
|
version: 4,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
updatedAt: NOW.toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('生效配置只返回 public 项(不含命名模板等非公开配置)', async () => {
|
||||||
|
const { service } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([configRow({ configValue: '灵犀 Pro' })]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.getEffective();
|
||||||
|
const keys = result.items.map((item) => item.key);
|
||||||
|
expect(keys).toContain('platform.name');
|
||||||
|
expect(keys).toContain('limit.customer.base_field');
|
||||||
|
expect(keys).not.toContain('naming.script.pattern');
|
||||||
|
expect(result.items.find((item) => item.key === 'platform.name')?.value).toBe('灵犀 Pro');
|
||||||
|
expect(result.items.find((item) => item.key === 'limit.customer.base_field')?.value).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SystemConfigService · 批量保存', () => {
|
||||||
|
it('未定义 key / 非法值返回 40001', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expectRejected(
|
||||||
|
service.update({ changes: [{ key: 'unknown.key', value: 1 }] }, 'u1'),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
await expectRejected(
|
||||||
|
service.update({ changes: [{ key: 'limit.customer.base_field', value: 999 }] }, 'u1'),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expectedVersion 不一致返回 40901,且不写库', async () => {
|
||||||
|
const { service, prisma } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
configRow({ configKey: 'limit.customer.base_field', configValue: 10, version: 2 }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
service.update(
|
||||||
|
{
|
||||||
|
changes: [
|
||||||
|
{ key: 'limit.customer.base_field', value: 12, expectedVersion: 1, reason: '调整上限' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
ErrorCode.VERSION_CONFLICT,
|
||||||
|
);
|
||||||
|
expect(prisma.systemConfig.updateMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.systemConfigVersion.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('条件更新命中 0 行(并发修改)返回 40901', async () => {
|
||||||
|
const { service } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
configRow({ configKey: 'limit.customer.base_field', configValue: 10 }),
|
||||||
|
),
|
||||||
|
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
service.update({ changes: [{ key: 'limit.customer.base_field', value: 12 }] }, 'u1'),
|
||||||
|
ErrorCode.VERSION_CONFLICT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('值相同跳过:不写库、不写版本历史', async () => {
|
||||||
|
const { service, prisma, patchAudit } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
configRow({ configKey: 'limit.customer.base_field', configValue: 10 }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await service.update({ changes: [{ key: 'limit.customer.base_field', value: 10 }] }, 'u1');
|
||||||
|
expect(prisma.systemConfig.updateMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.systemConfigVersion.create).not.toHaveBeenCalled();
|
||||||
|
expect(patchAudit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成功保存:version+1、写版本历史(old/new/reason/changedBy)与审计前后值', async () => {
|
||||||
|
const before = configRow({
|
||||||
|
configKey: 'limit.customer.base_field',
|
||||||
|
configValue: 10,
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const { service, prisma, patchAudit } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(before),
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
configRow({ configKey: 'limit.customer.base_field', configValue: 12, version: 2 }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.update(
|
||||||
|
{
|
||||||
|
changes: [
|
||||||
|
{ key: 'limit.customer.base_field', value: 12, expectedVersion: 1, reason: 'A4 验收' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
expect(prisma.systemConfig.updateMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { configKey: 'limit.customer.base_field', version: 1 },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
configValue: 12,
|
||||||
|
version: { increment: 1 },
|
||||||
|
updatedBy: 'u1',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(prisma.systemConfigVersion.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
configKey: 'limit.customer.base_field',
|
||||||
|
version: 2,
|
||||||
|
oldValue: 10,
|
||||||
|
newValue: 12,
|
||||||
|
reason: 'A4 验收',
|
||||||
|
changedBy: 'u1',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(patchAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
targetType: 'system_config',
|
||||||
|
before: { 'limit.customer.base_field': 10 },
|
||||||
|
after: { 'limit.customer.base_field': 12 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.items).toHaveLength(SYSTEM_CONFIG_DEFINITIONS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DB 缺行时首次落库 version 1 并写版本历史(oldValue 为空)', async () => {
|
||||||
|
const { service, prisma } = harness({
|
||||||
|
systemConfig: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||||
|
});
|
||||||
|
await service.update({ changes: [{ key: 'platform.name', value: '灵犀 Pro' }] }, 'u1');
|
||||||
|
expect(prisma.systemConfig.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ version: 1, configValue: '灵犀 Pro' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(prisma.systemConfigVersion.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ version: 1, newValue: '灵犀 Pro' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SystemConfigService · 变更历史', () => {
|
||||||
|
it('按倒序读取、解析操作人姓名、label 取定义、result 固定已生效', async () => {
|
||||||
|
const { service, prisma } = harness({
|
||||||
|
systemConfigVersion: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'v1',
|
||||||
|
configKey: 'limit.customer.base_field',
|
||||||
|
version: 2,
|
||||||
|
oldValue: 10,
|
||||||
|
newValue: 12,
|
||||||
|
reason: 'A4 验收',
|
||||||
|
changedBy: 'u1',
|
||||||
|
changedAt: NOW,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
user: { findMany: vi.fn().mockResolvedValue([{ id: 'u1', name: '江岚' }]) },
|
||||||
|
});
|
||||||
|
const result = await service.history({ limit: 50 });
|
||||||
|
expect(prisma.systemConfigVersion.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ orderBy: [{ changedAt: 'desc' }, { id: 'desc' }], take: 50 }),
|
||||||
|
);
|
||||||
|
expect(result.next_cursor).toBeNull();
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
key: 'limit.customer.base_field',
|
||||||
|
label: systemConfigDefinition('limit.customer.base_field')?.label,
|
||||||
|
version: 2,
|
||||||
|
oldValue: 10,
|
||||||
|
newValue: 12,
|
||||||
|
reason: 'A4 验收',
|
||||||
|
operator: '江岚',
|
||||||
|
changedAt: NOW.toISOString(),
|
||||||
|
result: '已生效',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('支持 config_key 过滤;操作人用户已删除时 operator 为 null', async () => {
|
||||||
|
const { service, prisma } = harness({
|
||||||
|
systemConfigVersion: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'v2',
|
||||||
|
configKey: 'platform.name',
|
||||||
|
version: 3,
|
||||||
|
oldValue: '灵犀',
|
||||||
|
newValue: '灵犀 Pro',
|
||||||
|
reason: null,
|
||||||
|
changedBy: 'deleted-user',
|
||||||
|
changedAt: NOW,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.history({ config_key: 'platform.name', limit: 20 });
|
||||||
|
expect(prisma.systemConfigVersion.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { configKey: 'platform.name' } }),
|
||||||
|
);
|
||||||
|
expect(result.items[0]?.operator).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SystemConfigService · 阈值读取(R-26)', () => {
|
||||||
|
it('缺行时回退定义默认值;读库异常时回退调用方兜底值', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expect(service.getNumber('limit.customer.base_field')).resolves.toBe(10);
|
||||||
|
await expect(service.getBoolean('notify.task.completed')).resolves.toBe(true);
|
||||||
|
await expect(service.getString('platform.name')).resolves.toBe('灵犀');
|
||||||
|
await expect(service.getNamingPattern('script')).resolves.toBe(
|
||||||
|
'{行业}-{客户}-{脚本方向}-{日期}-{序号}',
|
||||||
|
);
|
||||||
|
|
||||||
|
const broken = harness({
|
||||||
|
systemConfig: { findUnique: vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')) },
|
||||||
|
});
|
||||||
|
await expect(broken.service.getNumber('limit.role.max', 20)).resolves.toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('库内命名模板非法时回退定义默认模板', async () => {
|
||||||
|
const { service } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(configRow({ configValue: '{未知占位符}*' })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(service.getNamingPattern('image')).resolves.toBe(
|
||||||
|
'{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 批量事务更新需逐条校验 / 乐观锁 / 版本历史,集中保证事务边界与审计口径一致 */
|
||||||
|
import {
|
||||||
|
type EffectiveSystemConfigsResponse,
|
||||||
|
ErrorCode,
|
||||||
|
NAMING_MAX_LENGTH,
|
||||||
|
SYSTEM_CONFIG_DEFINITIONS,
|
||||||
|
systemConfigDefinition,
|
||||||
|
type SystemConfigHistoryQuery,
|
||||||
|
type SystemConfigHistoryResponse,
|
||||||
|
type SystemConfigListResponse,
|
||||||
|
type SystemConfigValue,
|
||||||
|
systemConfigValueType,
|
||||||
|
ulid,
|
||||||
|
type UpdateSystemConfigsInput,
|
||||||
|
validateNamingTemplate,
|
||||||
|
validateSystemConfigValue,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import { RequestContextService } from '../../../common/security/request-context';
|
||||||
|
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../../infra/tokens';
|
||||||
|
import { normalizeConfigValue, toSystemConfig, toSystemConfigChange } from './system-config.mapper';
|
||||||
|
|
||||||
|
interface ConfigChangeSnapshot {
|
||||||
|
key: string;
|
||||||
|
oldValue: SystemConfigValue | null;
|
||||||
|
newValue: SystemConfigValue;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NamingKind = 'script' | 'image' | 'video';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置(A4-BE-02 / PRD R-26):阈值 / 上限的唯一读取入口。
|
||||||
|
*
|
||||||
|
* - 元数据来自 `SYSTEM_CONFIG_DEFINITIONS`,值来自 `system_configs`;
|
||||||
|
* - 批量保存走事务 + 乐观锁(expectedVersion,冲突 40901),每次变更写 `system_config_versions`;
|
||||||
|
* - 其他模块只通过本 Service 读配置,禁止再硬编码阈值(R-26)。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SystemConfigService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 全量配置(含非公开项,需 system:configure) */
|
||||||
|
async list(): Promise<SystemConfigListResponse> {
|
||||||
|
const rows = await this.prisma.systemConfig.findMany();
|
||||||
|
const byKey = new Map(rows.map((row) => [row.configKey, row]));
|
||||||
|
const nameById = await this.userNames(
|
||||||
|
rows.flatMap((row) => (row.updatedBy === null ? [] : [row.updatedBy])),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
items: SYSTEM_CONFIG_DEFINITIONS.map((definition) => {
|
||||||
|
const row = byKey.get(definition.key);
|
||||||
|
return toSystemConfig(
|
||||||
|
definition,
|
||||||
|
row,
|
||||||
|
row?.updatedBy === null || row?.updatedBy === undefined
|
||||||
|
? null
|
||||||
|
: (nameById.get(row.updatedBy) ?? null),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生效配置:只返回 public 项,供前端读取上限(R-26),仅登录即可 */
|
||||||
|
async getEffective(): Promise<EffectiveSystemConfigsResponse> {
|
||||||
|
const publicDefinitions = SYSTEM_CONFIG_DEFINITIONS.filter((definition) => definition.public);
|
||||||
|
const rows = await this.prisma.systemConfig.findMany({
|
||||||
|
where: { configKey: { in: publicDefinitions.map((definition) => definition.key) } },
|
||||||
|
});
|
||||||
|
const byKey = new Map(rows.map((row) => [row.configKey, row]));
|
||||||
|
return {
|
||||||
|
items: publicDefinitions.map((definition) => ({
|
||||||
|
key: definition.key,
|
||||||
|
value: normalizeConfigValue(
|
||||||
|
byKey.get(definition.key)?.configValue,
|
||||||
|
definition.defaultValue,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量保存:事务内逐条校验 + 乐观锁 + 版本历史;值未变化可跳过(不写历史)。
|
||||||
|
* 返回保存后的全量配置,便于前端原地刷新版本号。
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
input: UpdateSystemConfigsInput,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<SystemConfigListResponse> {
|
||||||
|
const snapshots = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const changed: ConfigChangeSnapshot[] = [];
|
||||||
|
for (const change of input.changes) {
|
||||||
|
const definition = systemConfigDefinition(change.key);
|
||||||
|
if (definition === undefined) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: `配置项不存在:${change.key}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const validated = validateSystemConfigValue(definition, change.value);
|
||||||
|
if (!validated.ok) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: `${definition.label}:${validated.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const row = await tx.systemConfig.findUnique({ where: { configKey: change.key } });
|
||||||
|
const baseVersion = row?.version ?? 1;
|
||||||
|
if (change.expectedVersion !== undefined && change.expectedVersion !== baseVersion) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.VERSION_CONFLICT,
|
||||||
|
message: `配置「${definition.label}」已被他人修改,请刷新后重试`,
|
||||||
|
details: {
|
||||||
|
key: change.key,
|
||||||
|
expectedVersion: change.expectedVersion,
|
||||||
|
currentVersion: baseVersion,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const baseValue = normalizeConfigValue(row?.configValue, definition.defaultValue);
|
||||||
|
if (baseValue === validated.value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nextVersion = row === null ? 1 : row.version + 1;
|
||||||
|
if (row === null) {
|
||||||
|
await tx.systemConfig.create({
|
||||||
|
data: {
|
||||||
|
id: ulid(),
|
||||||
|
configKey: change.key,
|
||||||
|
configValue: validated.value,
|
||||||
|
valueType: systemConfigValueType(definition),
|
||||||
|
description: definition.description,
|
||||||
|
version: 1,
|
||||||
|
isEditable: definition.editable,
|
||||||
|
updatedBy: actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.systemConfigVersion.create({
|
||||||
|
data: {
|
||||||
|
id: ulid(),
|
||||||
|
configKey: change.key,
|
||||||
|
version: 1,
|
||||||
|
newValue: validated.value,
|
||||||
|
...(change.reason === undefined ? {} : { reason: change.reason }),
|
||||||
|
changedBy: actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 条件更新(DB-29):并发保存时只有一个请求能命中旧 version,其余返回 40901
|
||||||
|
const conditional = await tx.systemConfig.updateMany({
|
||||||
|
where: { configKey: change.key, version: row.version },
|
||||||
|
data: {
|
||||||
|
configValue: validated.value,
|
||||||
|
version: { increment: 1 },
|
||||||
|
description: definition.description,
|
||||||
|
updatedBy: actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (conditional.count === 0) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.VERSION_CONFLICT,
|
||||||
|
message: `配置「${definition.label}」已被他人修改,请刷新后重试`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.systemConfigVersion.create({
|
||||||
|
data: {
|
||||||
|
id: ulid(),
|
||||||
|
configKey: change.key,
|
||||||
|
version: nextVersion,
|
||||||
|
oldValue: baseValue,
|
||||||
|
newValue: validated.value,
|
||||||
|
reason: change.reason ?? null,
|
||||||
|
changedBy: actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
changed.push({
|
||||||
|
key: change.key,
|
||||||
|
oldValue: row === null ? null : baseValue,
|
||||||
|
newValue: validated.value,
|
||||||
|
version: nextVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (snapshots.length > 0) {
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'system_config',
|
||||||
|
targetId: snapshots.map((snapshot) => snapshot.key).join(','),
|
||||||
|
before: Object.fromEntries(snapshots.map((snapshot) => [snapshot.key, snapshot.oldValue])),
|
||||||
|
after: Object.fromEntries(snapshots.map((snapshot) => [snapshot.key, snapshot.newValue])),
|
||||||
|
changes: snapshots,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更记录:按 changed_at desc;操作人解析为用户姓名 */
|
||||||
|
async history(query: SystemConfigHistoryQuery): Promise<SystemConfigHistoryResponse> {
|
||||||
|
const rows = await this.prisma.systemConfigVersion.findMany({
|
||||||
|
where: query.config_key === undefined ? {} : { configKey: query.config_key },
|
||||||
|
orderBy: [{ changedAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: query.limit,
|
||||||
|
});
|
||||||
|
const changedByIds = [
|
||||||
|
...new Set(rows.flatMap((row) => (row.changedBy === null ? [] : [row.changedBy]))),
|
||||||
|
];
|
||||||
|
const nameById = await this.userNames(changedByIds);
|
||||||
|
return {
|
||||||
|
items: rows.map((row) => {
|
||||||
|
const label = systemConfigDefinition(row.configKey)?.label ?? row.configKey;
|
||||||
|
const operator = row.changedBy === null ? null : (nameById.get(row.changedBy) ?? null);
|
||||||
|
return toSystemConfigChange(row, label, operator);
|
||||||
|
}),
|
||||||
|
next_cursor: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量解析 updated_by / changed_by(ULID)为用户姓名,供列表与历史展示 */
|
||||||
|
private async userNames(userIds: readonly string[]): Promise<Map<string, string>> {
|
||||||
|
const uniqueIds = [...new Set(userIds)];
|
||||||
|
if (uniqueIds.length === 0) return new Map();
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: uniqueIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
return new Map(users.map((user) => [user.id, user.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 供其他模块读取配置(R-26):读失败 / 类型不符时回退定义默认值或调用方兜底
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async getNumber(key: string, fallback?: number): Promise<number> {
|
||||||
|
const value = await this.readValue(key);
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (fallback !== undefined) return fallback;
|
||||||
|
const definition = systemConfigDefinition(key);
|
||||||
|
if (definition !== undefined && typeof definition.defaultValue === 'number') {
|
||||||
|
return definition.defaultValue;
|
||||||
|
}
|
||||||
|
throw new Error(`系统配置「${key}」不是数字且缺少兜底值`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBoolean(key: string, fallback?: boolean): Promise<boolean> {
|
||||||
|
const value = await this.readValue(key);
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (fallback !== undefined) return fallback;
|
||||||
|
const definition = systemConfigDefinition(key);
|
||||||
|
if (definition !== undefined && typeof definition.defaultValue === 'boolean') {
|
||||||
|
return definition.defaultValue;
|
||||||
|
}
|
||||||
|
throw new Error(`系统配置「${key}」不是布尔值且缺少兜底值`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getString(key: string, fallback?: string): Promise<string> {
|
||||||
|
const value = await this.readValue(key);
|
||||||
|
if (typeof value === 'string' && value.length > 0) return value;
|
||||||
|
if (fallback !== undefined) return fallback;
|
||||||
|
const definition = systemConfigDefinition(key);
|
||||||
|
if (definition !== undefined && typeof definition.defaultValue === 'string') {
|
||||||
|
return definition.defaultValue;
|
||||||
|
}
|
||||||
|
throw new Error(`系统配置「${key}」不是字符串且缺少兜底值`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取命名模板类配置:库内模板非法时回退默认模板,避免脏配置导致命名全线失败 */
|
||||||
|
async getPattern(key: string, fallback?: string): Promise<string> {
|
||||||
|
const definition = systemConfigDefinition(key);
|
||||||
|
const value = await this.readValue(key);
|
||||||
|
const maxLength = definition?.maxLength ?? NAMING_MAX_LENGTH;
|
||||||
|
if (typeof value === 'string' && validateNamingTemplate(value, { maxLength }).valid) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (fallback !== undefined) return fallback;
|
||||||
|
if (definition !== undefined && typeof definition.defaultValue === 'string') {
|
||||||
|
return definition.defaultValue;
|
||||||
|
}
|
||||||
|
throw new Error(`系统配置「${key}」不是合法命名模板且缺少兜底值`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNamingPattern(kind: NamingKind): Promise<string> {
|
||||||
|
return this.getPattern(`naming.${kind}.pattern`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读库失败(连接抖动等)不阻断业务:返回 undefined,由调用方兜底默认值 */
|
||||||
|
private async readValue(key: string): Promise<SystemConfigValue | undefined> {
|
||||||
|
const definition = systemConfigDefinition(key);
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.systemConfig.findUnique({
|
||||||
|
where: { configKey: key },
|
||||||
|
select: { configValue: true },
|
||||||
|
});
|
||||||
|
if (row === null) return undefined;
|
||||||
|
return normalizeConfigValue(row.configValue, definition?.defaultValue ?? '');
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { SystemConfig, SystemConfigChange } from '@lingxi/shared';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
import { setUnauthorizedHandler } from './client';
|
||||||
|
import {
|
||||||
|
fetchEffectiveSystemConfigs,
|
||||||
|
fetchSystemConfigHistory,
|
||||||
|
fetchSystemConfigs,
|
||||||
|
updateSystemConfigs,
|
||||||
|
} from './configs';
|
||||||
|
|
||||||
|
const NOW = '2026-11-04T10:00:00.000Z';
|
||||||
|
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||||
|
new Response(status === 204 ? null : JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: status === 204 ? undefined : { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const config: SystemConfig = {
|
||||||
|
key: 'limit.customer.base_field',
|
||||||
|
group: 'limits',
|
||||||
|
label: '客户基础字段上限',
|
||||||
|
description: '每客户基础字段的最大数量',
|
||||||
|
impact: '前后端共用;修改后新申请按新上限校验',
|
||||||
|
type: 'number',
|
||||||
|
value: 10,
|
||||||
|
valueType: 'number',
|
||||||
|
unit: '个',
|
||||||
|
version: 3,
|
||||||
|
updatedAt: NOW,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
editable: true,
|
||||||
|
isPublic: true,
|
||||||
|
min: 1,
|
||||||
|
max: 50,
|
||||||
|
step: null,
|
||||||
|
maxLength: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const change: SystemConfigChange = {
|
||||||
|
key: 'limit.customer.base_field',
|
||||||
|
label: '客户基础字段上限',
|
||||||
|
version: 3,
|
||||||
|
oldValue: 8,
|
||||||
|
newValue: 10,
|
||||||
|
reason: '在系统配置页调整',
|
||||||
|
operator: '江岚',
|
||||||
|
changedAt: NOW,
|
||||||
|
result: '已生效',
|
||||||
|
};
|
||||||
|
|
||||||
|
function lastCall(): { url: string; init: RequestInit | undefined } {
|
||||||
|
const fetchMock = vi.mocked(globalThis.fetch);
|
||||||
|
const call = fetchMock.mock.calls.at(-1);
|
||||||
|
const input = call?.[0];
|
||||||
|
const url =
|
||||||
|
typeof input === 'string' ? input : input instanceof URL ? input.href : (input?.url ?? '');
|
||||||
|
return { url, init: call?.[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastBody(): unknown {
|
||||||
|
const body = lastCall().init?.body;
|
||||||
|
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
window.localStorage.clear();
|
||||||
|
useAuthStore.getState().clearSession();
|
||||||
|
setUnauthorizedHandler(null);
|
||||||
|
useAuthStore.getState().setSession({
|
||||||
|
accessToken: 'token',
|
||||||
|
user: { id: ID, name: '江岚', account: 'jiang.lan' },
|
||||||
|
permissions: ['system:configure'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('系统配置接口封装', () => {
|
||||||
|
it('全量配置 / 生效配置', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ items: [config] }))
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ items: [{ key: config.key, value: 10 }] }));
|
||||||
|
|
||||||
|
const list = await fetchSystemConfigs();
|
||||||
|
expect(list.items).toHaveLength(1);
|
||||||
|
expect(lastCall().url).toContain('/system/configs');
|
||||||
|
|
||||||
|
const effective = await fetchEffectiveSystemConfigs();
|
||||||
|
expect(effective.items[0]?.value).toBe(10);
|
||||||
|
expect(lastCall().url).toContain('/system/configs/effective');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('批量保存:PUT changes 带 expectedVersion(乐观锁)', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse({ items: [config] }));
|
||||||
|
await updateSystemConfigs({
|
||||||
|
changes: [{ key: 'limit.customer.base_field', value: 12, expectedVersion: 3 }],
|
||||||
|
});
|
||||||
|
expect(lastCall().init?.method).toBe('PUT');
|
||||||
|
expect(lastBody()).toEqual({
|
||||||
|
changes: [{ key: 'limit.customer.base_field', value: 12, expectedVersion: 3 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('变更记录:query 用 snake_case(config_key / limit)', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||||
|
jsonResponse({ items: [change], next_cursor: null }),
|
||||||
|
);
|
||||||
|
const history = await fetchSystemConfigHistory({
|
||||||
|
config_key: 'limit.customer.base_field',
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
expect(history.items[0]?.version).toBe(3);
|
||||||
|
expect(lastCall().url).toContain('/system/configs/history?');
|
||||||
|
expect(lastCall().url).toContain('config_key=');
|
||||||
|
expect(lastCall().url).toContain('limit=50');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* A4 系统配置接口(唯一出处)。
|
||||||
|
* 依据:packages/shared/src/schemas/config.ts、PRD R-26、技术架构 §5.7。
|
||||||
|
*
|
||||||
|
* `system_configs` 是阈值 / 上限唯一来源:页面保存走 PUT changes + expectedVersion 乐观锁;
|
||||||
|
* 前端上限读取走 /system/configs/effective(仅登录,只读)。响应 / 入参全部 shared Zod parse。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
type EffectiveSystemConfig,
|
||||||
|
effectiveSystemConfigsResponseSchema,
|
||||||
|
type SystemConfig,
|
||||||
|
type SystemConfigChange,
|
||||||
|
type SystemConfigHistoryQuery,
|
||||||
|
systemConfigHistoryQuerySchema,
|
||||||
|
systemConfigHistoryResponseSchema,
|
||||||
|
systemConfigListResponseSchema,
|
||||||
|
type UpdateSystemConfigsInput,
|
||||||
|
updateSystemConfigsInputSchema,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
|
||||||
|
import { request } from './client';
|
||||||
|
import { ENDPOINTS } from './endpoints';
|
||||||
|
|
||||||
|
function searchParams(entries: Record<string, string | number | undefined>): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(entries)) {
|
||||||
|
if (value !== undefined && value !== '') params.set(key, String(value));
|
||||||
|
}
|
||||||
|
const search = params.toString();
|
||||||
|
return search === '' ? '' : `?${search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全量配置(含非公开项,需 system:configure) */
|
||||||
|
export async function fetchSystemConfigs(): Promise<{ items: SystemConfig[] }> {
|
||||||
|
const payload: unknown = await request(ENDPOINTS.systemConfigs);
|
||||||
|
return systemConfigListResponseSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生效配置(仅登录;前端上限读取,无需 system:configure) */
|
||||||
|
export async function fetchEffectiveSystemConfigs(): Promise<{
|
||||||
|
items: EffectiveSystemConfig[];
|
||||||
|
}> {
|
||||||
|
const payload: unknown = await request(ENDPOINTS.systemConfigsEffective);
|
||||||
|
return effectiveSystemConfigsResponseSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量保存:changes 带 expectedVersion(不一致返回 40901) */
|
||||||
|
export async function updateSystemConfigs(
|
||||||
|
input: UpdateSystemConfigsInput,
|
||||||
|
): Promise<{ items: SystemConfig[] }> {
|
||||||
|
const body = updateSystemConfigsInputSchema.parse(input);
|
||||||
|
const payload: unknown = await request(ENDPOINTS.systemConfigs, { method: 'PUT', body });
|
||||||
|
return systemConfigListResponseSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更记录(含前后值、操作人、时间、原因与结果) */
|
||||||
|
export async function fetchSystemConfigHistory(
|
||||||
|
query: Partial<SystemConfigHistoryQuery> = {},
|
||||||
|
): Promise<{ items: SystemConfigChange[]; next_cursor: string | null }> {
|
||||||
|
const parsed = systemConfigHistoryQuerySchema.parse(query);
|
||||||
|
const payload: unknown = await request(
|
||||||
|
`${ENDPOINTS.systemConfigsHistory}${searchParams({
|
||||||
|
config_key: parsed.config_key,
|
||||||
|
limit: parsed.limit,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
return systemConfigHistoryResponseSchema.parse(payload);
|
||||||
|
}
|
||||||
@@ -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,209 @@
|
|||||||
|
import type {
|
||||||
|
DictItem,
|
||||||
|
DictItemListResponse,
|
||||||
|
DictTypeDetail,
|
||||||
|
DictTypeSummary,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
import { setUnauthorizedHandler } from './client';
|
||||||
|
import {
|
||||||
|
createDictItem,
|
||||||
|
createDictType,
|
||||||
|
deleteDictType,
|
||||||
|
fetchDictItemList,
|
||||||
|
fetchDictTypeDetail,
|
||||||
|
fetchDictTypeList,
|
||||||
|
setDictTypeStatus,
|
||||||
|
updateDictItem,
|
||||||
|
updateDictType,
|
||||||
|
} from './dicts';
|
||||||
|
|
||||||
|
const NOW = '2026-11-04T10:00:00.000Z';
|
||||||
|
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||||
|
new Response(status === 204 ? null : JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: status === 204 ? undefined : { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeSummary: DictTypeSummary = {
|
||||||
|
code: 'script_direction',
|
||||||
|
name: '脚本方向',
|
||||||
|
group: 'content',
|
||||||
|
kind: 'list',
|
||||||
|
description: '脚本“说什么”的方向',
|
||||||
|
industryScoped: true,
|
||||||
|
builtin: true,
|
||||||
|
status: 'active',
|
||||||
|
sortOrder: 60,
|
||||||
|
itemCount: 11,
|
||||||
|
activeItemCount: 11,
|
||||||
|
industryCount: 4,
|
||||||
|
updatedAt: NOW,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
};
|
||||||
|
|
||||||
|
const item: DictItem = {
|
||||||
|
id: ID,
|
||||||
|
typeCode: 'script_direction',
|
||||||
|
code: 'oral',
|
||||||
|
name: '口播',
|
||||||
|
parentCode: null,
|
||||||
|
parentName: null,
|
||||||
|
industryId: null,
|
||||||
|
industryPath: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
source: 'generic',
|
||||||
|
overridden: false,
|
||||||
|
refCount: 15,
|
||||||
|
updatedAt: NOW,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeDetail: DictTypeDetail = { ...typeSummary, items: [item] };
|
||||||
|
|
||||||
|
const itemList: DictItemListResponse = {
|
||||||
|
items: [item],
|
||||||
|
next_cursor: null,
|
||||||
|
layers: [
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 5 },
|
||||||
|
{ source: 'parent', industryId: ID, label: '父行业', count: 2 },
|
||||||
|
{ source: 'current', industryId: ID, label: '本行业', count: 1 },
|
||||||
|
],
|
||||||
|
effectiveCount: 7,
|
||||||
|
overriddenCount: 1,
|
||||||
|
disabledCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function lastCall(): { url: string; init: RequestInit | undefined } {
|
||||||
|
const fetchMock = vi.mocked(globalThis.fetch);
|
||||||
|
const call = fetchMock.mock.calls.at(-1);
|
||||||
|
const input = call?.[0];
|
||||||
|
const url =
|
||||||
|
typeof input === 'string' ? input : input instanceof URL ? input.href : (input?.url ?? '');
|
||||||
|
return { url, init: call?.[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastBody(): unknown {
|
||||||
|
const body = lastCall().init?.body;
|
||||||
|
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
window.localStorage.clear();
|
||||||
|
useAuthStore.getState().clearSession();
|
||||||
|
setUnauthorizedHandler(null);
|
||||||
|
useAuthStore.getState().setSession({
|
||||||
|
accessToken: 'token',
|
||||||
|
user: { id: ID, name: '江岚', account: 'jiang.lan' },
|
||||||
|
permissions: ['dict:manage'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('数据字典接口封装', () => {
|
||||||
|
it('类型列表:query 用 snake_case(keyword / group / status / limit / cursor)', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||||
|
jsonResponse({ items: [typeSummary], next_cursor: 'cursor-2' }),
|
||||||
|
);
|
||||||
|
const result = await fetchDictTypeList({
|
||||||
|
keyword: '脚本',
|
||||||
|
group: 'content',
|
||||||
|
status: 'active',
|
||||||
|
limit: 100,
|
||||||
|
cursor: 'cursor-1',
|
||||||
|
});
|
||||||
|
expect(result.next_cursor).toBe('cursor-2');
|
||||||
|
expect(lastCall().url).toContain('/dicts?keyword=');
|
||||||
|
expect(lastCall().url).toContain('group=content');
|
||||||
|
expect(lastCall().url).toContain('status=active');
|
||||||
|
expect(lastCall().url).toContain('limit=100');
|
||||||
|
expect(lastCall().url).toContain('cursor=cursor-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('类型详情 / 新建 / 更新 / 状态 / 删除', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(jsonResponse(typeDetail))
|
||||||
|
.mockResolvedValueOnce(jsonResponse(typeDetail, 201))
|
||||||
|
.mockResolvedValueOnce(jsonResponse(typeDetail))
|
||||||
|
.mockResolvedValueOnce(jsonResponse(typeDetail))
|
||||||
|
.mockResolvedValueOnce(jsonResponse(undefined, 204));
|
||||||
|
|
||||||
|
await fetchDictTypeDetail('script_direction');
|
||||||
|
expect(lastCall().url).toContain('/dicts/script_direction');
|
||||||
|
|
||||||
|
await createDictType({
|
||||||
|
code: 'delivery_slot',
|
||||||
|
name: '投放位置',
|
||||||
|
group: 'platform',
|
||||||
|
kind: 'list',
|
||||||
|
industryScoped: false,
|
||||||
|
description: '',
|
||||||
|
sortOrder: 10,
|
||||||
|
});
|
||||||
|
expect(lastCall().init?.method).toBe('POST');
|
||||||
|
expect(lastBody()).toMatchObject({ code: 'delivery_slot', industryScoped: false });
|
||||||
|
|
||||||
|
await updateDictType('script_direction', { name: '脚本方向2' });
|
||||||
|
expect(lastCall().init?.method).toBe('PATCH');
|
||||||
|
expect(lastBody()).toEqual({ name: '脚本方向2' });
|
||||||
|
|
||||||
|
await setDictTypeStatus('script_direction', { status: 'disabled' });
|
||||||
|
expect(lastCall().url).toContain('/dicts/script_direction/status');
|
||||||
|
expect(lastBody()).toEqual({ status: 'disabled' });
|
||||||
|
|
||||||
|
await deleteDictType('script_direction');
|
||||||
|
expect(lastCall().init?.method).toBe('DELETE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('条目列表:industry_id 只在传入行业视角时出现', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(jsonResponse(itemList))
|
||||||
|
.mockResolvedValueOnce(jsonResponse(itemList));
|
||||||
|
|
||||||
|
await fetchDictItemList('script_direction', { industry_id: ID });
|
||||||
|
expect(lastCall().url).toContain('/dicts/script_direction/items?industry_id=');
|
||||||
|
|
||||||
|
await fetchDictItemList('script_direction');
|
||||||
|
expect(lastCall().url).not.toContain('industry_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('新建 / 编辑条目:body 用 camelCase,编辑不提交编码与行业', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(jsonResponse(item, 201))
|
||||||
|
.mockResolvedValueOnce(jsonResponse(item));
|
||||||
|
|
||||||
|
await createDictItem('script_direction', {
|
||||||
|
code: 'oral',
|
||||||
|
name: '口播',
|
||||||
|
parentCode: null,
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
});
|
||||||
|
expect(lastCall().init?.method).toBe('POST');
|
||||||
|
expect(lastBody()).toMatchObject({ code: 'oral', parentCode: null, industryId: null });
|
||||||
|
|
||||||
|
await updateDictItem('script_direction', ID, {
|
||||||
|
name: '口播(新)',
|
||||||
|
sortOrder: 20,
|
||||||
|
status: 'disabled',
|
||||||
|
remark: '说明',
|
||||||
|
});
|
||||||
|
expect(lastCall().url).toContain(`/dicts/script_direction/items/${ID}`);
|
||||||
|
expect(lastCall().init?.method).toBe('PATCH');
|
||||||
|
expect(lastBody()).toEqual({
|
||||||
|
name: '口播(新)',
|
||||||
|
sortOrder: 20,
|
||||||
|
status: 'disabled',
|
||||||
|
remark: '说明',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* A4 数据字典接口(唯一出处)。
|
||||||
|
* 依据:packages/shared/src/schemas/dict.ts、PRD R-10、技术架构 §6.3.7。
|
||||||
|
*
|
||||||
|
* 约定:query 用 snake_case(行业视角 `industry_id`);写接口 body 用 camelCase(C-01)。
|
||||||
|
* 所有响应 / 入参都用 shared Zod schema parse,契约漂移在请求层直接暴露。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
type CreateDictItemInput,
|
||||||
|
createDictItemInputSchema,
|
||||||
|
type CreateDictTypeInput,
|
||||||
|
createDictTypeInputSchema,
|
||||||
|
type DictItem,
|
||||||
|
type DictItemListQuery,
|
||||||
|
dictItemListQuerySchema,
|
||||||
|
type DictItemListResponse,
|
||||||
|
dictItemListResponseSchema,
|
||||||
|
dictItemSchema,
|
||||||
|
type DictTypeDetail,
|
||||||
|
dictTypeDetailSchema,
|
||||||
|
type DictTypeListQuery,
|
||||||
|
dictTypeListQuerySchema,
|
||||||
|
type DictTypeSummary,
|
||||||
|
dictTypeSummarySchema,
|
||||||
|
listResponseSchema,
|
||||||
|
type SetDictTypeStatusInput,
|
||||||
|
setDictTypeStatusInputSchema,
|
||||||
|
type UpdateDictItemInput,
|
||||||
|
updateDictItemInputSchema,
|
||||||
|
type UpdateDictTypeInput,
|
||||||
|
updateDictTypeInputSchema,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
|
||||||
|
import { request } from './client';
|
||||||
|
import { ENDPOINTS } from './endpoints';
|
||||||
|
|
||||||
|
const dictTypeListResponseSchema = listResponseSchema(dictTypeSummarySchema);
|
||||||
|
|
||||||
|
function searchParams(entries: Record<string, string | number | undefined>): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(entries)) {
|
||||||
|
if (value !== undefined && value !== '') params.set(key, String(value));
|
||||||
|
}
|
||||||
|
const search = params.toString();
|
||||||
|
return search === '' ? '' : `?${search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字典类型列表:keyword / group / status / limit / cursor(query snake_case) */
|
||||||
|
export async function fetchDictTypeList(
|
||||||
|
query: Partial<DictTypeListQuery> = {},
|
||||||
|
): Promise<{ items: DictTypeSummary[]; next_cursor: string | null }> {
|
||||||
|
const parsed = dictTypeListQuerySchema.parse(query);
|
||||||
|
const payload: unknown = await request(
|
||||||
|
`${ENDPOINTS.dicts}${searchParams({
|
||||||
|
keyword: parsed.keyword,
|
||||||
|
group: parsed.group,
|
||||||
|
status: parsed.status,
|
||||||
|
limit: parsed.limit,
|
||||||
|
cursor: parsed.cursor,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
return dictTypeListResponseSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDictTypeDetail(typeCode: string): Promise<DictTypeDetail> {
|
||||||
|
const payload: unknown = await request(ENDPOINTS.dict(typeCode));
|
||||||
|
return dictTypeDetailSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDictType(input: CreateDictTypeInput): Promise<DictTypeDetail> {
|
||||||
|
const body = createDictTypeInputSchema.parse(input);
|
||||||
|
const payload: unknown = await request(ENDPOINTS.dicts, { method: 'POST', body });
|
||||||
|
return dictTypeDetailSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDictType(
|
||||||
|
typeCode: string,
|
||||||
|
input: UpdateDictTypeInput,
|
||||||
|
): Promise<DictTypeDetail> {
|
||||||
|
const body = updateDictTypeInputSchema.parse(input);
|
||||||
|
const payload: unknown = await request(ENDPOINTS.dict(typeCode), { method: 'PATCH', body });
|
||||||
|
return dictTypeDetailSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDictTypeStatus(
|
||||||
|
typeCode: string,
|
||||||
|
input: SetDictTypeStatusInput,
|
||||||
|
): Promise<DictTypeDetail> {
|
||||||
|
const body = setDictTypeStatusInputSchema.parse(input);
|
||||||
|
const payload: unknown = await request(ENDPOINTS.dictStatus(typeCode), {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return dictTypeDetailSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDictType(typeCode: string): Promise<void> {
|
||||||
|
await request<unknown>(ENDPOINTS.dict(typeCode), { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生效条目列表(三层合并结果):不传 industry_id 表示仅通用层 */
|
||||||
|
export async function fetchDictItemList(
|
||||||
|
typeCode: string,
|
||||||
|
query: Partial<DictItemListQuery> = {},
|
||||||
|
): Promise<DictItemListResponse> {
|
||||||
|
const parsed = dictItemListQuerySchema.parse(query);
|
||||||
|
const payload: unknown = await request(
|
||||||
|
`${ENDPOINTS.dictItems(typeCode)}${searchParams({ industry_id: parsed.industry_id })}`,
|
||||||
|
);
|
||||||
|
return dictItemListResponseSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDictItem(
|
||||||
|
typeCode: string,
|
||||||
|
input: CreateDictItemInput,
|
||||||
|
): Promise<DictItem> {
|
||||||
|
const body = createDictItemInputSchema.parse(input);
|
||||||
|
const payload: unknown = await request(ENDPOINTS.dictItems(typeCode), { method: 'POST', body });
|
||||||
|
return dictItemSchema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDictItem(
|
||||||
|
typeCode: string,
|
||||||
|
itemId: string,
|
||||||
|
input: UpdateDictItemInput,
|
||||||
|
): Promise<DictItem> {
|
||||||
|
const body = updateDictItemInputSchema.parse(input);
|
||||||
|
const payload: unknown = await request(ENDPOINTS.dictItem(typeCode, itemId), {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return dictItemSchema.parse(payload);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
* 后端若调整路径,只改这里;联调时以 `apps/web/src/api/generated/openapi.d.ts` 为准。
|
* 后端若调整路径,只改这里;联调时以 `apps/web/src/api/generated/openapi.d.ts` 为准。
|
||||||
*/
|
*/
|
||||||
const USERS = '/users';
|
const USERS = '/users';
|
||||||
|
const DICTS = '/dicts';
|
||||||
|
const SYSTEM_CONFIGS = '/system/configs';
|
||||||
|
|
||||||
export const ENDPOINTS = {
|
export const ENDPOINTS = {
|
||||||
login: '/auth/login',
|
login: '/auth/login',
|
||||||
@@ -28,4 +30,38 @@ export const ENDPOINTS = {
|
|||||||
roles: '/roles',
|
roles: '/roles',
|
||||||
role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`,
|
role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`,
|
||||||
permissions: '/permissions',
|
permissions: '/permissions',
|
||||||
|
// A3 client
|
||||||
|
industries: '/industries',
|
||||||
|
industry: (industryId: string): string => `/industries/${encodeURIComponent(industryId)}`,
|
||||||
|
industryStatus: (industryId: string): string =>
|
||||||
|
`/industries/${encodeURIComponent(industryId)}/status`,
|
||||||
|
customers: '/customers',
|
||||||
|
customer: (customerId: string): string => `/customers/${encodeURIComponent(customerId)}`,
|
||||||
|
customerStatus: (customerId: string): string =>
|
||||||
|
`/customers/${encodeURIComponent(customerId)}/status`,
|
||||||
|
customerTransfer: (customerId: string): string =>
|
||||||
|
`/customers/${encodeURIComponent(customerId)}/transfer`,
|
||||||
|
customerGroups: (customerId: string): string =>
|
||||||
|
`/customers/${encodeURIComponent(customerId)}/groups`,
|
||||||
|
customerApplyPack: (customerId: string): string =>
|
||||||
|
`/customers/${encodeURIComponent(customerId)}/apply-industry-pack`,
|
||||||
|
customerGroupsList: '/customer-groups',
|
||||||
|
customerGroup: (groupId: string): string => `/customer-groups/${encodeURIComponent(groupId)}`,
|
||||||
|
customerGroupMembers: (groupId: string): string =>
|
||||||
|
`/customer-groups/${encodeURIComponent(groupId)}/members`,
|
||||||
|
customerFields: '/customer-fields',
|
||||||
|
customerFieldsOf: (customerId: string): string =>
|
||||||
|
`/customers/${encodeURIComponent(customerId)}/fields`,
|
||||||
|
customerFieldApprove: (fieldId: string): string =>
|
||||||
|
`/customer-fields/${encodeURIComponent(fieldId)}/approve`,
|
||||||
|
// A4 dict / system config
|
||||||
|
dicts: DICTS,
|
||||||
|
dict: (typeCode: string): string => `${DICTS}/${encodeURIComponent(typeCode)}`,
|
||||||
|
dictStatus: (typeCode: string): string => `${DICTS}/${encodeURIComponent(typeCode)}/status`,
|
||||||
|
dictItems: (typeCode: string): string => `${DICTS}/${encodeURIComponent(typeCode)}/items`,
|
||||||
|
dictItem: (typeCode: string, itemId: string): string =>
|
||||||
|
`${DICTS}/${encodeURIComponent(typeCode)}/items/${encodeURIComponent(itemId)}`,
|
||||||
|
systemConfigs: SYSTEM_CONFIGS,
|
||||||
|
systemConfigsEffective: `${SYSTEM_CONFIGS}/effective`,
|
||||||
|
systemConfigsHistory: `${SYSTEM_CONFIGS}/history`,
|
||||||
} as const;
|
} as const;
|
||||||
+1068
-37
File diff suppressed because it is too large.
Load diff
@@ -13,4 +13,26 @@ export const queryKeys = {
|
|||||||
roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const,
|
roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const,
|
||||||
role: (roleId: string) => ['roles', 'detail', roleId] as const,
|
role: (roleId: string) => ['roles', 'detail', roleId] as const,
|
||||||
permissions: ['permissions'] as const,
|
permissions: ['permissions'] as const,
|
||||||
|
// A3 client
|
||||||
|
industries: ['industries'] as const,
|
||||||
|
industryList: (query: object) => ['industries', 'list', query] as const,
|
||||||
|
industry: (industryId: string) => ['industries', 'detail', industryId] as const,
|
||||||
|
customers: ['customers'] as const,
|
||||||
|
customerList: (query: object) => ['customers', 'list', query] as const,
|
||||||
|
customer: (customerId: string) => ['customers', 'detail', customerId] as const,
|
||||||
|
customerFieldsOf: (customerId: string) => ['customers', 'fields', customerId] as const,
|
||||||
|
customerGroups: ['customer-groups'] as const,
|
||||||
|
customerGroupList: (query: object) => ['customer-groups', 'list', query] as const,
|
||||||
|
customerGroup: (groupId: string) => ['customer-groups', 'detail', groupId] as const,
|
||||||
|
customerFields: (query: object) => ['customer-fields', 'list', query] as const,
|
||||||
|
customerFieldsRoot: ['customer-fields'] as const,
|
||||||
|
// A4 dict / system config
|
||||||
|
dicts: ['dicts'] as const,
|
||||||
|
dictTypeList: (query: object) => ['dicts', 'list', query] as const,
|
||||||
|
dictType: (typeCode: string) => ['dicts', 'detail', typeCode] as const,
|
||||||
|
dictItems: (typeCode: string, industryId: string | null) =>
|
||||||
|
['dicts', 'items', typeCode, industryId ?? 'all'] as const,
|
||||||
|
systemConfigs: ['system-configs'] as const,
|
||||||
|
systemConfigsEffective: ['system-configs', 'effective'] as const,
|
||||||
|
systemConfigHistory: (query: object) => ['system-configs', 'history', query] as const,
|
||||||
};
|
};
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* A4 系统配置查询与写操作。
|
||||||
|
*
|
||||||
|
* R-26:阈值 / 上限只存系统配置一处。`useEffectiveConfigsQuery` 供业务页读取前端上限,
|
||||||
|
* 读取失败或未登录时由调用方回退到 shared 常量(仅作展示兜底,后端仍做最终校验)。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
CUSTOMER_BASE_FIELD_LIMIT,
|
||||||
|
CUSTOMER_GEN_FIELD_LIMIT,
|
||||||
|
type SystemConfig,
|
||||||
|
type SystemConfigChange,
|
||||||
|
type SystemConfigHistoryQuery,
|
||||||
|
type SystemConfigValue,
|
||||||
|
type UpdateSystemConfigsInput,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
type UseMutationResult,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
type UseQueryResult,
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchEffectiveSystemConfigs,
|
||||||
|
fetchSystemConfigHistory,
|
||||||
|
fetchSystemConfigs,
|
||||||
|
updateSystemConfigs,
|
||||||
|
} from '@/api/configs';
|
||||||
|
import { queryKeys } from '@/api/query-keys';
|
||||||
|
|
||||||
|
export function useSystemConfigsQuery(): UseQueryResult<{ items: SystemConfig[] }> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.systemConfigs,
|
||||||
|
queryFn: fetchSystemConfigs,
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生效配置:key → value(仅登录可读,失败不阻塞业务页) */
|
||||||
|
export function useEffectiveConfigsQuery(): UseQueryResult<Record<string, SystemConfigValue>> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.systemConfigsEffective,
|
||||||
|
queryFn: fetchEffectiveSystemConfigs,
|
||||||
|
select: (data) => {
|
||||||
|
const values: Record<string, SystemConfigValue> = {};
|
||||||
|
for (const item of data.items) {
|
||||||
|
values[item.key] = item.value;
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取单个数值型生效配置;缺失 / 非数字时回退 shared 常量 */
|
||||||
|
export function useEffectiveNumberConfig(key: string, fallback: number): number {
|
||||||
|
const effective = useEffectiveConfigsQuery();
|
||||||
|
const value = effective.data?.[key];
|
||||||
|
return typeof value === 'number' ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerFieldLimits {
|
||||||
|
baseFieldLimit: number;
|
||||||
|
genFieldLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 客户基础字段 / 进入生成字段上限(A3 客户页与表单校验共用,R-26) */
|
||||||
|
export function useCustomerFieldLimits(): CustomerFieldLimits {
|
||||||
|
const effective = useEffectiveConfigsQuery();
|
||||||
|
const base = effective.data?.['limit.customer.base_field'];
|
||||||
|
const gen = effective.data?.['limit.customer.gen_field'];
|
||||||
|
return {
|
||||||
|
baseFieldLimit: typeof base === 'number' ? base : CUSTOMER_BASE_FIELD_LIMIT,
|
||||||
|
genFieldLimit: typeof gen === 'number' ? gen : CUSTOMER_GEN_FIELD_LIMIT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSystemConfigHistoryQuery(
|
||||||
|
query: Partial<SystemConfigHistoryQuery> = {},
|
||||||
|
options: { enabled?: boolean } = {},
|
||||||
|
): UseQueryResult<{ items: SystemConfigChange[]; next_cursor: string | null }> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.systemConfigHistory(query),
|
||||||
|
queryFn: () => fetchSystemConfigHistory(query),
|
||||||
|
enabled: options.enabled ?? true,
|
||||||
|
staleTime: 5_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSystemConfigsMutation(): UseMutationResult<
|
||||||
|
{ items: SystemConfig[] },
|
||||||
|
Error,
|
||||||
|
UpdateSystemConfigsInput
|
||||||
|
> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: updateSystemConfigs,
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.systemConfigs }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.systemConfigsEffective }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['system-configs', 'history'] }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* A4 数据字典查询与写操作(TanStack Query 唯一入口)。
|
||||||
|
* 字典变更只影响新数据(R-10),写操作成功后失效整棵 `dicts` 缓存,保证类型统计与条目列表同步。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
CreateDictItemInput,
|
||||||
|
CreateDictTypeInput,
|
||||||
|
DictItem,
|
||||||
|
DictItemListResponse,
|
||||||
|
DictTypeDetail,
|
||||||
|
DictTypeListQuery,
|
||||||
|
DictTypeSummary,
|
||||||
|
SetDictTypeStatusInput,
|
||||||
|
UpdateDictItemInput,
|
||||||
|
UpdateDictTypeInput,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
useMutation,
|
||||||
|
type UseMutationResult,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
type UseQueryResult,
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDictItem,
|
||||||
|
createDictType,
|
||||||
|
deleteDictType,
|
||||||
|
fetchDictItemList,
|
||||||
|
fetchDictTypeDetail,
|
||||||
|
fetchDictTypeList,
|
||||||
|
setDictTypeStatus,
|
||||||
|
updateDictItem,
|
||||||
|
updateDictType,
|
||||||
|
} from '@/api/dicts';
|
||||||
|
import { queryKeys } from '@/api/query-keys';
|
||||||
|
|
||||||
|
export function useDictTypesQuery(
|
||||||
|
query: Partial<DictTypeListQuery> = {},
|
||||||
|
): UseQueryResult<{ items: DictTypeSummary[]; next_cursor: string | null }> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.dictTypeList(query),
|
||||||
|
queryFn: () => fetchDictTypeList(query),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDictTypeDetailQuery(typeCode: string | null): UseQueryResult<DictTypeDetail> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.dictType(typeCode ?? ''),
|
||||||
|
queryFn: () => fetchDictTypeDetail(typeCode ?? ''),
|
||||||
|
enabled: typeCode !== null && typeCode !== '',
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生效条目(三层合并):industryId 为 null 时只取通用层 */
|
||||||
|
export function useDictItemsQuery(
|
||||||
|
typeCode: string | null,
|
||||||
|
industryId: string | null,
|
||||||
|
options: { enabled?: boolean } = {},
|
||||||
|
): UseQueryResult<DictItemListResponse> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.dictItems(typeCode ?? '', industryId),
|
||||||
|
queryFn: () =>
|
||||||
|
fetchDictItemList(typeCode ?? '', industryId === null ? {} : { industry_id: industryId }),
|
||||||
|
enabled: (options.enabled ?? true) && typeCode !== null && typeCode !== '',
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidateDicts(): () => Promise<void> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.dicts });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateDictTypeMutation(): UseMutationResult<
|
||||||
|
DictTypeDetail,
|
||||||
|
Error,
|
||||||
|
CreateDictTypeInput
|
||||||
|
> {
|
||||||
|
const invalidate = useInvalidateDicts();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createDictType,
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateDictTypeMutation(): UseMutationResult<
|
||||||
|
DictTypeDetail,
|
||||||
|
Error,
|
||||||
|
{ typeCode: string; input: UpdateDictTypeInput }
|
||||||
|
> {
|
||||||
|
const invalidate = useInvalidateDicts();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ typeCode, input }) => updateDictType(typeCode, input),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetDictTypeStatusMutation(): UseMutationResult<
|
||||||
|
DictTypeDetail,
|
||||||
|
Error,
|
||||||
|
{ typeCode: string; input: SetDictTypeStatusInput }
|
||||||
|
> {
|
||||||
|
const invalidate = useInvalidateDicts();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ typeCode, input }) => setDictTypeStatus(typeCode, input),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteDictTypeMutation(): UseMutationResult<void, Error, string> {
|
||||||
|
const invalidate = useInvalidateDicts();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteDictType,
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateDictItemMutation(): UseMutationResult<
|
||||||
|
DictItem,
|
||||||
|
Error,
|
||||||
|
{ typeCode: string; input: CreateDictItemInput }
|
||||||
|
> {
|
||||||
|
const invalidate = useInvalidateDicts();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ typeCode, input }) => createDictItem(typeCode, input),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateDictItemMutation(): UseMutationResult<
|
||||||
|
DictItem,
|
||||||
|
Error,
|
||||||
|
{ typeCode: string; itemId: string; input: UpdateDictItemInput }
|
||||||
|
> {
|
||||||
|
const invalidate = useInvalidateDicts();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ typeCode, itemId, input }) => updateDictItem(typeCode, itemId, input),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,12 +11,15 @@ import { PageBreadcrumbs } from './PageBreadcrumbs';
|
|||||||
import { SideNav } from './SideNav';
|
import { SideNav } from './SideNav';
|
||||||
import { TopBar } from './TopBar';
|
import { TopBar } from './TopBar';
|
||||||
|
|
||||||
/** 用户管理、部门管理为「满高主从布局」,由页面自己控制内部滚动 */
|
/** 用户管理、部门管理、数据字典、系统配置为「满高主从布局」,由页面自己控制内部滚动 */
|
||||||
function isFlushRoute(pathname: string): boolean {
|
function isFlushRoute(pathname: string): boolean {
|
||||||
return (
|
return (
|
||||||
pathname === ROUTES.system.users ||
|
pathname === ROUTES.system.users ||
|
||||||
pathname.startsWith(`${ROUTES.system.users}/`) ||
|
pathname.startsWith(`${ROUTES.system.users}/`) ||
|
||||||
pathname === ROUTES.system.departments
|
pathname === ROUTES.system.departments ||
|
||||||
|
pathname === ROUTES.system.dicts ||
|
||||||
|
pathname.startsWith(`${ROUTES.system.dicts}/`) ||
|
||||||
|
pathname === ROUTES.system.settings
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import '@/styles/users.css';
|
|||||||
import '@/styles/roles.css';
|
import '@/styles/roles.css';
|
||||||
import '@/styles/role-editor.css';
|
import '@/styles/role-editor.css';
|
||||||
import '@/styles/departments.css';
|
import '@/styles/departments.css';
|
||||||
|
import '@/styles/client.css';
|
||||||
|
import '@/styles/dicts.css';
|
||||||
|
import '@/styles/settings.css';
|
||||||
|
|
||||||
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||||
import { localeZhCN } from '@lingxi/ui';
|
import { localeZhCN } from '@lingxi/ui';
|
||||||
|
|||||||
@@ -53,6 +53,28 @@ describe('导航配置', () => {
|
|||||||
expect(findActiveNav(NAV_MODULES, '/system/departments')?.item?.label).toBe('部门管理');
|
expect(findActiveNav(NAV_MODULES, '/system/departments')?.item?.label).toBe('部门管理');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('客户与行业菜单使用查看权限,名称按 A3 口径更名', () => {
|
||||||
|
const systemItems = NAV_MODULES.find((module) => module.key === 'system')?.items ?? [];
|
||||||
|
const customersItem = systemItems.find((item) => item.key === 'customers');
|
||||||
|
const industriesItem = systemItems.find((item) => item.key === 'industries');
|
||||||
|
expect(customersItem?.label).toBe('客户管理');
|
||||||
|
expect(customersItem?.permission).toBe('customer:read');
|
||||||
|
expect(industriesItem?.label).toBe('行业管理');
|
||||||
|
expect(industriesItem?.permission).toBe('customer:read');
|
||||||
|
expect(
|
||||||
|
findActiveNav(NAV_MODULES, '/system/customers/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.item?.label,
|
||||||
|
).toBe('客户管理');
|
||||||
|
expect(findActiveNav(NAV_MODULES, '/system/industries')?.item?.label).toBe('行业管理');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('字典菜单按 A4 口径更名「数据字典」', () => {
|
||||||
|
const systemItems = NAV_MODULES.find((module) => module.key === 'system')?.items ?? [];
|
||||||
|
const dictItem = systemItems.find((item) => item.key === 'dicts');
|
||||||
|
expect(dictItem?.label).toBe('数据字典');
|
||||||
|
expect(dictItem?.permission).toBe('dict:manage');
|
||||||
|
expect(findActiveNav(NAV_MODULES, '/system/dicts')?.item?.label).toBe('数据字典');
|
||||||
|
});
|
||||||
|
|
||||||
it('按路径定位当前模块与菜单', () => {
|
it('按路径定位当前模块与菜单', () => {
|
||||||
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
|
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
|
||||||
expect(active?.module.key).toBe('material');
|
expect(active?.module.key).toBe('material');
|
||||||
|
|||||||
@@ -273,27 +273,35 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'customers',
|
key: 'customers',
|
||||||
label: '客户',
|
label: '客户管理',
|
||||||
path: '/system/customers',
|
path: '/system/customers',
|
||||||
icon: IconGlobal,
|
icon: IconGlobal,
|
||||||
permission: Permission.CUSTOMER_MANAGE,
|
permission: Permission.CUSTOMER_READ,
|
||||||
description: '客户、行业归属与数据范围',
|
description: '客户档案、状态与交付偏好',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'customer-groups',
|
||||||
|
label: '客户组',
|
||||||
|
path: '/system/customer-groups',
|
||||||
|
icon: IconApps,
|
||||||
|
permission: Permission.CUSTOMER_GROUP_MANAGE,
|
||||||
|
description: '客户组与批量授权单位',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'industries',
|
key: 'industries',
|
||||||
label: '行业树',
|
label: '行业管理',
|
||||||
path: '/system/industries',
|
path: '/system/industries',
|
||||||
icon: IconStack,
|
icon: IconStack,
|
||||||
permission: Permission.INDUSTRY_MANAGE,
|
permission: Permission.CUSTOMER_READ,
|
||||||
description: '行业分类树与行业包绑定',
|
description: '两级行业树、状态与客户归属',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'dicts',
|
key: 'dicts',
|
||||||
label: '字典',
|
label: '数据字典',
|
||||||
path: '/system/dicts',
|
path: '/system/dicts',
|
||||||
icon: IconDocs,
|
icon: IconDocs,
|
||||||
permission: Permission.DICT_MANAGE,
|
permission: Permission.DICT_MANAGE,
|
||||||
description: '核心字典与业务枚举',
|
description: '平台枚举、行业词表与编码管理',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'audit',
|
key: 'audit',
|
||||||
@@ -338,9 +346,9 @@ export const NAV_MODULES: readonly NavModule[] = [
|
|||||||
{
|
{
|
||||||
key: 'customer-fields',
|
key: 'customer-fields',
|
||||||
label: '客户字段',
|
label: '客户字段',
|
||||||
path: '/system/customer-fields',
|
path: '/system/customer-fields/approvals',
|
||||||
icon: IconTask,
|
icon: IconTask,
|
||||||
permission: Permission.CUSTOMER_MANAGE,
|
permission: Permission.CUSTOMER_FIELD_APPROVE,
|
||||||
description: '客户级扩展字段与审批',
|
description: '客户级扩展字段与审批',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||||
|
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
|
||||||
|
import { type CustomerFieldListItem, Permission } from '@lingxi/shared';
|
||||||
|
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Can } from '@/components/Can';
|
||||||
|
import {
|
||||||
|
useApplyCustomerFieldMutation,
|
||||||
|
useApproveCustomerFieldMutation,
|
||||||
|
useCustomerFieldsQuery,
|
||||||
|
useCustomersQuery,
|
||||||
|
} from '@/hooks/queries/use-client';
|
||||||
|
import { useCustomerFieldLimits } from '@/hooks/queries/use-configs';
|
||||||
|
|
||||||
|
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||||
|
text: '文本',
|
||||||
|
number: '数字',
|
||||||
|
date: '日期',
|
||||||
|
select: '单选',
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusTag(status: CustomerFieldListItem['status']): ReactNode {
|
||||||
|
const cls =
|
||||||
|
status === 'active'
|
||||||
|
? 'active'
|
||||||
|
: status === 'pending'
|
||||||
|
? 'paused'
|
||||||
|
: status === 'rejected'
|
||||||
|
? 'ended'
|
||||||
|
: 'disabled';
|
||||||
|
const label =
|
||||||
|
status === 'active'
|
||||||
|
? '已生效'
|
||||||
|
: status === 'pending'
|
||||||
|
? '待审批'
|
||||||
|
: status === 'rejected'
|
||||||
|
? '已驳回'
|
||||||
|
: '已停用';
|
||||||
|
return <span className={`lx-client-tag lx-client-tag--${cls}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplyModal({ onClose }: { onClose: () => void }): ReactNode {
|
||||||
|
const customers = useCustomersQuery({ limit: 100, status: 'active' });
|
||||||
|
const apply = useApplyCustomerFieldMutation();
|
||||||
|
const [customerId, setCustomerId] = useState<string | undefined>();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [purpose, setPurpose] = useState('');
|
||||||
|
const [type, setType] = useState<'text' | 'number' | 'date' | 'select'>('text');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
if (
|
||||||
|
customerId === undefined ||
|
||||||
|
name.trim() === '' ||
|
||||||
|
code.trim() === '' ||
|
||||||
|
purpose.trim() === ''
|
||||||
|
) {
|
||||||
|
setError('请完整填写客户、字段名称、编码与用途');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apply.mutateAsync({
|
||||||
|
customerId,
|
||||||
|
input: { name, code, type, purpose, entersGeneration: true, options: [] },
|
||||||
|
});
|
||||||
|
notifySuccess('字段申请已提交,等待审批');
|
||||||
|
onClose();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : '提交失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
title="申请客户字段"
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
okText="提交申请"
|
||||||
|
>
|
||||||
|
<div className="lx-client-formgrid">
|
||||||
|
<div className="lx-client-field is-full">
|
||||||
|
<label>申请客户</label>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={customerId}
|
||||||
|
placeholder="选择客户"
|
||||||
|
optionList={(customers.data?.items ?? []).map((customer) => ({
|
||||||
|
value: customer.id,
|
||||||
|
label: customer.name,
|
||||||
|
}))}
|
||||||
|
onChange={(value) => {
|
||||||
|
setCustomerId(String(value));
|
||||||
|
}}
|
||||||
|
filter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>字段名称</label>
|
||||||
|
<Input value={name} onChange={setName} placeholder="如:活动编码" />
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>字段编码</label>
|
||||||
|
<Input value={code} onChange={setCode} placeholder="如:campaign_code" />
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>字段类型</label>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={type}
|
||||||
|
optionList={[
|
||||||
|
{ value: 'text', label: '文本' },
|
||||||
|
{ value: 'number', label: '数字' },
|
||||||
|
{ value: 'date', label: '日期' },
|
||||||
|
{ value: 'select', label: '单选' },
|
||||||
|
]}
|
||||||
|
onChange={(value) => {
|
||||||
|
setType(value as typeof type);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field is-full">
|
||||||
|
<label>用途说明</label>
|
||||||
|
<Input value={purpose} onChange={setPurpose} placeholder="说明字段用途与进入生成的原因" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error !== null ? (
|
||||||
|
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RejectModal({
|
||||||
|
field,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
field: CustomerFieldListItem;
|
||||||
|
onClose: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const approve = useApproveCustomerFieldMutation();
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
if (reason.trim() === '') {
|
||||||
|
setError('驳回原因必填');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await approve.mutateAsync({ id: field.id, input: { decision: 'reject', reason } });
|
||||||
|
notifySuccess('已驳回,申请人将收到通知');
|
||||||
|
onClose();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : '驳回失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
title="驳回字段申请"
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
okText="确认驳回"
|
||||||
|
okButtonProps={{ type: 'danger' }}
|
||||||
|
>
|
||||||
|
<div className="lx-client-hint">
|
||||||
|
{field.customerName} · {field.name}({field.code})
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>驳回原因</label>
|
||||||
|
<Input
|
||||||
|
value={reason}
|
||||||
|
onChange={setReason}
|
||||||
|
placeholder="例如:与基础字段重复,仅需检索无需进入生成"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error !== null ? (
|
||||||
|
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 客户字段审批(选定方案 A:全宽表格 + 审批弹窗) */
|
||||||
|
export function CustomerFieldsPage(): ReactNode {
|
||||||
|
const { genFieldLimit } = useCustomerFieldLimits();
|
||||||
|
const [status, setStatus] = useState<'all' | 'pending' | 'active' | 'rejected'>('all');
|
||||||
|
const fields = useCustomerFieldsQuery({ limit: 100, status });
|
||||||
|
const allFields = useCustomerFieldsQuery({ limit: 100, status: 'all' });
|
||||||
|
const approve = useApproveCustomerFieldMutation();
|
||||||
|
const [applyOpen, setApplyOpen] = useState(false);
|
||||||
|
const [rejectField, setRejectField] = useState<CustomerFieldListItem | null>(null);
|
||||||
|
|
||||||
|
const items = fields.data?.items ?? [];
|
||||||
|
const allItems = allFields.data?.items ?? [];
|
||||||
|
const countOf = (value: CustomerFieldListItem['status']): number =>
|
||||||
|
allItems.filter((item) => item.status === value).length;
|
||||||
|
|
||||||
|
if (fields.isPending) return <PageSkeleton rows={8} />;
|
||||||
|
|
||||||
|
const approveField = (field: CustomerFieldListItem): void => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '通过字段申请',
|
||||||
|
content: `通过后「${field.name}(${field.code})」立即生效并进入客户生成链路,操作会写入审计。`,
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await approve.mutateAsync({ id: field.id, input: { decision: 'approve' } });
|
||||||
|
notifySuccess('已通过审批,字段已生效');
|
||||||
|
} catch (cause) {
|
||||||
|
notifyError(cause instanceof Error ? cause.message : '审批失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-client-page">
|
||||||
|
<header className="lx-client-header">
|
||||||
|
<div>
|
||||||
|
<h1>客户字段审批</h1>
|
||||||
|
<p>客户级字段分两层:基础字段仅检索报表、无需审批;进入生成字段必须申请 → 审批 → 生效</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-header__actions">
|
||||||
|
<Can permission={Permission.CUSTOMER_FIELD_APPLY}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setApplyOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
申请字段
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section className="lx-client-filters">
|
||||||
|
<div className="lx-client-seg" role="group" aria-label="字段申请状态">
|
||||||
|
{[
|
||||||
|
{ value: 'all' as const, label: '全部', count: allItems.length },
|
||||||
|
{ value: 'pending' as const, label: '待审批', count: countOf('pending') },
|
||||||
|
{ value: 'active' as const, label: '已生效', count: countOf('active') },
|
||||||
|
{ value: 'rejected' as const, label: '已驳回', count: countOf('rejected') },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
className={status === item.value ? 'is-on' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
setStatus(item.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label} <i>{item.count}</i>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--lx-color-text-secondary)' }}>
|
||||||
|
共 {items.length} 条申请
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
<section className="lx-client-table-card">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="lx-client-empty">
|
||||||
|
<b>暂无字段申请</b>
|
||||||
|
<span>客户提交进入生成字段的申请后,会出现在这里等待审批</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="lx-client-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>客户</th>
|
||||||
|
<th>字段</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>进入生成</th>
|
||||||
|
<th>申请人</th>
|
||||||
|
<th>申请时间</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((field) => (
|
||||||
|
<tr key={field.id}>
|
||||||
|
<td>
|
||||||
|
<b>{field.customerName}</b>
|
||||||
|
<span className="lx-client-sub">{field.industryPath}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<b>{field.name}</b>
|
||||||
|
<span className="lx-client-sub">{field.code}</span>
|
||||||
|
</td>
|
||||||
|
<td>{FIELD_TYPE_LABELS[field.type] ?? field.type}</td>
|
||||||
|
<td>{field.entersGeneration ? '是' : '否'}</td>
|
||||||
|
<td>{field.applicantName}</td>
|
||||||
|
<td className="lx-client-muted">
|
||||||
|
{field.appliedAt.slice(0, 19).replace('T', ' ')}
|
||||||
|
</td>
|
||||||
|
<td>{statusTag(field.status)}</td>
|
||||||
|
<td>
|
||||||
|
{field.status === 'pending' ? (
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setRejectField(field);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
approveField(field);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="lx-client-muted">已处理</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
<div className="lx-client-tablefoot">
|
||||||
|
审批与变更写入审计 · 未审批字段不进入生成 · 每客户进入生成字段 ≤ {genFieldLimit} 个
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{applyOpen ? (
|
||||||
|
<ApplyModal
|
||||||
|
onClose={() => {
|
||||||
|
setApplyOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{rejectField !== null ? (
|
||||||
|
<RejectModal
|
||||||
|
field={rejectField}
|
||||||
|
onClose={() => {
|
||||||
|
setRejectField(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||||
|
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { CustomerGroupDetail } from '@lingxi/shared';
|
||||||
|
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCreateCustomerGroupMutation,
|
||||||
|
useCustomerGroupDetailQuery,
|
||||||
|
useCustomerGroupsQuery,
|
||||||
|
useCustomersQuery,
|
||||||
|
useDeleteCustomerGroupMutation,
|
||||||
|
useUpdateCustomerGroupMembersMutation,
|
||||||
|
useUpdateCustomerGroupMutation,
|
||||||
|
} from '@/hooks/queries/use-client';
|
||||||
|
|
||||||
|
function GroupFormModal({
|
||||||
|
group,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
group?: CustomerGroupDetail;
|
||||||
|
onClose: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const create = useCreateCustomerGroupMutation();
|
||||||
|
const update = useUpdateCustomerGroupMutation();
|
||||||
|
const [name, setName] = useState(group?.name ?? '');
|
||||||
|
const [description, setDescription] = useState(group?.description ?? '');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
if (name.trim().length < 2) {
|
||||||
|
setError('客户组名称至少 2 个字符');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (group !== undefined) {
|
||||||
|
await update.mutateAsync({ id: group.id, input: { name, description } });
|
||||||
|
notifySuccess('客户组已保存');
|
||||||
|
} else {
|
||||||
|
await create.mutateAsync({ name, description });
|
||||||
|
notifySuccess('客户组已创建');
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : '保存失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
title={group === undefined ? '新建客户组' : '编辑客户组'}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
okText="保存"
|
||||||
|
>
|
||||||
|
<div className="lx-client-formgrid">
|
||||||
|
<div className="lx-client-field is-full">
|
||||||
|
<label>客户组名称</label>
|
||||||
|
<Input value={name} onChange={setName} placeholder="如:电商核心组" />
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field is-full">
|
||||||
|
<label>说明</label>
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={setDescription}
|
||||||
|
placeholder="说明客户组的用途与授权范围"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error !== null ? (
|
||||||
|
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MembersModal({
|
||||||
|
group,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
group: CustomerGroupDetail;
|
||||||
|
onClose: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const customers = useCustomersQuery({ limit: 100 });
|
||||||
|
const update = useUpdateCustomerGroupMembersMutation();
|
||||||
|
const currentIds = group.members.map((member) => member.id);
|
||||||
|
const [selected, setSelected] = useState<string[]>(currentIds);
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
const addCustomerIds = selected.filter((id) => !currentIds.includes(id));
|
||||||
|
const removeCustomerIds = currentIds.filter((id) => !selected.includes(id));
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({ id: group.id, input: { addCustomerIds, removeCustomerIds } });
|
||||||
|
notifySuccess(
|
||||||
|
`已更新客户组成员(新增 ${addCustomerIds.length} / 移除 ${removeCustomerIds.length})`,
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
} catch (cause) {
|
||||||
|
notifyError(cause instanceof Error ? cause.message : '保存失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
title="管理客户组成员"
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
okText="保存成员"
|
||||||
|
>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>成员客户</label>
|
||||||
|
<Select
|
||||||
|
multiple
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={selected}
|
||||||
|
optionList={(customers.data?.items ?? []).map((customer) => ({
|
||||||
|
value: customer.id,
|
||||||
|
label: `${customer.name}(${customer.industryPath})`,
|
||||||
|
}))}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSelected(value as string[]);
|
||||||
|
}}
|
||||||
|
filter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-hint">
|
||||||
|
调整成员会立即改变引用该客户组的角色可见客户集合;变更写入审计。
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 客户组(选定方案 B:左列表 + 右成员详情) */
|
||||||
|
export function CustomerGroupsPage(): ReactNode {
|
||||||
|
const groups = useCustomerGroupsQuery();
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const activeId = selectedId ?? groups.data?.items[0]?.id ?? null;
|
||||||
|
const detail = useCustomerGroupDetailQuery(activeId);
|
||||||
|
const remove = useDeleteCustomerGroupMutation();
|
||||||
|
const updateMembers = useUpdateCustomerGroupMembersMutation();
|
||||||
|
const [modal, setModal] = useState<'create' | 'edit' | 'members' | null>(null);
|
||||||
|
|
||||||
|
const items = useMemo(() => groups.data?.items ?? [], [groups.data]);
|
||||||
|
const memberTotal = items.reduce((sum, group) => sum + group.memberCount, 0);
|
||||||
|
if (groups.isPending) return <PageSkeleton rows={6} />;
|
||||||
|
|
||||||
|
const removeMember = async (groupId: string, customerId: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await updateMembers.mutateAsync({
|
||||||
|
id: groupId,
|
||||||
|
input: { addCustomerIds: [], removeCustomerIds: [customerId] },
|
||||||
|
});
|
||||||
|
notifySuccess('已移除 1 个客户');
|
||||||
|
} catch (cause) {
|
||||||
|
notifyError(cause instanceof Error ? cause.message : '移除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteGroup = (): void => {
|
||||||
|
if (detail.data === undefined) return;
|
||||||
|
Modal.confirm({
|
||||||
|
title: '删除客户组',
|
||||||
|
content: '删除前需确认无角色数据范围引用;删除后不可恢复。',
|
||||||
|
okButtonProps: { type: 'danger' },
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await remove.mutateAsync(detail.data.id);
|
||||||
|
notifySuccess('客户组已删除');
|
||||||
|
setSelectedId(null);
|
||||||
|
} catch (cause) {
|
||||||
|
notifyError(cause instanceof Error ? cause.message : '删除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-client-page">
|
||||||
|
<header className="lx-client-header">
|
||||||
|
<div>
|
||||||
|
<h1>客户组</h1>
|
||||||
|
<p>把客户按行业、团队或服务等级分组;客户组是批量授权与数据范围的最小单位</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-header__actions">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setModal('create');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建客户组
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="lx-client-split">
|
||||||
|
<section className="lx-client-left">
|
||||||
|
<div className="lx-client-left__head">
|
||||||
|
<b>客户组</b>
|
||||||
|
<span>
|
||||||
|
{items.length} 个 · 覆盖 {memberTotal} 个客户
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-left__body">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="lx-client-empty">
|
||||||
|
<b>暂无客户组</b>
|
||||||
|
<span>新建客户组,把客户按行业 / 团队分组</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((group) => (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
type="button"
|
||||||
|
className={`lx-client-tree__row${group.id === activeId ? ' is-active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(group.id);
|
||||||
|
}}
|
||||||
|
style={{ height: 46 }}
|
||||||
|
>
|
||||||
|
<span className="lx-client-tree__name">
|
||||||
|
<b style={{ display: 'block', fontWeight: 500 }}>{group.name}</b>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--lx-color-text-secondary)' }}>
|
||||||
|
{group.description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="lx-client-tree__count">{group.memberCount} 个</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-left__foot">共 {items.length} 个客户组 · 授权最小单位</div>
|
||||||
|
</section>
|
||||||
|
<section className="lx-client-right">
|
||||||
|
{detail.data === undefined ? (
|
||||||
|
<div className="lx-client-empty">
|
||||||
|
<b>选择客户组</b>
|
||||||
|
<span>从左侧选择客户组查看成员</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="lx-client-right__head">
|
||||||
|
<div>
|
||||||
|
<h2>{detail.data.name}</h2>
|
||||||
|
<p>
|
||||||
|
{detail.data.description || '暂无说明'} · {detail.data.memberCount} 个客户
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-header__actions">
|
||||||
|
<Button
|
||||||
|
theme="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setModal('members');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
管理成员
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setModal('edit');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button theme="outline" type="danger" onClick={deleteGroup}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-right__body">
|
||||||
|
<div className="lx-client-hint">
|
||||||
|
调整成员会立即改变引用该客户组的角色数据范围;变更写入审计。
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-membertable">
|
||||||
|
<div className="lx-client-membertable__head">
|
||||||
|
<span>客户</span>
|
||||||
|
<span>所属行业</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span>负责人</span>
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
|
{detail.data.members.length === 0 ? (
|
||||||
|
<div className="lx-client-hint" style={{ padding: '12px 10px' }}>
|
||||||
|
暂无成员
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{detail.data.members.map((member) => (
|
||||||
|
<div key={member.id} className="lx-client-membertable__row">
|
||||||
|
<span>
|
||||||
|
<b>{member.name}</b>
|
||||||
|
<i>{member.shortName}</i>
|
||||||
|
</span>
|
||||||
|
<span>{member.industryPath}</span>
|
||||||
|
<span>
|
||||||
|
{member.status === 'active'
|
||||||
|
? '在服务'
|
||||||
|
: member.status === 'paused'
|
||||||
|
? '暂停'
|
||||||
|
: '结束'}
|
||||||
|
</span>
|
||||||
|
<span>{member.ownerName}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lx-client-linkdanger"
|
||||||
|
onClick={() => void removeMember(detail.data.id, member.id)}
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{modal === 'create' ? (
|
||||||
|
<GroupFormModal
|
||||||
|
onClose={() => {
|
||||||
|
setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{modal === 'edit' && detail.data !== undefined ? (
|
||||||
|
<GroupFormModal
|
||||||
|
group={detail.data}
|
||||||
|
onClose={() => {
|
||||||
|
setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{modal === 'members' && detail.data !== undefined ? (
|
||||||
|
<MembersModal
|
||||||
|
group={detail.data}
|
||||||
|
onClose={() => {
|
||||||
|
setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,745 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||||
|
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
|
||||||
|
import { type CustomerDetail, type CustomerField, Permission } from '@lingxi/shared';
|
||||||
|
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||||
|
import { IconBack } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router';
|
||||||
|
|
||||||
|
import { Can } from '@/components/Can';
|
||||||
|
import {
|
||||||
|
useApplyCustomerFieldMutation,
|
||||||
|
useCustomerDetailQuery,
|
||||||
|
useCustomerFieldsOfQuery,
|
||||||
|
useCustomerGroupsQuery,
|
||||||
|
useSetCustomerGroupsMutation,
|
||||||
|
useSetCustomerStatusMutation,
|
||||||
|
useTransferCustomerMutation,
|
||||||
|
useUpdateCustomerMutation,
|
||||||
|
} from '@/hooks/queries/use-client';
|
||||||
|
import { useCustomerFieldLimits } from '@/hooks/queries/use-configs';
|
||||||
|
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
|
||||||
|
import { ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
import { CustomerStatusTag } from './CustomerStatusTag';
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<section className="lx-client-section">
|
||||||
|
<div className="lx-client-section__head">
|
||||||
|
<b>{title}</b>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldStatusTag({ field }: { field: CustomerField }): ReactNode {
|
||||||
|
const cls =
|
||||||
|
field.status === 'active'
|
||||||
|
? 'active'
|
||||||
|
: field.status === 'pending'
|
||||||
|
? 'paused'
|
||||||
|
: field.status === 'rejected'
|
||||||
|
? 'ended'
|
||||||
|
: 'disabled';
|
||||||
|
const label =
|
||||||
|
field.status === 'active'
|
||||||
|
? '已生效'
|
||||||
|
: field.status === 'pending'
|
||||||
|
? '待审批'
|
||||||
|
: field.status === 'rejected'
|
||||||
|
? '已驳回'
|
||||||
|
: '已停用';
|
||||||
|
return <span className={`lx-client-tag lx-client-tag--${cls}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransferModal({
|
||||||
|
customer,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
customer: CustomerDetail;
|
||||||
|
onClose: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const users = useUserCandidatesQuery(true);
|
||||||
|
const transfer = useTransferCustomerMutation();
|
||||||
|
const [ownerUserId, setOwnerUserId] = useState(customer.ownerUserId);
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await transfer.mutateAsync({ id: customer.id, input: { ownerUserId } });
|
||||||
|
notifySuccess('客户负责人已转交,已写入审计');
|
||||||
|
onClose();
|
||||||
|
} catch (cause) {
|
||||||
|
notifyError(cause instanceof Error ? cause.message : '转交失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
title="变更客户负责人"
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
okText="确认转交"
|
||||||
|
>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>新的客户负责人</label>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={ownerUserId}
|
||||||
|
optionList={(users.data?.items ?? []).map((user) => ({
|
||||||
|
value: user.id,
|
||||||
|
label: `${user.name}(${user.departmentName})`,
|
||||||
|
}))}
|
||||||
|
onChange={(value) => {
|
||||||
|
setOwnerUserId(String(value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-hint">
|
||||||
|
转交后该客户会从原负责人的「负责客户」范围移出,并写入审计。
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BaseFieldModal({
|
||||||
|
customer,
|
||||||
|
limit,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
customer: CustomerDetail;
|
||||||
|
limit: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const update = useUpdateCustomerMutation();
|
||||||
|
const [key, setKey] = useState('');
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
if (key.trim() === '') return;
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({
|
||||||
|
id: customer.id,
|
||||||
|
input: { baseFields: [...customer.baseFields, { key: key.trim(), value: value.trim() }] },
|
||||||
|
});
|
||||||
|
notifySuccess('基础字段已保存');
|
||||||
|
onClose();
|
||||||
|
} catch (cause) {
|
||||||
|
notifyError(cause instanceof Error ? cause.message : '保存失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal visible title="添加基础字段" onCancel={onClose} onOk={() => void submit()} okText="保存">
|
||||||
|
<div className="lx-client-formgrid">
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>字段键</label>
|
||||||
|
<Input value={key} onChange={setKey} placeholder="如:门店编号" />
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>字段值</label>
|
||||||
|
<Input value={value} onChange={setValue} placeholder="如:沪-012" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-hint">
|
||||||
|
基础字段仅用于检索与报表,永不进入生成;每客户最多 {limit} 个。
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplyFieldModal({
|
||||||
|
customer,
|
||||||
|
limit,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
customer: CustomerDetail;
|
||||||
|
limit: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const apply = useApplyCustomerFieldMutation();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [type, setType] = useState<'text' | 'number' | 'date' | 'select'>('text');
|
||||||
|
const [purpose, setPurpose] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
if (name.trim() === '' || code.trim() === '' || purpose.trim() === '') {
|
||||||
|
setError('请填写字段名称、编码与用途说明');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apply.mutateAsync({
|
||||||
|
customerId: customer.id,
|
||||||
|
input: { name, code, type, purpose, entersGeneration: true, options: [] },
|
||||||
|
});
|
||||||
|
notifySuccess('字段申请已提交,等待审批');
|
||||||
|
onClose();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : '提交失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
title="申请客户字段"
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
okText="提交申请"
|
||||||
|
>
|
||||||
|
<div className="lx-client-formgrid">
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>字段名称</label>
|
||||||
|
<Input value={name} onChange={setName} placeholder="如:活动编码" />
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>字段编码</label>
|
||||||
|
<Input value={code} onChange={setCode} placeholder="如:campaign_code" />
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>字段类型</label>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={type}
|
||||||
|
optionList={[
|
||||||
|
{ value: 'text', label: '文本' },
|
||||||
|
{ value: 'number', label: '数字' },
|
||||||
|
{ value: 'date', label: '日期' },
|
||||||
|
{ value: 'select', label: '单选' },
|
||||||
|
]}
|
||||||
|
onChange={(value) => {
|
||||||
|
setType(value as typeof type);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field is-full">
|
||||||
|
<label>用途说明</label>
|
||||||
|
<Input value={purpose} onChange={setPurpose} placeholder="说明字段用途与进入生成的原因" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error !== null ? (
|
||||||
|
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="lx-client-hint">进入生成字段需审批后生效;每客户最多 {limit} 个。</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupsModal({
|
||||||
|
customer,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
customer: CustomerDetail;
|
||||||
|
onClose: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const groups = useCustomerGroupsQuery();
|
||||||
|
const update = useSetCustomerGroupsMutation();
|
||||||
|
const [groupIds, setGroupIds] = useState<string[]>(customer.groups.map((group) => group.id));
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({ id: customer.id, input: { groupIds } });
|
||||||
|
notifySuccess('客户组归属已更新');
|
||||||
|
onClose();
|
||||||
|
} catch (cause) {
|
||||||
|
notifyError(cause instanceof Error ? cause.message : '保存失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
title="管理客户所属客户组"
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
okText="保存"
|
||||||
|
>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>客户组</label>
|
||||||
|
<Select
|
||||||
|
multiple
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={groupIds}
|
||||||
|
optionList={(groups.data?.items ?? []).map((group) => ({
|
||||||
|
value: group.id,
|
||||||
|
label: group.name,
|
||||||
|
}))}
|
||||||
|
onChange={(value) => {
|
||||||
|
setGroupIds(value as string[]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-hint">
|
||||||
|
客户组是数据范围授权的最小单位;调整会立即影响引用该客户组的角色可见范围。
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 客户详情(选定方案 A:独立三栏档案页) */
|
||||||
|
export function CustomerDetailPage(): ReactNode {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const detail = useCustomerDetailQuery(id ?? null);
|
||||||
|
const fields = useCustomerFieldsOfQuery(id ?? null);
|
||||||
|
const { baseFieldLimit, genFieldLimit } = useCustomerFieldLimits();
|
||||||
|
const setStatus = useSetCustomerStatusMutation();
|
||||||
|
const update = useUpdateCustomerMutation();
|
||||||
|
const [modal, setModal] = useState<'transfer' | 'base' | 'field' | 'groups' | null>(null);
|
||||||
|
|
||||||
|
if (detail.isPending) return <PageSkeleton rows={8} />;
|
||||||
|
if (detail.error) {
|
||||||
|
return (
|
||||||
|
<div className="lx-client-page">
|
||||||
|
<div className="lx-client-empty">
|
||||||
|
<b>客户加载失败</b>
|
||||||
|
<span>{detail.error.message}</span>
|
||||||
|
<Button onClick={() => void navigate(ROUTES.system.customers)}>返回客户列表</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const customer = detail.data;
|
||||||
|
const canWrite = customer.status === 'active';
|
||||||
|
const changeStatus = (target: 'paused' | 'ended' | 'active'): void => {
|
||||||
|
Modal.confirm({
|
||||||
|
title:
|
||||||
|
target === 'paused' ? '暂停客户服务' : target === 'ended' ? '结束客户服务' : '恢复客户服务',
|
||||||
|
content:
|
||||||
|
target === 'paused'
|
||||||
|
? '暂停后档案只读,不能发起新的生成任务;可随时恢复。'
|
||||||
|
: target === 'ended'
|
||||||
|
? '结束后全部数据只读归档,且不能恢复;如继续合作需新建客户。'
|
||||||
|
: '恢复后档案可编辑,并可以重新发起生成任务。',
|
||||||
|
okButtonProps: target === 'ended' ? { type: 'danger' } : undefined,
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await setStatus.mutateAsync({ id: customer.id, input: { status: target } });
|
||||||
|
notifySuccess(
|
||||||
|
target === 'paused'
|
||||||
|
? '客户已暂停'
|
||||||
|
: target === 'ended'
|
||||||
|
? '客户已结束并归档'
|
||||||
|
: '客户已恢复为在服务',
|
||||||
|
);
|
||||||
|
void detail.refetch();
|
||||||
|
} catch (cause) {
|
||||||
|
notifyError(cause instanceof Error ? cause.message : '操作失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeBaseField = async (index: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({
|
||||||
|
id: customer.id,
|
||||||
|
input: {
|
||||||
|
baseFields: customer.baseFields.filter((_item, itemIndex) => itemIndex !== index),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
notifySuccess('基础字段已删除');
|
||||||
|
} catch (cause) {
|
||||||
|
notifyError(cause instanceof Error ? cause.message : '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fieldRows = fields.data ?? customer.fields;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-client-page">
|
||||||
|
<header className="lx-client-header">
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
className="lx-client-back"
|
||||||
|
href={ROUTES.system.customers}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void navigate(ROUTES.system.customers);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconBack size={14} /> 返回客户列表
|
||||||
|
</a>
|
||||||
|
<h1>
|
||||||
|
{customer.name} <CustomerStatusTag status={customer.status} />
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
{customer.shortName} · {customer.industryPath} · 负责人 {customer.ownerName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-header__actions">
|
||||||
|
<Can permission={Permission.CUSTOMER_TRANSFER}>
|
||||||
|
<Button
|
||||||
|
theme="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setModal('transfer');
|
||||||
|
}}
|
||||||
|
disabled={customer.status === 'ended'}
|
||||||
|
>
|
||||||
|
变更负责人
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
|
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||||
|
{customer.status === 'active' ? (
|
||||||
|
<Button
|
||||||
|
theme="outline"
|
||||||
|
onClick={() => {
|
||||||
|
changeStatus('paused');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
暂停服务
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{customer.status === 'paused' ? (
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
changeStatus('active');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
恢复服务
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{customer.status !== 'ended' ? (
|
||||||
|
<Button
|
||||||
|
theme="outline"
|
||||||
|
type="danger"
|
||||||
|
onClick={() => {
|
||||||
|
changeStatus('ended');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
结束服务
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{customer.status !== 'active' ? (
|
||||||
|
<div
|
||||||
|
className={`lx-client-banner lx-client-banner--${customer.status === 'paused' ? 'paused' : 'ended'}`}
|
||||||
|
>
|
||||||
|
{customer.status === 'paused'
|
||||||
|
? '客户已暂停:档案只读,不能发起新的生成任务。恢复为「在服务」后可继续。'
|
||||||
|
: '客户已结束服务:全部数据只读归档,不能编辑或发起任务。'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="lx-client-grid">
|
||||||
|
<aside className="lx-client-anchors">
|
||||||
|
<a href="#basic">基本信息</a>
|
||||||
|
<a href="#prefs">交付偏好</a>
|
||||||
|
<a href="#base-fields">基础字段</a>
|
||||||
|
<a href="#gen-fields">进入生成</a>
|
||||||
|
<a href="#groups">客户组</a>
|
||||||
|
<a href="#asset">资产包</a>
|
||||||
|
</aside>
|
||||||
|
<div className="lx-client-panel">
|
||||||
|
<Section title="基本信息">
|
||||||
|
<div className="lx-client-kv" id="basic">
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>客户名称</span>
|
||||||
|
<b>{customer.name}</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>客户简称</span>
|
||||||
|
<b>{customer.shortName}</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>所属行业</span>
|
||||||
|
<b>{customer.industryPath}</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>服务状态</span>
|
||||||
|
<b>
|
||||||
|
<CustomerStatusTag status={customer.status} />
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>客户负责人</span>
|
||||||
|
<b>{customer.ownerName}</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>客户侧对接人</span>
|
||||||
|
<b>
|
||||||
|
{customer.contactName ?? '—'} {customer.contactPhone ?? ''}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>创建时间</span>
|
||||||
|
<b>{customer.createdAt.slice(0, 10)}</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>最近更新</span>
|
||||||
|
<b>{customer.updatedAt.slice(0, 19).replace('T', ' ')}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{customer.remark !== null ? (
|
||||||
|
<div className="lx-client-hint">{customer.remark}</div>
|
||||||
|
) : null}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="交付偏好">
|
||||||
|
<div className="lx-client-kv" id="prefs">
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>命名习惯</span>
|
||||||
|
<b>{customer.preferences.naming || '—'}</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>目录结构</span>
|
||||||
|
<b>{customer.preferences.directory || '—'}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-hint">
|
||||||
|
常用规格:{customer.preferences.specs.join('、') || '—'}
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-hint">
|
||||||
|
素材禁忌:{customer.preferences.taboo.join('、') || '—'}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="基础字段"
|
||||||
|
action={
|
||||||
|
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={!canWrite || customer.baseFields.length >= baseFieldLimit}
|
||||||
|
onClick={() => {
|
||||||
|
setModal('base');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
添加基础字段
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div id="base-fields">
|
||||||
|
{customer.baseFields.length === 0 ? (
|
||||||
|
<div className="lx-client-hint">暂无基础字段</div>
|
||||||
|
) : null}
|
||||||
|
{customer.baseFields.map((field, index) => (
|
||||||
|
<div key={`${field.key}-${index}`} className="lx-client-memberrow">
|
||||||
|
<b>{field.key}</b>
|
||||||
|
<span>{field.value}</span>
|
||||||
|
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
theme="borderless"
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() => void removeBaseField(index)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-hint">
|
||||||
|
自由键值对,仅用于检索与报表,永不进入生成;每客户 ≤ {baseFieldLimit} 个(
|
||||||
|
{customer.baseFields.length}/{baseFieldLimit})。
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="进入生成字段"
|
||||||
|
action={
|
||||||
|
<Can permission={Permission.CUSTOMER_FIELD_APPLY}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
disabled={!canWrite || customer.genFieldCount >= genFieldLimit}
|
||||||
|
onClick={() => {
|
||||||
|
setModal('field');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
申请字段
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div id="gen-fields">
|
||||||
|
{fieldRows.length === 0 ? (
|
||||||
|
<div className="lx-client-hint">暂无进入生成字段</div>
|
||||||
|
) : null}
|
||||||
|
{fieldRows.map((field) => (
|
||||||
|
<div key={field.id} className="lx-client-memberrow">
|
||||||
|
<b>{field.name}</b>
|
||||||
|
<span>
|
||||||
|
{field.code} · {field.type} · {field.purpose}
|
||||||
|
</span>
|
||||||
|
<FieldStatusTag field={field} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-hint">
|
||||||
|
需申请 → 审批 → 生效;每客户 ≤ {genFieldLimit} 个({customer.genFieldCount}/
|
||||||
|
{genFieldLimit})。
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="客户组"
|
||||||
|
action={
|
||||||
|
<Can permission={Permission.CUSTOMER_GROUP_MANAGE}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setModal('groups');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
管理客户组
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="lx-client-chips" id="groups">
|
||||||
|
{customer.groups.length === 0 ? (
|
||||||
|
<span className="lx-client-hint">未加入客户组</span>
|
||||||
|
) : null}
|
||||||
|
{customer.groups.map((group) => (
|
||||||
|
<span key={group.id} className="lx-client-chip">
|
||||||
|
{group.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="行业资产包">
|
||||||
|
<div className="lx-client-assetempty" id="asset">
|
||||||
|
<b>该行业尚未发布可用行业包</b>
|
||||||
|
行业包发布后可在此一键套用模板、场景、人物、提示词与卖点,并替换为客户 Logo 与警示语。
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="lx-client-side">
|
||||||
|
<section className="lx-client-sidecard">
|
||||||
|
<h3>服务状态</h3>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>进入生成字段</span>
|
||||||
|
<b>
|
||||||
|
{customer.genFieldCount} / {genFieldLimit}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>基础字段</span>
|
||||||
|
<b>
|
||||||
|
{customer.baseFieldCount} / {baseFieldLimit}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>客户组</span>
|
||||||
|
<b>{customer.groups.length} 个</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>资产包</span>
|
||||||
|
<b>待行业包发布</b>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="lx-client-sidecard">
|
||||||
|
<h3>快捷操作</h3>
|
||||||
|
<div className="lx-client-sideacts">
|
||||||
|
<Can permission={Permission.CUSTOMER_TRANSFER}>
|
||||||
|
<Button
|
||||||
|
theme="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setModal('transfer');
|
||||||
|
}}
|
||||||
|
disabled={customer.status === 'ended'}
|
||||||
|
>
|
||||||
|
变更负责人
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
|
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||||
|
{customer.status === 'active' ? (
|
||||||
|
<Button
|
||||||
|
theme="outline"
|
||||||
|
onClick={() => {
|
||||||
|
changeStatus('paused');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
暂停服务
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{customer.status === 'paused' ? (
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
changeStatus('active');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
恢复服务
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{customer.status !== 'ended' ? (
|
||||||
|
<Button
|
||||||
|
theme="outline"
|
||||||
|
type="danger"
|
||||||
|
onClick={() => {
|
||||||
|
changeStatus('ended');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
结束服务
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-hint">当前客户只读时,快捷操作会按状态自动隐藏。</div>
|
||||||
|
</section>
|
||||||
|
<section className="lx-client-sidecard">
|
||||||
|
<h3>审计提示</h3>
|
||||||
|
<div className="lx-client-hint">
|
||||||
|
客户状态、负责人、字段与客户组的变更都会写入审计,记录前后值与操作人。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modal === 'transfer' ? (
|
||||||
|
<TransferModal
|
||||||
|
customer={customer}
|
||||||
|
onClose={() => {
|
||||||
|
setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{modal === 'base' ? (
|
||||||
|
<BaseFieldModal
|
||||||
|
customer={customer}
|
||||||
|
limit={baseFieldLimit}
|
||||||
|
onClose={() => {
|
||||||
|
setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{modal === 'field' ? (
|
||||||
|
<ApplyFieldModal
|
||||||
|
customer={customer}
|
||||||
|
limit={genFieldLimit}
|
||||||
|
onClose={() => {
|
||||||
|
setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{modal === 'groups' ? (
|
||||||
|
<GroupsModal
|
||||||
|
customer={customer}
|
||||||
|
onClose={() => {
|
||||||
|
setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 四步向导集中编排步骤、行业选择与提交校验 */
|
||||||
|
import { Button, Input, Select } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { CreateCustomerInput, IndustryTreeItem } from '@lingxi/shared';
|
||||||
|
import { notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { useCreateCustomerMutation, useIndustriesQuery } from '@/hooks/queries/use-client';
|
||||||
|
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
|
||||||
|
import { customerDetailPath, ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ key: 'industry', label: '选择行业', desc: '行业决定行业包与可用模板' },
|
||||||
|
{ key: 'basic', label: '基础信息', desc: '名称、简称、负责人与对接人' },
|
||||||
|
{ key: 'prefs', label: '交付偏好', desc: '命名、目录、规格与禁忌' },
|
||||||
|
{ key: 'pack', label: '行业资产包', desc: '冷启动套用行业资产' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function IndustryPicker({
|
||||||
|
items,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
items: readonly IndustryTreeItem[];
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const groups = items
|
||||||
|
.filter((root) => root.status === 'active')
|
||||||
|
.map((root) => ({
|
||||||
|
root,
|
||||||
|
options:
|
||||||
|
root.children.length === 0
|
||||||
|
? [root]
|
||||||
|
: root.children.filter((child) => child.status === 'active'),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.options.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-indpick">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.root.id} className="lx-indpick-group">
|
||||||
|
<div className="lx-indpick-root">
|
||||||
|
<span className="lx-indpick-dot" />
|
||||||
|
<b>{group.root.name}</b>
|
||||||
|
<span>
|
||||||
|
{group.root.children.length === 0
|
||||||
|
? '无二级行业,可直接选择'
|
||||||
|
: `${group.root.children.length} 个二级行业`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-indpick-opts">
|
||||||
|
{group.options.map((option) => {
|
||||||
|
const checked = option.id === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
className={`lx-radio${checked ? ' is-on' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(option.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-radio-dot" />
|
||||||
|
<span>{option.name}</span>
|
||||||
|
<i>{option.customerCount} 个客户</i>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{groups.length === 0 ? <div className="lx-client-empty">暂无可选行业</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建客户(选定方案 B:左侧带说明的步骤导航 + 右侧表单) */
|
||||||
|
export function CustomerNewPage(): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const industries = useIndustriesQuery({ limit: 100 });
|
||||||
|
const users = useUserCandidatesQuery(true);
|
||||||
|
const create = useCreateCustomerMutation();
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [industryId, setIndustryId] = useState<string | undefined>();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [shortName, setShortName] = useState('');
|
||||||
|
const [ownerUserId, setOwnerUserId] = useState<string | undefined>();
|
||||||
|
const [contactName, setContactName] = useState('');
|
||||||
|
const [contactPhone, setContactPhone] = useState('');
|
||||||
|
const [remark, setRemark] = useState('');
|
||||||
|
const [naming, setNaming] = useState('客户名_方向_日期_序号');
|
||||||
|
const [directory, setDirectory] = useState('客户名/年份/方向');
|
||||||
|
const [specs, setSpecs] = useState('1080×1920,1080×1080');
|
||||||
|
const [taboo, setTaboo] = useState('绝对化用语,未授权肖像');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const industryLabel = useMemo(() => {
|
||||||
|
for (const root of industries.data?.items ?? []) {
|
||||||
|
if (root.id === industryId) return root.name;
|
||||||
|
for (const child of root.children) {
|
||||||
|
if (child.id === industryId) return `${root.name} / ${child.name}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '未选择';
|
||||||
|
}, [industries.data, industryId]);
|
||||||
|
|
||||||
|
const next = (): void => {
|
||||||
|
setError(null);
|
||||||
|
if (current === 0 && industryId === undefined) {
|
||||||
|
setError('请选择客户归属行业');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (current === 1) {
|
||||||
|
if (name.trim() === '') {
|
||||||
|
setError('请输入客户名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shortName.trim().length < 2) {
|
||||||
|
setError('客户简称必填,用于自动命名(42201)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ownerUserId === undefined) {
|
||||||
|
setError('请选择客户负责人');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCurrent((step) => Math.min(step + 1, 3));
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
if (industryId === undefined || ownerUserId === undefined) return;
|
||||||
|
const input: CreateCustomerInput = {
|
||||||
|
industryId,
|
||||||
|
name,
|
||||||
|
shortName,
|
||||||
|
ownerUserId,
|
||||||
|
contactName: contactName === '' ? undefined : contactName,
|
||||||
|
contactPhone: contactPhone === '' ? undefined : contactPhone,
|
||||||
|
remark: remark === '' ? undefined : remark,
|
||||||
|
preferences: {
|
||||||
|
naming,
|
||||||
|
directory,
|
||||||
|
specs: specs
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item !== ''),
|
||||||
|
taboo: taboo
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item !== ''),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const created = await create.mutateAsync(input);
|
||||||
|
notifySuccess('客户已创建');
|
||||||
|
void navigate(customerDetailPath(created.id));
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : '创建失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (industries.isPending) return <PageSkeleton rows={6} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-client-page">
|
||||||
|
<header className="lx-client-header">
|
||||||
|
<div>
|
||||||
|
<h1>新建客户</h1>
|
||||||
|
<p>分四步创建客户:选择行业 → 基础信息 → 交付偏好 → 行业资产包</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="lx-client-newgrid">
|
||||||
|
<aside className="lx-client-stepnav">
|
||||||
|
<div className="lx-client-stepnav__head">
|
||||||
|
<b>创建客户</b>
|
||||||
|
<span>共 4 步</span>
|
||||||
|
</div>
|
||||||
|
{STEPS.map((step, index) => {
|
||||||
|
const state = index === current ? ' is-on' : index < current ? ' is-done' : '';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={step.key}
|
||||||
|
type="button"
|
||||||
|
className={`lx-stepnav-item${state}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (index < current) setCurrent(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-stepnav-num">{index < current ? '✓' : index + 1}</span>
|
||||||
|
<span className="lx-stepnav-text">
|
||||||
|
<b>{step.label}</b>
|
||||||
|
<span>{step.desc}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</aside>
|
||||||
|
<section className="lx-client-formcard">
|
||||||
|
<div className="lx-client-formcard__head">
|
||||||
|
<b>{STEPS[current]?.label}</b>
|
||||||
|
<span>
|
||||||
|
{current === 0
|
||||||
|
? `${industryLabel} · 只能选择末级行业`
|
||||||
|
: current === 1
|
||||||
|
? '简称必填,用于全部自动命名'
|
||||||
|
: current === 2
|
||||||
|
? '命名、目录、规格与素材禁忌'
|
||||||
|
: '行业包发布后可一键套用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-formcard__body">
|
||||||
|
{current === 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="lx-client-section__head">
|
||||||
|
<b>客户归属行业</b>
|
||||||
|
<span className="lx-client-hint" style={{ marginTop: 0 }}>
|
||||||
|
只能选择末级行业;一级行业没有二级时可直接选择
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<IndustryPicker
|
||||||
|
items={industries.data?.items ?? []}
|
||||||
|
value={industryId}
|
||||||
|
onChange={setIndustryId}
|
||||||
|
/>
|
||||||
|
<div className="lx-client-hint">
|
||||||
|
所属行业决定行业包;客户创建后不可跨行业移动,调整行业需停用后新建。
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{current === 1 ? (
|
||||||
|
<div className="lx-client-formgrid">
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>
|
||||||
|
客户名称 <i>*</i>
|
||||||
|
</label>
|
||||||
|
<Input value={name} onChange={setName} placeholder="如:花间集" />
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>
|
||||||
|
客户简称 <i>*</i>
|
||||||
|
</label>
|
||||||
|
<Input value={shortName} onChange={setShortName} placeholder="如:HJ" />
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>
|
||||||
|
客户负责人 <i>*</i>
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={ownerUserId}
|
||||||
|
optionList={(users.data?.items ?? []).map((user) => ({
|
||||||
|
value: user.id,
|
||||||
|
label: `${user.name}(${user.departmentName})`,
|
||||||
|
}))}
|
||||||
|
onChange={(value) => {
|
||||||
|
setOwnerUserId(String(value));
|
||||||
|
}}
|
||||||
|
filter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>客户侧对接人</label>
|
||||||
|
<Input value={contactName} onChange={setContactName} placeholder="如:林晓" />
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>对接人电话</label>
|
||||||
|
<Input
|
||||||
|
value={contactPhone}
|
||||||
|
onChange={setContactPhone}
|
||||||
|
placeholder="如:138****2210"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field is-full">
|
||||||
|
<label>备注</label>
|
||||||
|
<Input
|
||||||
|
value={remark}
|
||||||
|
onChange={setRemark}
|
||||||
|
placeholder="客户偏好、合作背景等(可选)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{current === 2 ? (
|
||||||
|
<div className="lx-client-formgrid">
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>命名习惯</label>
|
||||||
|
<Input value={naming} onChange={setNaming} />
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>目录结构</label>
|
||||||
|
<Input value={directory} onChange={setDirectory} />
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field is-full">
|
||||||
|
<label>常用规格(逗号分隔)</label>
|
||||||
|
<Input value={specs} onChange={setSpecs} />
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field is-full">
|
||||||
|
<label>素材禁忌(逗号分隔)</label>
|
||||||
|
<Input value={taboo} onChange={setTaboo} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{current === 3 ? (
|
||||||
|
<div className="lx-client-assetempty">
|
||||||
|
<b>该行业尚未发布可用行业包</b>
|
||||||
|
行业包发布后可在此一键套用模板、场景、人物、提示词与卖点,并自动替换为客户 Logo
|
||||||
|
与警示语。当前可以先完成客户创建,行业包发布后回客户详情套用。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error !== null ? (
|
||||||
|
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-formcard__foot">
|
||||||
|
<span className="lx-client-hint">步骤 {current + 1} / 4 · 可回退修改</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
{current === 0 ? (
|
||||||
|
<Button theme="outline" onClick={() => void navigate(ROUTES.system.customers)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setCurrent((step) => step - 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
上一步
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{current < 3 ? (
|
||||||
|
<Button theme="solid" type="primary" onClick={next}>
|
||||||
|
下一步
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
loading={create.isPending}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
>
|
||||||
|
创建客户
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { CustomerStatus } from '@lingxi/shared';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
const LABELS: Record<CustomerStatus, string> = {
|
||||||
|
active: '在服务',
|
||||||
|
paused: '暂停',
|
||||||
|
ended: '结束',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CustomerStatusTag({ status }: { status: CustomerStatus }): ReactNode {
|
||||||
|
return <span className={`lx-client-tag lx-client-tag--${status}`}>{LABELS[status]}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||||
|
import { Button, Input, Select } from '@douyinfe/semi-ui-19';
|
||||||
|
import { type CustomerSummary, Permission } from '@lingxi/shared';
|
||||||
|
import { PageSkeleton } from '@lingxi/ui';
|
||||||
|
import { IconAdd, IconSearch } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { Can } from '@/components/Can';
|
||||||
|
import {
|
||||||
|
useCustomerGroupsQuery,
|
||||||
|
useCustomersQuery,
|
||||||
|
useIndustriesQuery,
|
||||||
|
} from '@/hooks/queries/use-client';
|
||||||
|
import { useCustomerFieldLimits } from '@/hooks/queries/use-configs';
|
||||||
|
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
|
||||||
|
import { customerDetailPath, customerNewPath, ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
import { CustomerStatusTag } from './CustomerStatusTag';
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'all', label: '全部状态' },
|
||||||
|
{ value: 'active', label: '在服务' },
|
||||||
|
{ value: 'paused', label: '暂停' },
|
||||||
|
{ value: 'ended', label: '结束' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 客户管理(选定方案 A:全宽表格 + 独立详情页) */
|
||||||
|
export function CustomersPage(): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [industryId, setIndustryId] = useState<string | undefined>();
|
||||||
|
const [status, setStatus] = useState<'all' | 'active' | 'paused' | 'ended'>('all');
|
||||||
|
const [ownerUserId, setOwnerUserId] = useState<string | undefined>();
|
||||||
|
const [groupId, setGroupId] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const { baseFieldLimit, genFieldLimit } = useCustomerFieldLimits();
|
||||||
|
const industries = useIndustriesQuery({ limit: 100 });
|
||||||
|
const groups = useCustomerGroupsQuery();
|
||||||
|
const users = useUserCandidatesQuery(true);
|
||||||
|
const customers = useCustomersQuery({
|
||||||
|
limit: 100,
|
||||||
|
...(keyword.trim() === '' ? {} : { keyword: keyword.trim() }),
|
||||||
|
...(industryId === undefined ? {} : { industry_id: industryId }),
|
||||||
|
...(status === 'all' ? {} : { status }),
|
||||||
|
...(ownerUserId === undefined ? {} : { owner_user_id: ownerUserId }),
|
||||||
|
...(groupId === undefined ? {} : { group_id: groupId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const industryOptions = useMemo(() => {
|
||||||
|
const options: { value: string; label: string }[] = [];
|
||||||
|
for (const root of industries.data?.items ?? []) {
|
||||||
|
options.push({ value: root.id, label: root.name });
|
||||||
|
for (const child of root.children) {
|
||||||
|
options.push({ value: child.id, label: `${root.name} / ${child.name}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}, [industries.data]);
|
||||||
|
|
||||||
|
const rows = customers.data?.items ?? [];
|
||||||
|
const counts = {
|
||||||
|
active: rows.filter((item) => item.status === 'active').length,
|
||||||
|
paused: rows.filter((item) => item.status === 'paused').length,
|
||||||
|
ended: rows.filter((item) => item.status === 'ended').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDetail = (customer: CustomerSummary): void => {
|
||||||
|
void navigate(customerDetailPath(customer.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (customers.isPending) return <PageSkeleton rows={8} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-client-page">
|
||||||
|
<header className="lx-client-header">
|
||||||
|
<div>
|
||||||
|
<h1>客户管理</h1>
|
||||||
|
<p>客户是资产与生产任务的隔离单元;暂停后只读,结束服务后全部数据只读归档</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-header__actions">
|
||||||
|
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
icon={<IconAdd size={16} />}
|
||||||
|
onClick={() => void navigate(customerNewPath())}
|
||||||
|
>
|
||||||
|
新建客户
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="lx-client-filters" aria-label="筛选客户">
|
||||||
|
<Input
|
||||||
|
style={{ width: 220 }}
|
||||||
|
value={keyword}
|
||||||
|
prefix={<IconSearch size={16} />}
|
||||||
|
placeholder="搜索客户名称或简称"
|
||||||
|
showClear
|
||||||
|
onChange={setKeyword}
|
||||||
|
aria-label="搜索客户"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
style={{ width: 180 }}
|
||||||
|
value={industryId}
|
||||||
|
placeholder="全部行业"
|
||||||
|
optionList={[{ value: '', label: '全部行业' }, ...industryOptions]}
|
||||||
|
onChange={(value) => {
|
||||||
|
setIndustryId(typeof value === 'string' && value !== '' ? value : undefined);
|
||||||
|
}}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={status}
|
||||||
|
optionList={STATUS_OPTIONS}
|
||||||
|
onChange={(value) => {
|
||||||
|
setStatus(value as typeof status);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
style={{ width: 160 }}
|
||||||
|
value={ownerUserId}
|
||||||
|
placeholder="全部负责人"
|
||||||
|
optionList={[
|
||||||
|
{ value: '', label: '全部负责人' },
|
||||||
|
...(users.data?.items ?? []).map((user) => ({ value: user.id, label: user.name })),
|
||||||
|
]}
|
||||||
|
onChange={(value) => {
|
||||||
|
setOwnerUserId(typeof value === 'string' && value !== '' ? value : undefined);
|
||||||
|
}}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
style={{ width: 160 }}
|
||||||
|
value={groupId}
|
||||||
|
placeholder="全部客户组"
|
||||||
|
optionList={[
|
||||||
|
{ value: '', label: '全部客户组' },
|
||||||
|
...(groups.data?.items ?? []).map((group) => ({ value: group.id, label: group.name })),
|
||||||
|
]}
|
||||||
|
onChange={(value) => {
|
||||||
|
setGroupId(typeof value === 'string' && value !== '' ? value : undefined);
|
||||||
|
}}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
theme="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setKeyword('');
|
||||||
|
setIndustryId(undefined);
|
||||||
|
setStatus('all');
|
||||||
|
setOwnerUserId(undefined);
|
||||||
|
setGroupId(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--lx-color-text-secondary)' }}>
|
||||||
|
共 {rows.length} 个客户
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="lx-client-table-card">
|
||||||
|
{customers.error ? (
|
||||||
|
<div className="lx-client-empty">
|
||||||
|
<b>客户加载失败</b>
|
||||||
|
<span>{customers.error.message}</span>
|
||||||
|
<Button onClick={() => void customers.refetch()}>重试</Button>
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="lx-client-empty">
|
||||||
|
<b>暂无客户</b>
|
||||||
|
<span>按当前筛选条件没有客户,试试重置筛选</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="lx-client-tablehead">
|
||||||
|
<b>全部客户</b>
|
||||||
|
<span>共 {rows.length} 个</span>
|
||||||
|
<div className="lx-client-tablehead__stats">
|
||||||
|
<span>
|
||||||
|
在服务 <b>{counts.active}</b>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
暂停 <b>{counts.paused}</b>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
结束 <b>{counts.ended}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table className="lx-client-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>客户</th>
|
||||||
|
<th>所属行业</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>负责人</th>
|
||||||
|
<th>客户组</th>
|
||||||
|
<th>进入生成</th>
|
||||||
|
<th>基础字段</th>
|
||||||
|
<th>更新时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((customer) => (
|
||||||
|
<tr
|
||||||
|
key={customer.id}
|
||||||
|
onClick={() => {
|
||||||
|
openDetail(customer);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<b>{customer.name}</b>
|
||||||
|
<span className="lx-client-sub">{customer.shortName}</span>
|
||||||
|
</td>
|
||||||
|
<td>{customer.industryPath}</td>
|
||||||
|
<td>
|
||||||
|
<CustomerStatusTag status={customer.status} />
|
||||||
|
</td>
|
||||||
|
<td>{customer.ownerName}</td>
|
||||||
|
<td>{customer.groups.map((group) => group.name).join('、') || '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={`lx-client-count${customer.genFieldCount >= genFieldLimit ? ' is-full' : ''}`}
|
||||||
|
>
|
||||||
|
{customer.genFieldCount}/{genFieldLimit}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="lx-client-count">
|
||||||
|
{customer.baseFieldCount}/{baseFieldLimit}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="lx-client-muted">
|
||||||
|
{customer.updatedAt.slice(0, 19).replace('T', ' ')}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openDetail(customer);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{customers.error ? null : (
|
||||||
|
<div className="lx-client-tablefoot">
|
||||||
|
<span>客户名称在同一末级行业下唯一;客户不可跨行业移动</span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
theme="borderless"
|
||||||
|
onClick={() => void navigate(ROUTES.system.customerGroups)}
|
||||||
|
>
|
||||||
|
管理客户组
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
|
||||||
|
import { Button, RadioGroup, Select, Tag } from '@douyinfe/semi-ui-19';
|
||||||
|
import {
|
||||||
|
DICT_GROUPS,
|
||||||
|
type DictItem,
|
||||||
|
type DictItemListResponse,
|
||||||
|
type DictTypeDetail,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { ErrorState, PageEmpty } from '@lingxi/ui';
|
||||||
|
import { IconAdd, IconAlert, IconEdit, IconPause, IconPlay, IconView } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
dictItemViewStats,
|
||||||
|
dictScopeChips,
|
||||||
|
dictTypeKindLabel,
|
||||||
|
formatDictDateTime,
|
||||||
|
} from './dict-utils';
|
||||||
|
import { DictGroupIcon } from './DictGroupIcon';
|
||||||
|
import { DictItemsView } from './DictItemsView';
|
||||||
|
|
||||||
|
export interface DictScopeOption {
|
||||||
|
value: string | null;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_ITEMS: DictItemListResponse = {
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
layers: [],
|
||||||
|
effectiveCount: 0,
|
||||||
|
overriddenCount: 0,
|
||||||
|
disabledCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function groupNameOf(groupKey: DictTypeDetail['group']): string {
|
||||||
|
return DICT_GROUPS.find((group) => group.key === groupKey)?.name ?? groupKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailSkeleton(): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="lx-dict-detail-skeleton" aria-hidden="true">
|
||||||
|
<div className="lx-dict-detail-skeleton__head">
|
||||||
|
<span className="lx-dict-detail-skeleton__avatar" />
|
||||||
|
<span className="lx-dict-detail-skeleton__lines">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="lx-dict-detail-skeleton__bar" />
|
||||||
|
<div className="lx-dict-detail-skeleton__rows">
|
||||||
|
{Array.from({ length: 6 }, (_value, index) => (
|
||||||
|
<span key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailHeader({
|
||||||
|
type,
|
||||||
|
readOnly,
|
||||||
|
typeDisabled,
|
||||||
|
onAddItem,
|
||||||
|
onEditType,
|
||||||
|
onToggleType,
|
||||||
|
}: {
|
||||||
|
type: DictTypeDetail;
|
||||||
|
readOnly: boolean;
|
||||||
|
typeDisabled: boolean;
|
||||||
|
onAddItem: () => void;
|
||||||
|
onEditType: () => void;
|
||||||
|
onToggleType: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="lx-dict-head">
|
||||||
|
<span className={`lx-dict-head__avatar${typeDisabled ? ' is-gray' : ''}`}>
|
||||||
|
<DictGroupIcon group={type.group} size={22} />
|
||||||
|
</span>
|
||||||
|
<div className="lx-dict-head__main">
|
||||||
|
<div className="lx-dict-head__name">
|
||||||
|
<b>{type.name}</b>
|
||||||
|
<Tag className={type.builtin ? 'lx-dict-tag is-blue' : 'lx-dict-tag'} size="small">
|
||||||
|
{type.builtin ? '内置' : '自定义'}
|
||||||
|
</Tag>
|
||||||
|
<Tag className="lx-dict-tag" size="small">
|
||||||
|
{dictTypeKindLabel(type.kind)}
|
||||||
|
</Tag>
|
||||||
|
<Tag className={type.industryScoped ? 'lx-dict-tag is-blue' : 'lx-dict-tag'} size="small">
|
||||||
|
{type.industryScoped ? '行业作用域' : '全平台'}
|
||||||
|
</Tag>
|
||||||
|
<Tag
|
||||||
|
className={type.status === 'active' ? 'lx-dict-tag is-green' : 'lx-dict-tag is-grey'}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{type.status === 'active' ? '启用' : '停用'}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="lx-dict-head__meta">
|
||||||
|
<code>{type.code}</code>
|
||||||
|
<i>·</i>
|
||||||
|
<span>{groupNameOf(type.group)}</span>
|
||||||
|
<i>·</i>
|
||||||
|
<span>{type.itemCount} 个条目</span>
|
||||||
|
<i>·</i>
|
||||||
|
<span>{type.industryCount} 个行业覆盖</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-dict-head__meta">
|
||||||
|
<span>排序 {type.sortOrder}</span>
|
||||||
|
<i>·</i>
|
||||||
|
<span>
|
||||||
|
最近更新 {type.updatedBy ?? '—'} {formatDictDateTime(type.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{type.description.trim() === '' ? null : (
|
||||||
|
<p className="lx-dict-head__desc">{type.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lx-dict-head__actions">
|
||||||
|
{readOnly ? (
|
||||||
|
<span className="lx-dict-readonly-chip">
|
||||||
|
<IconView size={14} />
|
||||||
|
只读查看
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconAdd size={14} />}
|
||||||
|
disabled={typeDisabled}
|
||||||
|
onClick={onAddItem}
|
||||||
|
>
|
||||||
|
新增条目
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<IconEdit size={14} />}
|
||||||
|
disabled={typeDisabled}
|
||||||
|
onClick={onEditType}
|
||||||
|
>
|
||||||
|
编辑类型
|
||||||
|
</Button>
|
||||||
|
{typeDisabled ? (
|
||||||
|
<Button size="small" icon={<IconPlay size={14} />} onClick={onToggleType}>
|
||||||
|
启用类型
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
theme="light"
|
||||||
|
icon={<IconPause size={14} />}
|
||||||
|
onClick={onToggleType}
|
||||||
|
>
|
||||||
|
停用类型
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScopeBar({
|
||||||
|
type,
|
||||||
|
industryId,
|
||||||
|
options,
|
||||||
|
items,
|
||||||
|
onIndustryChange,
|
||||||
|
}: {
|
||||||
|
type: DictTypeDetail;
|
||||||
|
industryId: string | null;
|
||||||
|
options: readonly DictScopeOption[];
|
||||||
|
items: DictItemListResponse;
|
||||||
|
onIndustryChange: (industryId: string | null) => void;
|
||||||
|
}): ReactNode {
|
||||||
|
if (!type.industryScoped) {
|
||||||
|
return (
|
||||||
|
<div className="lx-dict-scope">
|
||||||
|
<span className="lx-dict-scope__label">行业视角</span>
|
||||||
|
<span className="lx-dict-scope__unsupported">
|
||||||
|
<IconAlert size={14} />
|
||||||
|
全平台通用 · 该类型不支持行业扩展
|
||||||
|
</span>
|
||||||
|
<span className="lx-dict-layer-chip is-on">
|
||||||
|
通用 <b>{items.effectiveCount}</b>
|
||||||
|
</span>
|
||||||
|
<span className="lx-dict-scope__hint">
|
||||||
|
<IconAlert size={13} />
|
||||||
|
行业包只覆盖差异项
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const chips = dictScopeChips(items, { scoped: industryId !== null });
|
||||||
|
return (
|
||||||
|
<div className="lx-dict-scope">
|
||||||
|
<span className="lx-dict-scope__label">行业视角</span>
|
||||||
|
<Select
|
||||||
|
className="lx-dict-scope__select"
|
||||||
|
size="small"
|
||||||
|
value={industryId ?? 'all'}
|
||||||
|
optionList={[
|
||||||
|
{ value: 'all', label: '全平台' },
|
||||||
|
...options.map((option) => ({ value: option.value ?? 'all', label: option.label })),
|
||||||
|
]}
|
||||||
|
onChange={(value) => {
|
||||||
|
onIndustryChange(typeof value === 'string' && value !== 'all' ? value : null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="lx-dict-scope__layers">
|
||||||
|
{chips.map((chip, index) => (
|
||||||
|
<span key={chip.source} className="lx-dict-layer-wrap">
|
||||||
|
{index > 0 ? <i className="lx-dict-layer-plus">+</i> : null}
|
||||||
|
<span className={`lx-dict-layer-chip${chip.count > 0 ? ' is-on' : ''}`}>
|
||||||
|
{chip.label} <b>{chip.count}</b>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<i className="lx-dict-layer-plus">=</i>
|
||||||
|
<span className="lx-dict-layer-chip is-total">
|
||||||
|
生效 <b>{items.effectiveCount}</b>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="lx-dict-scope__hint">
|
||||||
|
<IconAlert size={13} />
|
||||||
|
同编码以更具体层覆盖
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewRow({
|
||||||
|
view,
|
||||||
|
items,
|
||||||
|
onViewChange,
|
||||||
|
}: {
|
||||||
|
view: 'tree' | 'list';
|
||||||
|
items: DictItemListResponse;
|
||||||
|
onViewChange: (view: 'tree' | 'list') => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const stats = dictItemViewStats(items);
|
||||||
|
return (
|
||||||
|
<div className="lx-dict-viewrow">
|
||||||
|
<RadioGroup
|
||||||
|
type="button"
|
||||||
|
buttonSize="small"
|
||||||
|
value={view}
|
||||||
|
options={[
|
||||||
|
{ value: 'tree', label: `树视图 ${stats.total}` },
|
||||||
|
{ value: 'list', label: `词表视图 ${stats.total}` },
|
||||||
|
]}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = String(event.target.value);
|
||||||
|
if (next === 'tree' || next === 'list') onViewChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="lx-dict-viewrow__hint">
|
||||||
|
生效 <b>{stats.active}</b> 项 · 继承 {stats.inherited} · 覆盖 {stats.overridden}
|
||||||
|
{stats.disabled > 0 ? ` · 停用 ${stats.disabled}` : ''}
|
||||||
|
</span>
|
||||||
|
{stats.overridden > 0 ? (
|
||||||
|
<span className="lx-dict-override-pill">{stats.overridden} 项被更具体层覆盖</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyItems({
|
||||||
|
type,
|
||||||
|
readOnly,
|
||||||
|
industryLabel,
|
||||||
|
onAddItem,
|
||||||
|
}: {
|
||||||
|
type: DictTypeDetail;
|
||||||
|
readOnly: boolean;
|
||||||
|
industryLabel: string;
|
||||||
|
onAddItem: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="lx-dict-empty">
|
||||||
|
<PageEmpty
|
||||||
|
title="当前视角下暂无条目"
|
||||||
|
description={`「${industryLabel}」视角没有生效条目;可切换行业视角或新增条目。`}
|
||||||
|
action={
|
||||||
|
readOnly || type.status === 'disabled' ? undefined : (
|
||||||
|
<Button type="primary" theme="solid" icon={<IconAdd size={14} />} onClick={onAddItem}>
|
||||||
|
新增条目
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictDetailPanelProps {
|
||||||
|
type: DictTypeDetail | undefined;
|
||||||
|
typeLoading: boolean;
|
||||||
|
typeError: Error | null;
|
||||||
|
items: DictItemListResponse | undefined;
|
||||||
|
itemsLoading: boolean;
|
||||||
|
itemsError: Error | null;
|
||||||
|
view: 'tree' | 'list';
|
||||||
|
industryId: string | null;
|
||||||
|
industryOptions: readonly DictScopeOption[];
|
||||||
|
readOnly: boolean;
|
||||||
|
onRetry: () => void;
|
||||||
|
onViewChange: (view: 'tree' | 'list') => void;
|
||||||
|
onIndustryChange: (industryId: string | null) => void;
|
||||||
|
onAddItem: () => void;
|
||||||
|
onEditType: () => void;
|
||||||
|
onToggleType: () => void;
|
||||||
|
onViewItem: (item: DictItem) => void;
|
||||||
|
onEditItem: (item: DictItem) => void;
|
||||||
|
onToggleItem: (item: DictItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右栏:类型头 + 行业作用域 + 树/词表切换 + 条目区 + 底部 R-10 提示 */
|
||||||
|
export function DictDetailPanel({
|
||||||
|
type,
|
||||||
|
typeLoading,
|
||||||
|
typeError,
|
||||||
|
items,
|
||||||
|
itemsLoading,
|
||||||
|
itemsError,
|
||||||
|
view,
|
||||||
|
industryId,
|
||||||
|
industryOptions,
|
||||||
|
readOnly,
|
||||||
|
onRetry,
|
||||||
|
onViewChange,
|
||||||
|
onIndustryChange,
|
||||||
|
onAddItem,
|
||||||
|
onEditType,
|
||||||
|
onToggleType,
|
||||||
|
onViewItem,
|
||||||
|
onEditItem,
|
||||||
|
onToggleItem,
|
||||||
|
}: DictDetailPanelProps): ReactNode {
|
||||||
|
const response = items ?? EMPTY_ITEMS;
|
||||||
|
const industryLabel = useMemo(() => {
|
||||||
|
if (industryId === null) return '全平台';
|
||||||
|
return industryOptions.find((option) => option.value === industryId)?.label ?? industryId;
|
||||||
|
}, [industryId, industryOptions]);
|
||||||
|
|
||||||
|
if (typeLoading) {
|
||||||
|
return (
|
||||||
|
<section className="lx-dict-detail">
|
||||||
|
<DetailSkeleton />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeError !== null) {
|
||||||
|
return (
|
||||||
|
<section className="lx-dict-detail">
|
||||||
|
<ErrorState
|
||||||
|
title="字典详情加载失败"
|
||||||
|
message={typeError.message}
|
||||||
|
onRetry={onRetry}
|
||||||
|
className="lx-dict-detail__state"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === undefined) {
|
||||||
|
return (
|
||||||
|
<section className="lx-dict-detail">
|
||||||
|
<PageEmpty
|
||||||
|
title="选择左侧字典类型查看详情"
|
||||||
|
description="支持树视图与词表视图、行业三层合并与条目维护。"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeDisabled = type.status === 'disabled';
|
||||||
|
return (
|
||||||
|
<section className="lx-dict-detail">
|
||||||
|
{typeDisabled ? (
|
||||||
|
<div className="lx-dict-banner">
|
||||||
|
<IconPause size={15} />
|
||||||
|
<div>
|
||||||
|
<b>该字典类型已停用</b>
|
||||||
|
<span>
|
||||||
|
停用后新数据不可选该类型条目;历史引用保留快照,历史统计按快照解释(R-10)。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{readOnly ? (
|
||||||
|
<div className="lx-dict-banner is-muted">
|
||||||
|
<IconView size={15} />
|
||||||
|
<div>
|
||||||
|
<b>当前账号无字典写权限(dict:manage)</b>
|
||||||
|
<span>仅可查看类型、切换视图与行业视角,不可新增或修改条目。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DetailHeader
|
||||||
|
type={type}
|
||||||
|
readOnly={readOnly}
|
||||||
|
typeDisabled={typeDisabled}
|
||||||
|
onAddItem={onAddItem}
|
||||||
|
onEditType={onEditType}
|
||||||
|
onToggleType={onToggleType}
|
||||||
|
/>
|
||||||
|
<ScopeBar
|
||||||
|
type={type}
|
||||||
|
industryId={industryId}
|
||||||
|
options={industryOptions}
|
||||||
|
items={response}
|
||||||
|
onIndustryChange={onIndustryChange}
|
||||||
|
/>
|
||||||
|
<ViewRow view={view} items={response} onViewChange={onViewChange} />
|
||||||
|
<div className="lx-dict-items">
|
||||||
|
{itemsLoading ? <DetailSkeleton /> : null}
|
||||||
|
{!itemsLoading && itemsError !== null ? (
|
||||||
|
<ErrorState
|
||||||
|
title="字典条目加载失败"
|
||||||
|
message={itemsError.message}
|
||||||
|
onRetry={onRetry}
|
||||||
|
className="lx-dict-detail__state"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!itemsLoading && itemsError === null && response.items.length === 0 ? (
|
||||||
|
<EmptyItems
|
||||||
|
type={type}
|
||||||
|
readOnly={readOnly}
|
||||||
|
industryLabel={industryLabel}
|
||||||
|
onAddItem={onAddItem}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!itemsLoading && itemsError === null && response.items.length > 0 ? (
|
||||||
|
<DictItemsView
|
||||||
|
items={response.items}
|
||||||
|
view={view}
|
||||||
|
readOnly={readOnly}
|
||||||
|
typeDisabled={typeDisabled}
|
||||||
|
onView={onViewItem}
|
||||||
|
onEdit={onEditItem}
|
||||||
|
onToggle={onToggleItem}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="lx-dict-footer">
|
||||||
|
<span className="lx-dict-footer__lock">编码创建后不可修改</span>
|
||||||
|
<span className="lx-dict-footer__sep">·</span>
|
||||||
|
<span>变更只影响新数据,历史统计按快照解释(R-10)</span>
|
||||||
|
<span className="lx-dict-footer__right">
|
||||||
|
生效 {response.effectiveCount} 项 · 停用 {response.disabledCount} 项
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { DictGroupKey } from '@lingxi/shared';
|
||||||
|
import { IconScript, IconShield, IconStack, IconUser } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function DictGroupIcon({
|
||||||
|
group,
|
||||||
|
size = 16,
|
||||||
|
}: {
|
||||||
|
group: DictGroupKey;
|
||||||
|
size?: number;
|
||||||
|
}): ReactNode {
|
||||||
|
if (group === 'content') return <IconScript size={size} />;
|
||||||
|
if (group === 'platform') return <IconStack size={size} />;
|
||||||
|
if (group === 'quality') return <IconShield size={size} />;
|
||||||
|
return <IconUser size={size} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
|
||||||
|
import { Button, Input, InputNumber, Select, SideSheet, Switch } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { DictItem, DictTypeDetail } from '@lingxi/shared';
|
||||||
|
import { notifySuccess } from '@lingxi/ui';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { ApiError } from '@/api/client';
|
||||||
|
import { useCreateDictItemMutation, useUpdateDictItemMutation } from '@/hooks/queries/use-dicts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DictItemFormErrors,
|
||||||
|
type DictItemFormState,
|
||||||
|
validateCreateDictItem,
|
||||||
|
validateUpdateDictItem,
|
||||||
|
} from './dict-utils';
|
||||||
|
import type { DictScopeOption } from './DictDetailPanel';
|
||||||
|
|
||||||
|
export type DictItemSheetMode = 'create' | 'edit' | 'view';
|
||||||
|
|
||||||
|
interface DictItemSheetProps {
|
||||||
|
mode: DictItemSheetMode;
|
||||||
|
type: DictTypeDetail;
|
||||||
|
item: DictItem | null;
|
||||||
|
items: readonly DictItem[];
|
||||||
|
industryOptions: readonly DictScopeOption[];
|
||||||
|
defaultIndustryId: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialForm(item: DictItem | null, defaultIndustryId: string | null): DictItemFormState {
|
||||||
|
if (item === null) {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
parentCode: '',
|
||||||
|
industryId: defaultIndustryId ?? '',
|
||||||
|
sortOrder: '10',
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
code: item.code,
|
||||||
|
parentCode: item.parentCode ?? '',
|
||||||
|
industryId: item.industryId ?? '',
|
||||||
|
sortOrder: String(item.sortOrder),
|
||||||
|
status: item.status,
|
||||||
|
remark: item.remark,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
full,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
hint?: ReactNode;
|
||||||
|
error?: string;
|
||||||
|
full?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<label className={`lx-dict-field${full === true ? ' is-full' : ''}`}>
|
||||||
|
<span className="lx-dict-field__label">
|
||||||
|
{label}
|
||||||
|
{required === true ? <i>*</i> : null}
|
||||||
|
{hint === undefined ? null : <span className="lx-dict-field__hint">{hint}</span>}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
{error === undefined ? null : <span className="lx-dict-field__error">{error}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiCodeMessage(cause: unknown): string | null {
|
||||||
|
return cause instanceof ApiError && cause.code === 40902 ? cause.message : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增 / 编辑 / 查看条目:编辑时编码、父级、所属行业只读(创建后不可改) */
|
||||||
|
export function DictItemSheet({
|
||||||
|
mode,
|
||||||
|
type,
|
||||||
|
item,
|
||||||
|
items,
|
||||||
|
industryOptions,
|
||||||
|
defaultIndustryId,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: DictItemSheetProps): ReactNode {
|
||||||
|
const readOnly = mode === 'view';
|
||||||
|
const isTree = type.kind === 'tree';
|
||||||
|
const [form, setForm] = useState<DictItemFormState>(() => initialForm(item, defaultIndustryId));
|
||||||
|
const [errors, setErrors] = useState<DictItemFormErrors>({});
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const createItem = useCreateDictItemMutation();
|
||||||
|
const updateItem = useUpdateDictItemMutation();
|
||||||
|
|
||||||
|
const industryList = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: '', label: '通用(全平台)' },
|
||||||
|
...industryOptions.map((option) => ({ value: option.value ?? '', label: option.label })),
|
||||||
|
],
|
||||||
|
[industryOptions],
|
||||||
|
);
|
||||||
|
const parentList = useMemo(() => {
|
||||||
|
const roots = items.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.parentCode === null &&
|
||||||
|
candidate.code !== item?.code &&
|
||||||
|
candidate.status === 'active',
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
{ value: '', label: '— 作为一级条目 —' },
|
||||||
|
...roots.map((candidate) => ({ value: candidate.code, label: candidate.name })),
|
||||||
|
];
|
||||||
|
}, [items, item?.code]);
|
||||||
|
|
||||||
|
const patch = (values: Partial<DictItemFormState>): void => {
|
||||||
|
setForm((current) => ({ ...current, ...values }));
|
||||||
|
setErrors({});
|
||||||
|
setSubmitError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
if (readOnly) return;
|
||||||
|
if (mode === 'create') {
|
||||||
|
const result = validateCreateDictItem(form);
|
||||||
|
if (result.input === null) {
|
||||||
|
setErrors(result.errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createItem.mutateAsync({ typeCode: type.code, input: result.input });
|
||||||
|
notifySuccess('条目已创建');
|
||||||
|
onSaved();
|
||||||
|
} catch (cause) {
|
||||||
|
const message = apiCodeMessage(cause);
|
||||||
|
if (message !== null) setErrors({ code: message });
|
||||||
|
else setSubmitError(cause instanceof Error ? cause.message : '创建失败');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item === null) return;
|
||||||
|
const result = validateUpdateDictItem(form);
|
||||||
|
if (result.input === null) {
|
||||||
|
setErrors(result.errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateItem.mutateAsync({ typeCode: type.code, itemId: item.id, input: result.input });
|
||||||
|
notifySuccess('条目已更新');
|
||||||
|
onSaved();
|
||||||
|
} catch (cause) {
|
||||||
|
setSubmitError(cause instanceof Error ? cause.message : '保存失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = mode === 'create' ? '新增条目' : mode === 'edit' ? '编辑条目' : '查看条目';
|
||||||
|
const saving = createItem.isPending || updateItem.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SideSheet
|
||||||
|
visible
|
||||||
|
width={560}
|
||||||
|
title={title}
|
||||||
|
closeOnEsc
|
||||||
|
maskClosable={false}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={
|
||||||
|
<div className="lx-dict-sheet__footer">
|
||||||
|
<Button onClick={onClose}>{readOnly ? '关闭' : '取消'}</Button>
|
||||||
|
{readOnly ? null : (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
theme="solid"
|
||||||
|
loading={saving}
|
||||||
|
onClick={() => {
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="lx-dict-sheet">
|
||||||
|
<div className="lx-dict-sheet__sub">
|
||||||
|
所属字典 <code>{type.code}</code> · {type.name}
|
||||||
|
</div>
|
||||||
|
<div className="lx-dict-form-grid">
|
||||||
|
<Field label="条目名称" required full error={errors.name}>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
disabled={readOnly}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder="如:口播"
|
||||||
|
onChange={(value) => {
|
||||||
|
patch({ name: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="编码"
|
||||||
|
required={mode === 'create'}
|
||||||
|
full
|
||||||
|
hint={mode === 'create' ? '创建后不可修改' : '编码创建后不可修改'}
|
||||||
|
error={errors.code}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={form.code}
|
||||||
|
disabled={readOnly || mode !== 'create'}
|
||||||
|
maxLength={49}
|
||||||
|
placeholder="字母、数字、下划线或短横线"
|
||||||
|
onChange={(value) => {
|
||||||
|
patch({ code: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="父级"
|
||||||
|
hint={isTree ? '可选,树类型专有' : '词表类型无层级'}
|
||||||
|
error={errors.parentCode}
|
||||||
|
>
|
||||||
|
{isTree ? (
|
||||||
|
<Select
|
||||||
|
value={form.parentCode}
|
||||||
|
disabled={readOnly || mode === 'edit'}
|
||||||
|
optionList={parentList}
|
||||||
|
onChange={(value) => {
|
||||||
|
patch({ parentCode: typeof value === 'string' ? value : '' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input value="— 词表类型无层级 —" disabled />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="所属行业"
|
||||||
|
hint={mode === 'edit' ? '创建后不可切换' : '默认当前视角,可切换'}
|
||||||
|
error={errors.industryId}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={form.industryId}
|
||||||
|
disabled={readOnly || mode === 'edit'}
|
||||||
|
optionList={industryList}
|
||||||
|
onChange={(value) => {
|
||||||
|
patch({ industryId: typeof value === 'string' ? value : '' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="排序" error={errors.sortOrder}>
|
||||||
|
<InputNumber
|
||||||
|
value={form.sortOrder}
|
||||||
|
min={0}
|
||||||
|
max={9999}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(value) => {
|
||||||
|
patch({ sortOrder: String(value) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="状态">
|
||||||
|
<span className="lx-dict-switch-line">
|
||||||
|
<Switch
|
||||||
|
checked={form.status === 'active'}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(checked) => {
|
||||||
|
patch({ status: checked ? 'active' : 'disabled' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{form.status === 'active' ? '启用条目' : '停用条目'}</span>
|
||||||
|
</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="备注" full error={errors.remark}>
|
||||||
|
<Input
|
||||||
|
value={form.remark}
|
||||||
|
disabled={readOnly}
|
||||||
|
maxLength={255}
|
||||||
|
placeholder="补充用途、口径或平台要求"
|
||||||
|
onChange={(value) => {
|
||||||
|
patch({ remark: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{submitError === null ? null : <div className="lx-dict-form-error">{submitError}</div>}
|
||||||
|
{mode === 'create' ? (
|
||||||
|
<div className="lx-dict-note">
|
||||||
|
编码提交后不可修改;名称、排序、备注与启停可再次编辑。新增在当前行业视角下即时生效。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item !== null ? (
|
||||||
|
<div className="lx-dict-note is-warn">
|
||||||
|
被 <b>{item.refCount}</b> 个行业包 /
|
||||||
|
模板引用;变更只影响新数据,历史引用保留填写时快照(R-10)。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SideSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
|
||||||
|
import type { DictItem } from '@lingxi/shared';
|
||||||
|
import { IconChevronDown, IconChevronRight } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { buildDictItemTree, formatDictDateTime } from './dict-utils';
|
||||||
|
import { DictSourceTags, DictStatusTag } from './DictTags';
|
||||||
|
|
||||||
|
export interface DictItemActionProps {
|
||||||
|
item: DictItem;
|
||||||
|
readOnly: boolean;
|
||||||
|
typeDisabled: boolean;
|
||||||
|
onView: (item: DictItem) => void;
|
||||||
|
onEdit: (item: DictItem) => void;
|
||||||
|
onToggle: (item: DictItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemActions({
|
||||||
|
item,
|
||||||
|
readOnly,
|
||||||
|
typeDisabled,
|
||||||
|
onView,
|
||||||
|
onEdit,
|
||||||
|
onToggle,
|
||||||
|
}: DictItemActionProps): ReactNode {
|
||||||
|
if (readOnly || typeDisabled) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lx-dict-link"
|
||||||
|
onClick={() => {
|
||||||
|
onView(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="lx-dict-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lx-dict-link"
|
||||||
|
onClick={() => {
|
||||||
|
onEdit(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={item.status === 'active' ? 'lx-dict-link is-danger' : 'lx-dict-link'}
|
||||||
|
onClick={() => {
|
||||||
|
onToggle(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.status === 'active' ? '停用' : '启用'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemCells({ item }: { item: DictItem }): ReactNode {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td className="lx-dict-col-name">
|
||||||
|
<b title={item.name}>{item.name}</b>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code className="lx-dict-code">{item.code}</code>
|
||||||
|
</td>
|
||||||
|
<td className="lx-dict-col-parent" title={item.parentName ?? undefined}>
|
||||||
|
{item.parentName ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<DictSourceTags
|
||||||
|
source={item.source}
|
||||||
|
overridden={item.overridden}
|
||||||
|
disabled={item.status === 'disabled'}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="lx-dict-num">{item.sortOrder}</td>
|
||||||
|
<td>
|
||||||
|
<DictStatusTag status={item.status} />
|
||||||
|
</td>
|
||||||
|
<td className="lx-dict-num">{item.refCount}</td>
|
||||||
|
<td className="lx-dict-col-time" title={item.updatedAt}>
|
||||||
|
{formatDictDateTime(item.updatedAt)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DictItemsTable({
|
||||||
|
items,
|
||||||
|
readOnly,
|
||||||
|
typeDisabled,
|
||||||
|
onView,
|
||||||
|
onEdit,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
items: readonly DictItem[];
|
||||||
|
} & Omit<DictItemActionProps, 'item'>): ReactNode {
|
||||||
|
return (
|
||||||
|
<table className="lx-dict-table">
|
||||||
|
<colgroup>
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>编码</th>
|
||||||
|
<th>父级</th>
|
||||||
|
<th>来源</th>
|
||||||
|
<th className="lx-dict-num">排序</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th className="lx-dict-num">引用</th>
|
||||||
|
<th>更新时间</th>
|
||||||
|
<th className="lx-dict-col-acts">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id} className={item.status === 'disabled' ? 'is-disabled' : undefined}>
|
||||||
|
<ItemCells item={item} />
|
||||||
|
<td className="lx-dict-col-acts">
|
||||||
|
<ItemActions
|
||||||
|
item={item}
|
||||||
|
readOnly={readOnly}
|
||||||
|
typeDisabled={typeDisabled}
|
||||||
|
onView={onView}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DictTreeRow({
|
||||||
|
item,
|
||||||
|
childrenByCode,
|
||||||
|
collapsed,
|
||||||
|
onToggleNode,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
item: DictItem;
|
||||||
|
childrenByCode: Map<string, DictItem[]>;
|
||||||
|
collapsed: ReadonlySet<string>;
|
||||||
|
onToggleNode: (code: string) => void;
|
||||||
|
actions: Omit<DictItemActionProps, 'item'>;
|
||||||
|
}): ReactNode {
|
||||||
|
const children = childrenByCode.get(item.code) ?? [];
|
||||||
|
const open = !collapsed.has(item.code);
|
||||||
|
const isParent = children.length > 0;
|
||||||
|
return (
|
||||||
|
<div className="lx-dict-tree__branch">
|
||||||
|
<div className={`lx-dict-tree__row${item.status === 'disabled' ? ' is-disabled' : ''}`}>
|
||||||
|
{isParent ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lx-dict-tree__caret"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={open ? '收起子项' : '展开子项'}
|
||||||
|
onClick={() => {
|
||||||
|
onToggleNode(item.code);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{open ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="lx-dict-tree__caret is-empty" />
|
||||||
|
)}
|
||||||
|
<div className="lx-dict-tree__main">
|
||||||
|
<b title={item.name}>{item.name}</b>
|
||||||
|
<code className="lx-dict-code">{item.code}</code>
|
||||||
|
<DictSourceTags
|
||||||
|
source={item.source}
|
||||||
|
overridden={item.overridden}
|
||||||
|
disabled={item.status === 'disabled'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lx-dict-tree__meta">
|
||||||
|
{isParent ? <span>{children.length} 个子项</span> : null}
|
||||||
|
<span>{item.refCount} 引用</span>
|
||||||
|
<span>{formatDictDateTime(item.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-dict-tree__acts">
|
||||||
|
<ItemActions item={item} {...actions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isParent && open
|
||||||
|
? children.map((child) => (
|
||||||
|
<div key={child.id} className="lx-dict-tree__child">
|
||||||
|
<DictTreeRow
|
||||||
|
item={child}
|
||||||
|
childrenByCode={childrenByCode}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggleNode={onToggleNode}
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DictItemsTree({
|
||||||
|
items,
|
||||||
|
readOnly,
|
||||||
|
typeDisabled,
|
||||||
|
onView,
|
||||||
|
onEdit,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
items: readonly DictItem[];
|
||||||
|
} & Omit<DictItemActionProps, 'item'>): ReactNode {
|
||||||
|
const tree = useMemo(() => buildDictItemTree(items), [items]);
|
||||||
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
const toggleNode = (code: string): void => {
|
||||||
|
setCollapsed((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(code)) next.delete(code);
|
||||||
|
else next.add(code);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="lx-dict-tree">
|
||||||
|
{tree.roots.map((item) => (
|
||||||
|
<DictTreeRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
childrenByCode={tree.childrenByCode}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggleNode={toggleNode}
|
||||||
|
actions={{ readOnly, typeDisabled, onView, onEdit, onToggle }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictItemsViewProps {
|
||||||
|
items: readonly DictItem[];
|
||||||
|
view: 'tree' | 'list';
|
||||||
|
readOnly: boolean;
|
||||||
|
typeDisabled: boolean;
|
||||||
|
onView: (item: DictItem) => void;
|
||||||
|
onEdit: (item: DictItem) => void;
|
||||||
|
onToggle: (item: DictItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 条目区域:树视图 / 词表视图(两种视图都展示编码、来源与停用态) */
|
||||||
|
export function DictItemsView({
|
||||||
|
items,
|
||||||
|
view,
|
||||||
|
readOnly,
|
||||||
|
typeDisabled,
|
||||||
|
onView,
|
||||||
|
onEdit,
|
||||||
|
onToggle,
|
||||||
|
}: DictItemsViewProps): ReactNode {
|
||||||
|
const actions = { readOnly, typeDisabled, onView, onEdit, onToggle };
|
||||||
|
return view === 'tree' ? (
|
||||||
|
<DictItemsTree items={items} {...actions} />
|
||||||
|
) : (
|
||||||
|
<DictItemsTable items={items} {...actions} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Tag } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { DictItemSource, DictStatus } from '@lingxi/shared';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { dictSourceLabel } from './dict-utils';
|
||||||
|
|
||||||
|
export function DictStatusTag({ status }: { status: DictStatus }): ReactNode {
|
||||||
|
return (
|
||||||
|
<Tag className={`lx-dict-status lx-dict-status--${status}`} size="small">
|
||||||
|
{status === 'active' ? '启用' : '停用'}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 三层合并来源标签:通用 / 父行业 / 本行业 +(可选)已覆盖(R-10 快照仍按停用展示) */
|
||||||
|
export function DictSourceTags({
|
||||||
|
source,
|
||||||
|
overridden,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
source: DictItemSource;
|
||||||
|
overridden: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<span className="lx-dict-source-group">
|
||||||
|
<Tag className={`lx-dict-source lx-dict-source--${source}`} size="small">
|
||||||
|
{dictSourceLabel(source)}
|
||||||
|
</Tag>
|
||||||
|
{overridden ? (
|
||||||
|
<Tag className="lx-dict-source lx-dict-source--override" size="small">
|
||||||
|
已覆盖
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
{disabled === true ? (
|
||||||
|
<Tag className="lx-dict-source lx-dict-source--disabled" size="small">
|
||||||
|
已停用
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
|
||||||
|
import { Input, RadioGroup } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { DictGroupKey, DictStatus, DictTypeSummary } from '@lingxi/shared';
|
||||||
|
import { ErrorState, PageEmpty } from '@lingxi/ui';
|
||||||
|
import { IconChevronDown, IconChevronRight, IconSearch } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { dictTypeKindLabel, formatDictDate, groupDictTypes } from './dict-utils';
|
||||||
|
import { DictGroupIcon } from './DictGroupIcon';
|
||||||
|
import { DictStatusTag } from './DictTags';
|
||||||
|
|
||||||
|
export interface DictTypeListPanelProps {
|
||||||
|
types: readonly DictTypeSummary[];
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
keyword: string;
|
||||||
|
status: 'all' | DictStatus;
|
||||||
|
selectedCode: string | null;
|
||||||
|
onKeywordChange: (value: string) => void;
|
||||||
|
onStatusChange: (value: 'all' | DictStatus) => void;
|
||||||
|
onSelect: (typeCode: string) => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DictTypeRow({
|
||||||
|
type,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
type: DictTypeSummary;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (typeCode: string) => void;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`lx-dict-type${selected ? ' is-selected' : ''}`}
|
||||||
|
aria-current={selected ? 'true' : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(type.code);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-dict-type__icon">
|
||||||
|
<DictGroupIcon group={type.group} />
|
||||||
|
</span>
|
||||||
|
<span className="lx-dict-type__main">
|
||||||
|
<span className="lx-dict-type__name">
|
||||||
|
<b>{type.name}</b>
|
||||||
|
<span className="lx-dict-mini-tag">{dictTypeKindLabel(type.kind)}</span>
|
||||||
|
{type.builtin ? <span className="lx-dict-mini-tag is-blue">内置</span> : null}
|
||||||
|
<span className={`lx-dict-mini-tag${type.industryScoped ? ' is-blue' : ''}`}>
|
||||||
|
{type.industryScoped ? '行业' : '全平台'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="lx-dict-type__meta">
|
||||||
|
<code>{type.code}</code>
|
||||||
|
<i>·</i>
|
||||||
|
<span>{type.itemCount} 项</span>
|
||||||
|
{type.industryScoped ? (
|
||||||
|
<>
|
||||||
|
<i>·</i>
|
||||||
|
<span>覆盖 {type.industryCount}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="lx-dict-type__side">
|
||||||
|
<DictStatusTag status={type.status} />
|
||||||
|
<span className="lx-dict-type__date">{formatDictDate(type.updatedAt)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DictTypeGroup({
|
||||||
|
name,
|
||||||
|
count,
|
||||||
|
iconKey,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
iconKey: DictGroupKey;
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<section className="lx-dict-group">
|
||||||
|
<button type="button" className="lx-dict-group__head" aria-expanded={open} onClick={onToggle}>
|
||||||
|
<span className="lx-dict-group__caret">
|
||||||
|
{open ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
||||||
|
</span>
|
||||||
|
<span className="lx-dict-group__icon">
|
||||||
|
<DictGroupIcon group={iconKey} />
|
||||||
|
</span>
|
||||||
|
<b>{name}</b>
|
||||||
|
<span className="lx-dict-group__count">{count} 个类型</span>
|
||||||
|
</button>
|
||||||
|
{open ? <div className="lx-dict-group__items">{children}</div> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DictListSkeleton(): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="lx-dict-skeleton" aria-hidden="true">
|
||||||
|
{Array.from({ length: 6 }, (_value, index) => (
|
||||||
|
<div key={index} className="lx-dict-skeleton__row">
|
||||||
|
<span className="lx-dict-skeleton__icon" />
|
||||||
|
<span className="lx-dict-skeleton__lines">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 左栏 320:统计在页头,卡片内是搜索 + 状态筛选 + 4 分组可折叠类型列表 */
|
||||||
|
export function DictTypeListPanel({
|
||||||
|
types,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
keyword,
|
||||||
|
status,
|
||||||
|
selectedCode,
|
||||||
|
onKeywordChange,
|
||||||
|
onStatusChange,
|
||||||
|
onSelect,
|
||||||
|
onRetry,
|
||||||
|
}: DictTypeListPanelProps): ReactNode {
|
||||||
|
const groups = useMemo(() => groupDictTypes(types), [types]);
|
||||||
|
const [closedGroups, setClosedGroups] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
const statusCounts = useMemo(
|
||||||
|
() => ({
|
||||||
|
all: types.length,
|
||||||
|
active: types.filter((type) => type.status === 'active').length,
|
||||||
|
disabled: types.filter((type) => type.status === 'disabled').length,
|
||||||
|
}),
|
||||||
|
[types],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleGroup = (groupKey: string): void => {
|
||||||
|
setClosedGroups((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(groupKey)) next.delete(groupKey);
|
||||||
|
else next.add(groupKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="lx-dict-list" aria-label="字典类型">
|
||||||
|
<div className="lx-dict-list__head">
|
||||||
|
<span className="lx-dict-list__title">字典类型</span>
|
||||||
|
<span className="lx-dict-list__count">{loading ? '—' : types.length}</span>
|
||||||
|
<span className="lx-dict-list__note">三层合并</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-dict-list__tools">
|
||||||
|
<Input
|
||||||
|
value={keyword}
|
||||||
|
prefix={<IconSearch size={16} />}
|
||||||
|
placeholder="搜索类型名称或编码"
|
||||||
|
showClear
|
||||||
|
aria-label="搜索字典类型"
|
||||||
|
onChange={onKeywordChange}
|
||||||
|
/>
|
||||||
|
<RadioGroup
|
||||||
|
type="button"
|
||||||
|
buttonSize="small"
|
||||||
|
className="lx-dict-status-filter"
|
||||||
|
value={status}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: `全部 ${statusCounts.all}` },
|
||||||
|
{ value: 'active', label: `启用 ${statusCounts.active}` },
|
||||||
|
{ value: 'disabled', label: `停用 ${statusCounts.disabled}` },
|
||||||
|
]}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = String(event.target.value);
|
||||||
|
if (next === 'all' || next === 'active' || next === 'disabled') onStatusChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lx-dict-list__body">
|
||||||
|
{loading ? <DictListSkeleton /> : null}
|
||||||
|
{error !== null ? (
|
||||||
|
<ErrorState
|
||||||
|
title="字典类型加载失败"
|
||||||
|
message={error.message}
|
||||||
|
onRetry={onRetry}
|
||||||
|
className="lx-dict-list__state"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!loading && error === null && groups.length === 0 ? (
|
||||||
|
<PageEmpty
|
||||||
|
title="没有匹配的字典类型"
|
||||||
|
description={
|
||||||
|
keyword.trim() === ''
|
||||||
|
? '当前没有可用类型,可新建字典类型'
|
||||||
|
: '换个关键词或调整状态筛选'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!loading && error === null
|
||||||
|
? groups.map((group) => (
|
||||||
|
<DictTypeGroup
|
||||||
|
key={group.key}
|
||||||
|
name={group.name}
|
||||||
|
count={group.types.length}
|
||||||
|
iconKey={group.key}
|
||||||
|
open={!closedGroups.has(group.key)}
|
||||||
|
onToggle={() => {
|
||||||
|
toggleGroup(group.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.types.map((type) => (
|
||||||
|
<DictTypeRow
|
||||||
|
key={type.code}
|
||||||
|
type={type}
|
||||||
|
selected={type.code === selectedCode}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DictTypeGroup>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
|
||||||
|
import { Button, Input, InputNumber, Modal, Select, Switch, TextArea } from '@douyinfe/semi-ui-19';
|
||||||
|
import { DICT_GROUPS, type DictTypeDetail } from '@lingxi/shared';
|
||||||
|
import { notifySuccess } from '@lingxi/ui';
|
||||||
|
import { IconDelete } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { ApiError } from '@/api/client';
|
||||||
|
import {
|
||||||
|
useCreateDictTypeMutation,
|
||||||
|
useDeleteDictTypeMutation,
|
||||||
|
useUpdateDictTypeMutation,
|
||||||
|
} from '@/hooks/queries/use-dicts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DictTypeFormErrors,
|
||||||
|
type DictTypeFormState,
|
||||||
|
validateCreateDictType,
|
||||||
|
validateUpdateDictType,
|
||||||
|
} from './dict-utils';
|
||||||
|
|
||||||
|
export type DictTypeModalMode = 'create' | 'edit';
|
||||||
|
|
||||||
|
interface DictTypeModalProps {
|
||||||
|
mode: DictTypeModalMode;
|
||||||
|
type: DictTypeDetail | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (type: DictTypeDetail) => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_OPTIONS = DICT_GROUPS.map((group) => ({ value: group.key, label: group.name }));
|
||||||
|
const KIND_OPTIONS = [
|
||||||
|
{ value: 'list', label: '词表' },
|
||||||
|
{ value: 'tree', label: '树' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function initialForm(mode: DictTypeModalMode, type: DictTypeDetail | null): DictTypeFormState {
|
||||||
|
if (mode === 'edit' && type !== null) {
|
||||||
|
return {
|
||||||
|
code: type.code,
|
||||||
|
name: type.name,
|
||||||
|
group: type.group,
|
||||||
|
kind: type.kind,
|
||||||
|
industryScoped: type.industryScoped,
|
||||||
|
description: type.description,
|
||||||
|
sortOrder: String(type.sortOrder),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
group: 'content',
|
||||||
|
kind: 'list',
|
||||||
|
industryScoped: false,
|
||||||
|
description: '',
|
||||||
|
sortOrder: '10',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
full,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
hint?: ReactNode;
|
||||||
|
error?: string;
|
||||||
|
full?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<label className={`lx-dict-field${full === true ? ' is-full' : ''}`}>
|
||||||
|
<span className="lx-dict-field__label">
|
||||||
|
{label}
|
||||||
|
{required === true ? <i>*</i> : null}
|
||||||
|
{hint === undefined ? null : <span className="lx-dict-field__hint">{hint}</span>}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
{error === undefined ? null : <span className="lx-dict-field__error">{error}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建 / 编辑字典类型;内置类型不可删除,编码 / 形态 / 行业作用域创建后只读 */
|
||||||
|
export function DictTypeModal({
|
||||||
|
mode,
|
||||||
|
type,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
onDeleted,
|
||||||
|
}: DictTypeModalProps): ReactNode {
|
||||||
|
const [form, setForm] = useState<DictTypeFormState>(() => initialForm(mode, type));
|
||||||
|
const [errors, setErrors] = useState<DictTypeFormErrors>({});
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const createType = useCreateDictTypeMutation();
|
||||||
|
const updateType = useUpdateDictTypeMutation();
|
||||||
|
const deleteType = useDeleteDictTypeMutation();
|
||||||
|
|
||||||
|
const patch = (values: Partial<DictTypeFormState>): void => {
|
||||||
|
setForm((current) => ({ ...current, ...values }));
|
||||||
|
setErrors({});
|
||||||
|
setSubmitError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
if (mode === 'create') {
|
||||||
|
const result = validateCreateDictType(form);
|
||||||
|
if (result.input === null) {
|
||||||
|
setErrors(result.errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await createType.mutateAsync(result.input);
|
||||||
|
notifySuccess('字典类型已创建');
|
||||||
|
onSaved(created);
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof ApiError && cause.code === 40902) setErrors({ code: cause.message });
|
||||||
|
else setSubmitError(cause instanceof Error ? cause.message : '创建失败');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === null) return;
|
||||||
|
const result = validateUpdateDictType(form);
|
||||||
|
if (result.input === null) {
|
||||||
|
setErrors(result.errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await updateType.mutateAsync({ typeCode: type.code, input: result.input });
|
||||||
|
notifySuccess('字典类型已更新');
|
||||||
|
onSaved(updated);
|
||||||
|
} catch (cause) {
|
||||||
|
setSubmitError(cause instanceof Error ? cause.message : '保存失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (): void => {
|
||||||
|
if (type === null || type.builtin) return;
|
||||||
|
Modal.confirm({
|
||||||
|
title: `删除字典类型「${type.name}」?`,
|
||||||
|
content:
|
||||||
|
'删除后该类型的全部条目将一并移除,历史数据中的填写快照不受影响(R-10)。此操作不可恢复。',
|
||||||
|
okText: '删除类型',
|
||||||
|
okButtonProps: { type: 'danger' },
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await deleteType.mutateAsync(type.code);
|
||||||
|
notifySuccess('字典类型已删除');
|
||||||
|
onDeleted();
|
||||||
|
} catch (cause) {
|
||||||
|
setSubmitError(cause instanceof Error ? cause.message : '删除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const saving = createType.isPending || updateType.isPending;
|
||||||
|
const immutableHint = mode === 'edit' ? '创建后不可修改' : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
title={mode === 'create' ? '新建字典类型' : '编辑字典类型'}
|
||||||
|
width={560}
|
||||||
|
closeOnEsc
|
||||||
|
maskClosable={false}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={
|
||||||
|
<div className="lx-dict-modal__footer">
|
||||||
|
{mode === 'edit' && type !== null && !type.builtin ? (
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
theme="borderless"
|
||||||
|
icon={<IconDelete size={14} />}
|
||||||
|
onClick={confirmDelete}
|
||||||
|
>
|
||||||
|
删除该类型
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<span className="lx-dict-modal__spacer" />
|
||||||
|
<Button onClick={onClose}>取消</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
theme="solid"
|
||||||
|
loading={saving}
|
||||||
|
onClick={() => {
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="lx-dict-form-grid">
|
||||||
|
{mode === 'create' ? (
|
||||||
|
<>
|
||||||
|
<Field label="类型名称" required full error={errors.name}>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder="如:投放位置"
|
||||||
|
onChange={(value) => {
|
||||||
|
patch({ name: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="类型编码" required full hint="创建后不可修改" error={errors.code}>
|
||||||
|
<Input
|
||||||
|
value={form.code}
|
||||||
|
maxLength={49}
|
||||||
|
placeholder="小写字母、数字与下划线"
|
||||||
|
onChange={(value) => {
|
||||||
|
patch({ code: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Field label="类型编码" full hint={immutableHint}>
|
||||||
|
<Input value={form.code} disabled />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{mode === 'edit' ? (
|
||||||
|
<Field label="类型名称" required full error={errors.name}>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
maxLength={64}
|
||||||
|
onChange={(value) => {
|
||||||
|
patch({ name: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
<Field label="所属分组" error={errors.group}>
|
||||||
|
<Select
|
||||||
|
value={form.group}
|
||||||
|
optionList={GROUP_OPTIONS}
|
||||||
|
onChange={(value) => {
|
||||||
|
const matched = DICT_GROUPS.find((group) => group.key === value);
|
||||||
|
if (matched !== undefined) patch({ group: matched.key });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="形态" hint={mode === 'edit' ? immutableHint : '树 / 词表'}>
|
||||||
|
<Select
|
||||||
|
value={form.kind}
|
||||||
|
disabled={mode === 'edit'}
|
||||||
|
optionList={KIND_OPTIONS}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value === 'tree' || value === 'list') patch({ kind: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="行业作用域" hint={mode === 'edit' ? immutableHint : '关闭则全平台通用'}>
|
||||||
|
<span className="lx-dict-switch-line">
|
||||||
|
<Switch
|
||||||
|
checked={form.industryScoped}
|
||||||
|
disabled={mode === 'edit'}
|
||||||
|
onChange={(checked) => {
|
||||||
|
patch({ industryScoped: checked });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{form.industryScoped ? '允许行业覆盖 / 追加' : '仅全平台通用'}</span>
|
||||||
|
</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="排序" error={errors.sortOrder}>
|
||||||
|
<InputNumber
|
||||||
|
value={form.sortOrder}
|
||||||
|
min={0}
|
||||||
|
max={9999}
|
||||||
|
onChange={(value) => {
|
||||||
|
patch({ sortOrder: String(value) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="说明" full error={errors.description}>
|
||||||
|
<TextArea
|
||||||
|
value={form.description}
|
||||||
|
maxLength={255}
|
||||||
|
rows={3}
|
||||||
|
placeholder="说明该字典的用途与引用位置"
|
||||||
|
onChange={(value) => {
|
||||||
|
patch({ description: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{submitError === null ? null : <div className="lx-dict-form-error">{submitError}</div>}
|
||||||
|
{mode === 'edit' && type?.builtin === true ? (
|
||||||
|
<div className="lx-dict-note">内置类型的编码不可修改,仅支持停用,不提供「删除类型」。</div>
|
||||||
|
) : null}
|
||||||
|
{mode === 'create' ? (
|
||||||
|
<div className="lx-dict-note">
|
||||||
|
自定义类型可删除;内置类型仅支持停用。类型编码创建后不可修改。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 字典主从页编排列表筛选、详情查询、URL 状态与两个弹层,集中便于追踪数据流 */
|
||||||
|
import { Button, Modal } from '@douyinfe/semi-ui-19';
|
||||||
|
import { type DictItem, type DictTypeDetail, Permission } from '@lingxi/shared';
|
||||||
|
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||||
|
import { IconAdd, IconView } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { useIndustriesQuery } from '@/hooks/queries/use-client';
|
||||||
|
import {
|
||||||
|
useDictItemsQuery,
|
||||||
|
useDictTypeDetailQuery,
|
||||||
|
useDictTypesQuery,
|
||||||
|
useSetDictTypeStatusMutation,
|
||||||
|
useUpdateDictItemMutation,
|
||||||
|
} from '@/hooks/queries/use-dicts';
|
||||||
|
import { usePermission } from '@/hooks/use-permission';
|
||||||
|
import { dictDetailPath, ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
import { dictTypeStats, filterDictTypes } from './dict-utils';
|
||||||
|
import { DictDetailPanel, type DictScopeOption } from './DictDetailPanel';
|
||||||
|
import { DictItemSheet, type DictItemSheetMode } from './DictItemSheet';
|
||||||
|
import { DictTypeListPanel } from './DictTypeListPanel';
|
||||||
|
import { DictTypeModal, type DictTypeModalMode } from './DictTypeModal';
|
||||||
|
|
||||||
|
interface ItemSheetState {
|
||||||
|
mode: DictItemSheetMode;
|
||||||
|
item: DictItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数据字典(方案 A:左 320 类型列表 + 右详情主从分栏;列表 / 详情两个入口共用) */
|
||||||
|
export function DictsPage(): ReactNode {
|
||||||
|
const { type: routeTypeCode } = useParams<{ type: string }>();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const canWrite = usePermission(Permission.DICT_MANAGE);
|
||||||
|
const isDetailRoute = routeTypeCode !== undefined;
|
||||||
|
|
||||||
|
const [itemSheet, setItemSheet] = useState<ItemSheetState | null>(null);
|
||||||
|
const [typeModal, setTypeModal] = useState<DictTypeModalMode | null>(null);
|
||||||
|
|
||||||
|
const keywordParam = searchParams.get('keyword') ?? '';
|
||||||
|
const statusParam = searchParams.get('status');
|
||||||
|
const status = statusParam === 'active' || statusParam === 'disabled' ? statusParam : 'all';
|
||||||
|
const industryParam = searchParams.get('industry');
|
||||||
|
const viewParam = searchParams.get('view');
|
||||||
|
|
||||||
|
const typesQuery = useDictTypesQuery({ limit: 100 });
|
||||||
|
const industries = useIndustriesQuery({ limit: 100 });
|
||||||
|
const allTypes = useMemo(() => typesQuery.data?.items ?? [], [typesQuery.data]);
|
||||||
|
const visibleTypes = useMemo(
|
||||||
|
() => filterDictTypes(allTypes, { keyword: keywordParam, status }),
|
||||||
|
[allTypes, keywordParam, status],
|
||||||
|
);
|
||||||
|
const stats = useMemo(() => dictTypeStats(allTypes), [allTypes]);
|
||||||
|
|
||||||
|
const defaultTypeCode =
|
||||||
|
allTypes.find((candidate) => candidate.code === 'script_direction')?.code ??
|
||||||
|
visibleTypes[0]?.code ??
|
||||||
|
allTypes[0]?.code ??
|
||||||
|
null;
|
||||||
|
const selectedCode = routeTypeCode ?? defaultTypeCode;
|
||||||
|
const detailQuery = useDictTypeDetailQuery(selectedCode);
|
||||||
|
const type = detailQuery.data;
|
||||||
|
/** 行业视角:无 query 时默认「电商 / 服饰鞋包」;`industry=all` 表示显式选择全平台 */
|
||||||
|
const defaultIndustryId = useMemo(() => {
|
||||||
|
let firstLeaf: string | null = null;
|
||||||
|
for (const root of industries.data?.items ?? []) {
|
||||||
|
if (root.name === '服饰鞋包') return root.id;
|
||||||
|
for (const child of root.children) {
|
||||||
|
if (child.name === '服饰鞋包') return child.id;
|
||||||
|
firstLeaf ??= child.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstLeaf;
|
||||||
|
}, [industries.data]);
|
||||||
|
const industryId =
|
||||||
|
type?.industryScoped === true
|
||||||
|
? industryParam === null
|
||||||
|
? defaultIndustryId
|
||||||
|
: industryParam === 'all'
|
||||||
|
? null
|
||||||
|
: industryParam
|
||||||
|
: null;
|
||||||
|
const itemsQuery = useDictItemsQuery(selectedCode, industryId, {
|
||||||
|
enabled: type !== undefined,
|
||||||
|
});
|
||||||
|
const view =
|
||||||
|
viewParam === 'tree' || viewParam === 'list'
|
||||||
|
? viewParam
|
||||||
|
: type?.kind === 'tree'
|
||||||
|
? 'tree'
|
||||||
|
: 'list';
|
||||||
|
|
||||||
|
const industryOptions = useMemo<DictScopeOption[]>(() => {
|
||||||
|
const options: DictScopeOption[] = [];
|
||||||
|
for (const root of industries.data?.items ?? []) {
|
||||||
|
options.push({ value: root.id, label: root.name });
|
||||||
|
for (const child of root.children) {
|
||||||
|
options.push({ value: child.id, label: `${root.name} / ${child.name}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}, [industries.data]);
|
||||||
|
|
||||||
|
const updateItem = useUpdateDictItemMutation();
|
||||||
|
const setTypeStatus = useSetDictTypeStatusMutation();
|
||||||
|
|
||||||
|
const updateParams = (updates: Record<string, string | null>, replace = false): void => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (value === null) next.delete(key);
|
||||||
|
else next.set(key, value);
|
||||||
|
}
|
||||||
|
setSearchParams(next, { replace });
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectType = (typeCode: string): void => {
|
||||||
|
const search = searchParams.toString();
|
||||||
|
void navigate({
|
||||||
|
pathname: dictDetailPath(typeCode),
|
||||||
|
search: search === '' ? '' : `?${search}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const retry = (): void => {
|
||||||
|
void detailQuery.refetch();
|
||||||
|
void itemsQuery.refetch();
|
||||||
|
void typesQuery.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateItem = (): void => {
|
||||||
|
if (!canWrite || type === undefined || type.status === 'disabled') return;
|
||||||
|
setItemSheet({ mode: 'create', item: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleItem = (item: DictItem): void => {
|
||||||
|
if (type === undefined) return;
|
||||||
|
const disabling = item.status === 'active';
|
||||||
|
Modal.confirm({
|
||||||
|
title: `${disabling ? '停用条目「' : '启用条目「'}${item.name}」?`,
|
||||||
|
content: disabling
|
||||||
|
? '停用后新数据不可选该条目,历史引用仍显示「已停用」,历史统计按填写时快照解释(R-10)。'
|
||||||
|
: '启用后新数据可重新选择该条目,历史数据与快照不变。',
|
||||||
|
okText: disabling ? '停用' : '启用',
|
||||||
|
okButtonProps: disabling ? { type: 'danger' } : undefined,
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await updateItem.mutateAsync({
|
||||||
|
typeCode: type.code,
|
||||||
|
itemId: item.id,
|
||||||
|
input: { status: disabling ? 'disabled' : 'active' },
|
||||||
|
});
|
||||||
|
notifySuccess(disabling ? `已停用「${item.name}」` : `已启用「${item.name}」`);
|
||||||
|
} catch (cause) {
|
||||||
|
notifyError(cause instanceof Error ? cause.message : '操作失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleType = (): void => {
|
||||||
|
if (type === undefined) return;
|
||||||
|
const disabling = type.status === 'active';
|
||||||
|
Modal.confirm({
|
||||||
|
title: `${disabling ? '停用字典类型「' : '启用字典类型「'}${type.name}」?`,
|
||||||
|
content: disabling
|
||||||
|
? `停用后新数据不可选该类型条目(共 ${type.itemCount} 个,含行业覆盖项);历史引用保留填写时快照,历史统计按快照解释(R-10)。`
|
||||||
|
: '启用后新数据恢复可选该类型条目,历史快照不变。',
|
||||||
|
okText: disabling ? '停用类型' : '启用类型',
|
||||||
|
okButtonProps: disabling ? { type: 'danger' } : undefined,
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await setTypeStatus.mutateAsync({
|
||||||
|
typeCode: type.code,
|
||||||
|
input: { status: disabling ? 'disabled' : 'active' },
|
||||||
|
});
|
||||||
|
notifySuccess(disabling ? '已停用字典类型' : '已启用字典类型');
|
||||||
|
} catch (cause) {
|
||||||
|
notifyError(cause instanceof Error ? cause.message : '操作失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTypeSaved = (saved: DictTypeDetail): void => {
|
||||||
|
setTypeModal(null);
|
||||||
|
if (!isDetailRoute) {
|
||||||
|
const search = searchParams.toString();
|
||||||
|
void navigate({
|
||||||
|
pathname: dictDetailPath(saved.code),
|
||||||
|
search: search === '' ? '' : `?${search}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTypeDeleted = (): void => {
|
||||||
|
setTypeModal(null);
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.delete('industry');
|
||||||
|
next.delete('view');
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
void navigate({
|
||||||
|
pathname: ROUTES.system.dicts,
|
||||||
|
search: next.toString() === '' ? '' : `?${next.toString()}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageHead = (
|
||||||
|
<header className="lx-dict-page__header">
|
||||||
|
<div>
|
||||||
|
<h1>数据字典</h1>
|
||||||
|
<p>平台枚举、行业词表与编码的唯一来源</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-dict-page__actions">
|
||||||
|
{canWrite ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconAdd size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
setTypeModal('create');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建字典类型
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="lx-dict-readonly-chip">
|
||||||
|
<IconView size={14} />
|
||||||
|
只读权限
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
|
||||||
|
const statStrip = (
|
||||||
|
<div className="lx-dict-statstrip">
|
||||||
|
<span className="lx-dict-statstrip__item">
|
||||||
|
字典类型 <b>{typesQuery.isPending ? '—' : stats.typeCount}</b>
|
||||||
|
</span>
|
||||||
|
<span className="lx-dict-statstrip__item">
|
||||||
|
字典条目 <b>{typesQuery.isPending ? '—' : stats.itemCount}</b>
|
||||||
|
</span>
|
||||||
|
<span className="lx-dict-statstrip__item">
|
||||||
|
分组 <b>{typesQuery.isPending ? '—' : stats.groupCount}</b>
|
||||||
|
</span>
|
||||||
|
<span className="lx-dict-statstrip__item">
|
||||||
|
树类型 <b>{typesQuery.isPending ? '—' : stats.treeCount}</b>
|
||||||
|
</span>
|
||||||
|
<span className="lx-dict-statstrip__hint">
|
||||||
|
类型编码创建后不可修改;条目变更只影响新数据(R-10)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-dict-page">
|
||||||
|
{isDetailRoute ? null : pageHead}
|
||||||
|
{isDetailRoute ? null : statStrip}
|
||||||
|
{typesQuery.isPending && !isDetailRoute ? (
|
||||||
|
<PageSkeleton rows={8} />
|
||||||
|
) : (
|
||||||
|
<div className="lx-dict-split">
|
||||||
|
<DictTypeListPanel
|
||||||
|
types={visibleTypes}
|
||||||
|
loading={typesQuery.isPending}
|
||||||
|
error={typesQuery.error}
|
||||||
|
keyword={keywordParam}
|
||||||
|
status={status}
|
||||||
|
selectedCode={selectedCode}
|
||||||
|
onKeywordChange={(value) => {
|
||||||
|
updateParams({ keyword: value === '' ? null : value }, true);
|
||||||
|
}}
|
||||||
|
onStatusChange={(value) => {
|
||||||
|
updateParams({ status: value === 'all' ? null : value }, true);
|
||||||
|
}}
|
||||||
|
onSelect={selectType}
|
||||||
|
onRetry={() => {
|
||||||
|
void typesQuery.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DictDetailPanel
|
||||||
|
type={type}
|
||||||
|
typeLoading={selectedCode !== null && detailQuery.isPending}
|
||||||
|
typeError={detailQuery.error}
|
||||||
|
items={itemsQuery.data}
|
||||||
|
itemsLoading={selectedCode !== null && itemsQuery.isPending}
|
||||||
|
itemsError={itemsQuery.error}
|
||||||
|
view={view}
|
||||||
|
industryId={industryId}
|
||||||
|
industryOptions={industryOptions}
|
||||||
|
readOnly={!canWrite}
|
||||||
|
onRetry={retry}
|
||||||
|
onViewChange={(next) => {
|
||||||
|
updateParams({ view: next });
|
||||||
|
}}
|
||||||
|
onIndustryChange={(next) => {
|
||||||
|
// 显式选择全平台写入 industry=all,避免被默认行业覆盖
|
||||||
|
updateParams({ industry: next ?? 'all' });
|
||||||
|
}}
|
||||||
|
onAddItem={openCreateItem}
|
||||||
|
onEditType={() => {
|
||||||
|
setTypeModal('edit');
|
||||||
|
}}
|
||||||
|
onToggleType={toggleType}
|
||||||
|
onViewItem={(item) => {
|
||||||
|
setItemSheet({ mode: 'view', item });
|
||||||
|
}}
|
||||||
|
onEditItem={(item) => {
|
||||||
|
setItemSheet({ mode: 'edit', item });
|
||||||
|
}}
|
||||||
|
onToggleItem={toggleItem}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{itemSheet !== null && type !== undefined ? (
|
||||||
|
<DictItemSheet
|
||||||
|
mode={itemSheet.mode}
|
||||||
|
type={type}
|
||||||
|
item={itemSheet.item}
|
||||||
|
items={itemsQuery.data?.items ?? []}
|
||||||
|
industryOptions={industryOptions}
|
||||||
|
defaultIndustryId={industryId}
|
||||||
|
onClose={() => {
|
||||||
|
setItemSheet(null);
|
||||||
|
}}
|
||||||
|
onSaved={() => {
|
||||||
|
setItemSheet(null);
|
||||||
|
void itemsQuery.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{typeModal !== null ? (
|
||||||
|
<DictTypeModal
|
||||||
|
mode={typeModal}
|
||||||
|
type={typeModal === 'edit' ? (type ?? null) : null}
|
||||||
|
onClose={() => {
|
||||||
|
setTypeModal(null);
|
||||||
|
}}
|
||||||
|
onSaved={handleTypeSaved}
|
||||||
|
onDeleted={handleTypeDeleted}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import type { DictItem, DictItemListResponse, DictTypeSummary } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildDictItemTree,
|
||||||
|
dictItemViewStats,
|
||||||
|
dictScopeChips,
|
||||||
|
dictSourceLabel,
|
||||||
|
dictTypeKindLabel,
|
||||||
|
dictTypeStats,
|
||||||
|
filterDictTypes,
|
||||||
|
formatDictDate,
|
||||||
|
groupDictTypes,
|
||||||
|
isDictItemCodeValid,
|
||||||
|
isDictTypeCodeValid,
|
||||||
|
validateCreateDictItem,
|
||||||
|
validateCreateDictType,
|
||||||
|
validateUpdateDictItem,
|
||||||
|
} from './dict-utils';
|
||||||
|
|
||||||
|
const NOW = '2026-11-04T10:00:00.000Z';
|
||||||
|
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||||
|
|
||||||
|
function typeSummary(overrides: Partial<DictTypeSummary> & { code: string }): DictTypeSummary {
|
||||||
|
return {
|
||||||
|
name: overrides.code,
|
||||||
|
group: 'content',
|
||||||
|
kind: 'list',
|
||||||
|
description: '',
|
||||||
|
industryScoped: false,
|
||||||
|
builtin: true,
|
||||||
|
status: 'active',
|
||||||
|
sortOrder: 10,
|
||||||
|
itemCount: 0,
|
||||||
|
activeItemCount: 0,
|
||||||
|
industryCount: 0,
|
||||||
|
updatedAt: NOW,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dictItem(overrides: Partial<DictItem> & { id: string; code: string }): DictItem {
|
||||||
|
return {
|
||||||
|
typeCode: 'script_direction',
|
||||||
|
name: overrides.code,
|
||||||
|
parentCode: null,
|
||||||
|
parentName: null,
|
||||||
|
industryId: null,
|
||||||
|
industryPath: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
source: 'generic',
|
||||||
|
overridden: false,
|
||||||
|
refCount: 0,
|
||||||
|
updatedAt: NOW,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemResponse(items: DictItem[]): DictItemListResponse {
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
next_cursor: null,
|
||||||
|
layers: [],
|
||||||
|
effectiveCount: items.length,
|
||||||
|
overriddenCount: items.filter((item) => item.overridden).length,
|
||||||
|
disabledCount: items.filter((item) => item.status === 'disabled').length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('数据字典派生视图', () => {
|
||||||
|
it('构建二级树:父级缺失的条目提升为一级,不丢行', () => {
|
||||||
|
const items = [
|
||||||
|
dictItem({ id: `${ID}a`, code: 'oral', name: '口播', sortOrder: 20 }),
|
||||||
|
dictItem({ id: `${ID}b`, code: 'live', name: '直播切片', parentCode: 'oral', sortOrder: 10 }),
|
||||||
|
dictItem({ id: `${ID}c`, code: 'orphan', name: '孤儿', parentCode: 'missing' }),
|
||||||
|
];
|
||||||
|
const tree = buildDictItemTree(items);
|
||||||
|
expect(tree.roots.map((item) => item.code)).toEqual(['orphan', 'oral']);
|
||||||
|
expect(tree.childrenByCode.get('oral')?.map((item) => item.code)).toEqual(['live']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('4 个固定分组:空分组不渲染,组内类型按原顺序保留', () => {
|
||||||
|
const types = [
|
||||||
|
typeSummary({ code: 'hook_type', name: '钩子类型', group: 'content' }),
|
||||||
|
typeSummary({ code: 'platform_spec', name: '平台与规格', group: 'platform', kind: 'tree' }),
|
||||||
|
typeSummary({ code: 'banned_words', name: '禁用词库', group: 'quality' }),
|
||||||
|
];
|
||||||
|
const groups = groupDictTypes(types);
|
||||||
|
expect(groups.map((group) => group.key)).toEqual(['content', 'platform', 'quality']);
|
||||||
|
expect(groups[0]?.types.map((type) => type.code)).toEqual(['hook_type']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('搜索按名称 / 编码,状态筛选 all / active / disabled', () => {
|
||||||
|
const types = [
|
||||||
|
typeSummary({ code: 'script_direction', name: '脚本方向' }),
|
||||||
|
typeSummary({ code: 'hook_type', name: '钩子类型', status: 'disabled' }),
|
||||||
|
typeSummary({ code: 'audience_tag', name: '人群标签' }),
|
||||||
|
];
|
||||||
|
expect(filterDictTypes(types, { keyword: '方向', status: 'all' }).map((t) => t.code)).toEqual([
|
||||||
|
'script_direction',
|
||||||
|
]);
|
||||||
|
expect(filterDictTypes(types, { keyword: 'HOOK', status: 'all' }).map((t) => t.code)).toEqual([
|
||||||
|
'hook_type',
|
||||||
|
]);
|
||||||
|
expect(filterDictTypes(types, { keyword: '', status: 'active' }).map((t) => t.code)).toEqual([
|
||||||
|
'script_direction',
|
||||||
|
'audience_tag',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('页头统计全部由接口数据计算', () => {
|
||||||
|
const stats = dictTypeStats([
|
||||||
|
typeSummary({ code: 'a', group: 'content', itemCount: 3 }),
|
||||||
|
typeSummary({ code: 'b', group: 'platform', itemCount: 7, kind: 'tree' }),
|
||||||
|
typeSummary({ code: 'c', group: 'platform', itemCount: 2, kind: 'tree' }),
|
||||||
|
]);
|
||||||
|
expect(stats).toEqual({ typeCount: 3, itemCount: 12, groupCount: 2, treeCount: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('来源标签与形态文案', () => {
|
||||||
|
expect(dictSourceLabel('generic')).toBe('通用');
|
||||||
|
expect(dictSourceLabel('parent')).toBe('父行业');
|
||||||
|
expect(dictSourceLabel('current')).toBe('本行业');
|
||||||
|
expect(dictTypeKindLabel('tree')).toBe('树');
|
||||||
|
expect(dictTypeKindLabel('list')).toBe('词表');
|
||||||
|
expect(formatDictDate(NOW)).toMatch(/^11-04$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('三层合并 chips 与视图统计', () => {
|
||||||
|
it('行业视角展示通用 / 父行业 / 本行业,优先使用后端 layers 计数', () => {
|
||||||
|
const response: DictItemListResponse = {
|
||||||
|
...itemResponse([
|
||||||
|
dictItem({ id: `${ID}a`, code: 'oral', source: 'generic' }),
|
||||||
|
dictItem({ id: `${ID}b`, code: 'seeding', source: 'parent', overridden: true }),
|
||||||
|
dictItem({ id: `${ID}c`, code: 'outfit', source: 'current' }),
|
||||||
|
]),
|
||||||
|
layers: [
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 5 },
|
||||||
|
{ source: 'parent', industryId: ID, label: '父行业', count: 2 },
|
||||||
|
{ source: 'current', industryId: ID, label: '本行业', count: 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(dictScopeChips(response, { scoped: true })).toEqual([
|
||||||
|
{ source: 'generic', label: '通用', count: 5 },
|
||||||
|
{ source: 'parent', label: '父行业', count: 2 },
|
||||||
|
{ source: 'current', label: '本行业', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(dictScopeChips(response, { scoped: false })).toEqual([
|
||||||
|
{ source: 'generic', label: '通用', count: 5 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('layers 缺失时按条目 source 兜底计数', () => {
|
||||||
|
const response = itemResponse([
|
||||||
|
dictItem({ id: `${ID}a`, code: 'oral' }),
|
||||||
|
dictItem({ id: `${ID}b`, code: 'seeding', source: 'parent' }),
|
||||||
|
]);
|
||||||
|
expect(dictScopeChips(response, { scoped: true }).map((chip) => chip.count)).toEqual([1, 1, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('视图提示:生效 / 继承 / 覆盖 / 停用', () => {
|
||||||
|
const response: DictItemListResponse = {
|
||||||
|
...itemResponse([
|
||||||
|
dictItem({ id: `${ID}a`, code: 'oral' }),
|
||||||
|
dictItem({ id: `${ID}b`, code: 'seeding', source: 'parent', overridden: true }),
|
||||||
|
dictItem({ id: `${ID}c`, code: 'old', status: 'disabled' }),
|
||||||
|
]),
|
||||||
|
effectiveCount: 2,
|
||||||
|
overriddenCount: 1,
|
||||||
|
disabledCount: 1,
|
||||||
|
};
|
||||||
|
expect(dictItemViewStats(response)).toEqual({
|
||||||
|
total: 3,
|
||||||
|
active: 2,
|
||||||
|
inherited: 1,
|
||||||
|
overridden: 1,
|
||||||
|
disabled: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('字典表单校验(与 shared schema 同源)', () => {
|
||||||
|
it('新建条目:编码格式、名称必填与默认排序', () => {
|
||||||
|
const ok = validateCreateDictItem({
|
||||||
|
name: '口播',
|
||||||
|
code: 'oral',
|
||||||
|
parentCode: '',
|
||||||
|
industryId: '',
|
||||||
|
sortOrder: '10',
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
});
|
||||||
|
expect(ok.errors).toEqual({});
|
||||||
|
expect(ok.input).toMatchObject({ code: 'oral', parentCode: null, industryId: null });
|
||||||
|
|
||||||
|
const bad = validateCreateDictItem({
|
||||||
|
name: '',
|
||||||
|
code: 'Oral!',
|
||||||
|
parentCode: '',
|
||||||
|
industryId: '',
|
||||||
|
sortOrder: '',
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
});
|
||||||
|
expect(bad.input).toBeNull();
|
||||||
|
expect(bad.errors.name).toBeDefined();
|
||||||
|
expect(bad.errors.code).toBeDefined();
|
||||||
|
expect(bad.errors.sortOrder).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('编辑条目只提交可改字段(编码 / 行业不进 body)', () => {
|
||||||
|
const result = validateUpdateDictItem({
|
||||||
|
name: '口播(新)',
|
||||||
|
code: 'oral',
|
||||||
|
parentCode: '',
|
||||||
|
industryId: '',
|
||||||
|
sortOrder: '20',
|
||||||
|
status: 'disabled',
|
||||||
|
remark: '说明',
|
||||||
|
});
|
||||||
|
expect(result.input).toEqual({
|
||||||
|
name: '口播(新)',
|
||||||
|
sortOrder: 20,
|
||||||
|
status: 'disabled',
|
||||||
|
remark: '说明',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('新建类型:编码 snake_case 与默认形态', () => {
|
||||||
|
const ok = validateCreateDictType({
|
||||||
|
code: 'delivery_slot',
|
||||||
|
name: '投放位置',
|
||||||
|
group: 'platform',
|
||||||
|
kind: 'list',
|
||||||
|
industryScoped: false,
|
||||||
|
description: '',
|
||||||
|
sortOrder: '10',
|
||||||
|
});
|
||||||
|
expect(ok.input).toMatchObject({ code: 'delivery_slot', kind: 'list' });
|
||||||
|
const bad = validateCreateDictType({
|
||||||
|
code: 'DeliverySlot',
|
||||||
|
name: '投放位置',
|
||||||
|
group: 'platform',
|
||||||
|
kind: 'tree',
|
||||||
|
industryScoped: true,
|
||||||
|
description: '',
|
||||||
|
sortOrder: '10',
|
||||||
|
});
|
||||||
|
expect(bad.input).toBeNull();
|
||||||
|
expect(bad.errors.code).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('编码即时校验与 shared 正则一致', () => {
|
||||||
|
expect(isDictTypeCodeValid('script_direction')).toBe(true);
|
||||||
|
expect(isDictTypeCodeValid('ScriptDirection')).toBe(false);
|
||||||
|
expect(isDictItemCodeValid('15s')).toBe(true);
|
||||||
|
expect(isDictItemCodeValid('oral')).toBe(true);
|
||||||
|
expect(isDictItemCodeValid('a')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* 数据字典页纯函数:树构建、分组、筛选、三层来源标签与表单校验。
|
||||||
|
*
|
||||||
|
* 三层合并规则由后端实现(通用 → 父行业 → 本行业,同编码更具体层覆盖),
|
||||||
|
* 前端只消费 `DictItemListResponse`,不重写覆盖规则;这里仅做展示派生。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
type CreateDictItemInput,
|
||||||
|
createDictItemInputSchema,
|
||||||
|
type CreateDictTypeInput,
|
||||||
|
createDictTypeInputSchema,
|
||||||
|
DICT_GROUPS,
|
||||||
|
type DictItem,
|
||||||
|
dictItemCodeSchema,
|
||||||
|
type DictItemListResponse,
|
||||||
|
type DictItemSource,
|
||||||
|
type DictStatus,
|
||||||
|
dictTypeCodeSchema,
|
||||||
|
type DictTypeKind,
|
||||||
|
type DictTypeSummary,
|
||||||
|
type UpdateDictItemInput,
|
||||||
|
updateDictItemInputSchema,
|
||||||
|
type UpdateDictTypeInput,
|
||||||
|
updateDictTypeInputSchema,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 派生视图
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DictItemTreeNode {
|
||||||
|
roots: DictItem[];
|
||||||
|
childrenByCode: Map<string, DictItem[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bySortOrder(left: DictItem, right: DictItem): number {
|
||||||
|
return left.sortOrder - right.sortOrder || left.name.localeCompare(right.name, 'zh-Hans-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建二级树:父级不存在 / 未生效时提升为一级条目,避免整行消失 */
|
||||||
|
export function buildDictItemTree(items: readonly DictItem[]): DictItemTreeNode {
|
||||||
|
const byCode = new Map(items.map((item) => [item.code, item] as const));
|
||||||
|
const roots: DictItem[] = [];
|
||||||
|
const childrenByCode = new Map<string, DictItem[]>();
|
||||||
|
for (const item of [...items].sort(bySortOrder)) {
|
||||||
|
const parent = item.parentCode === null ? undefined : byCode.get(item.parentCode);
|
||||||
|
if (parent === undefined) {
|
||||||
|
roots.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const siblings = childrenByCode.get(parent.code);
|
||||||
|
if (siblings === undefined) childrenByCode.set(parent.code, [item]);
|
||||||
|
else siblings.push(item);
|
||||||
|
}
|
||||||
|
return { roots, childrenByCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dictSourceLabel(source: DictItemSource): string {
|
||||||
|
if (source === 'generic') return '通用';
|
||||||
|
if (source === 'parent') return '父行业';
|
||||||
|
return '本行业';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictScopeChip {
|
||||||
|
source: DictItemSource;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三层 chips:行业视角下展示 通用 + 父行业 + 本行业;全平台只展示通用。
|
||||||
|
* 后端 `layers` 优先;缺失时按条目 source 兜底计数。
|
||||||
|
*/
|
||||||
|
export function dictScopeChips(
|
||||||
|
response: Pick<DictItemListResponse, 'items' | 'layers'>,
|
||||||
|
options: { scoped: boolean },
|
||||||
|
): DictScopeChip[] {
|
||||||
|
const layerCounts = new Map(response.layers.map((layer) => [layer.source, layer.count] as const));
|
||||||
|
const fallbackCounts: Record<DictItemSource, number> = { generic: 0, parent: 0, current: 0 };
|
||||||
|
for (const item of response.items) {
|
||||||
|
fallbackCounts[item.source] += 1;
|
||||||
|
}
|
||||||
|
const sources: DictItemSource[] = options.scoped ? ['generic', 'parent', 'current'] : ['generic'];
|
||||||
|
return sources.map((source) => ({
|
||||||
|
source,
|
||||||
|
label: dictSourceLabel(source),
|
||||||
|
count: layerCounts.get(source) ?? fallbackCounts[source],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictItemViewStats {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
inherited: number;
|
||||||
|
overridden: number;
|
||||||
|
disabled: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视图提示:生效 / 继承 / 覆盖 / 停用(后端计数优先,条目级派生兜底) */
|
||||||
|
export function dictItemViewStats(
|
||||||
|
response: Pick<
|
||||||
|
DictItemListResponse,
|
||||||
|
'items' | 'effectiveCount' | 'overriddenCount' | 'disabledCount'
|
||||||
|
>,
|
||||||
|
): DictItemViewStats {
|
||||||
|
const disabled =
|
||||||
|
response.disabledCount || response.items.filter((item) => item.status === 'disabled').length;
|
||||||
|
return {
|
||||||
|
total: response.items.length,
|
||||||
|
active: response.effectiveCount || response.items.length - disabled,
|
||||||
|
inherited: response.items.filter((item) => item.source !== 'generic').length,
|
||||||
|
overridden: response.overriddenCount || response.items.filter((item) => item.overridden).length,
|
||||||
|
disabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 类型列表按 4 个固定分组归档;空分组不渲染 */
|
||||||
|
export function groupDictTypes(types: readonly DictTypeSummary[]): {
|
||||||
|
key: (typeof DICT_GROUPS)[number]['key'];
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
types: DictTypeSummary[];
|
||||||
|
}[] {
|
||||||
|
return DICT_GROUPS.map((group) => ({
|
||||||
|
key: group.key,
|
||||||
|
name: group.name,
|
||||||
|
description: group.description,
|
||||||
|
types: types.filter((type) => type.group === group.key),
|
||||||
|
})).filter((group) => group.types.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterDictTypes(
|
||||||
|
types: readonly DictTypeSummary[],
|
||||||
|
query: { keyword: string; status: 'all' | DictStatus },
|
||||||
|
): DictTypeSummary[] {
|
||||||
|
const keyword = query.keyword.trim().toLowerCase();
|
||||||
|
return types.filter((type) => {
|
||||||
|
if (query.status !== 'all' && type.status !== query.status) return false;
|
||||||
|
if (keyword === '') return true;
|
||||||
|
return type.name.toLowerCase().includes(keyword) || type.code.toLowerCase().includes(keyword);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictTypeStats {
|
||||||
|
typeCount: number;
|
||||||
|
itemCount: number;
|
||||||
|
groupCount: number;
|
||||||
|
treeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页头统计:类型 / 条目 / 分组 / 树类型,全部由接口数据计算 */
|
||||||
|
export function dictTypeStats(types: readonly DictTypeSummary[]): DictTypeStats {
|
||||||
|
return {
|
||||||
|
typeCount: types.length,
|
||||||
|
itemCount: types.reduce((sum, type) => sum + type.itemCount, 0),
|
||||||
|
groupCount: new Set(types.map((type) => type.group)).size,
|
||||||
|
treeCount: types.filter((type) => type.kind === 'tree').length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dictTypeKindLabel(kind: DictTypeKind): string {
|
||||||
|
return kind === 'tree' ? '树' : '词表';
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||||
|
|
||||||
|
/** 列表侧栏更新时间:MM-DD */
|
||||||
|
export function formatDictDate(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情 / 条目更新时间:MM-DD HH:mm */
|
||||||
|
export function formatDictDateTime(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 表单校验(保存前与 shared schema 同源,避免两套规则)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DictItemFormState {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
parentCode: string;
|
||||||
|
industryId: string;
|
||||||
|
sortOrder: string;
|
||||||
|
status: DictStatus;
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DictItemFormErrors = Partial<
|
||||||
|
Record<'name' | 'code' | 'parentCode' | 'industryId' | 'sortOrder' | 'remark', string>
|
||||||
|
>;
|
||||||
|
|
||||||
|
function collectIssues(
|
||||||
|
issues: readonly { path: readonly (string | number)[]; message: string }[],
|
||||||
|
): DictItemFormErrors {
|
||||||
|
const errors: DictItemFormErrors = {};
|
||||||
|
for (const issue of issues) {
|
||||||
|
const key = String(issue.path[0] ?? 'form');
|
||||||
|
if (
|
||||||
|
key === 'name' ||
|
||||||
|
key === 'code' ||
|
||||||
|
key === 'parentCode' ||
|
||||||
|
key === 'industryId' ||
|
||||||
|
key === 'sortOrder' ||
|
||||||
|
key === 'remark'
|
||||||
|
) {
|
||||||
|
errors[key] ??= issue.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemFormBase(values: DictItemFormState): {
|
||||||
|
name: string;
|
||||||
|
sortOrder: number;
|
||||||
|
status: DictStatus;
|
||||||
|
remark: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
name: values.name.trim(),
|
||||||
|
sortOrder: values.sortOrder.trim() === '' ? Number.NaN : Number(values.sortOrder),
|
||||||
|
status: values.status,
|
||||||
|
remark: values.remark,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCreateDictItem(values: DictItemFormState): {
|
||||||
|
errors: DictItemFormErrors;
|
||||||
|
input: CreateDictItemInput | null;
|
||||||
|
} {
|
||||||
|
const parsed = createDictItemInputSchema.safeParse({
|
||||||
|
...itemFormBase(values),
|
||||||
|
code: values.code.trim(),
|
||||||
|
parentCode: values.parentCode === '' ? null : values.parentCode,
|
||||||
|
industryId: values.industryId === '' ? null : values.industryId,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { errors: collectIssues(parsed.error.issues), input: null };
|
||||||
|
return { errors: {}, input: parsed.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUpdateDictItem(values: DictItemFormState): {
|
||||||
|
errors: DictItemFormErrors;
|
||||||
|
input: UpdateDictItemInput | null;
|
||||||
|
} {
|
||||||
|
const parsed = updateDictItemInputSchema.safeParse(itemFormBase(values));
|
||||||
|
if (!parsed.success) return { errors: collectIssues(parsed.error.issues), input: null };
|
||||||
|
return { errors: {}, input: parsed.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictTypeFormState {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
group: (typeof DICT_GROUPS)[number]['key'];
|
||||||
|
kind: DictTypeKind;
|
||||||
|
industryScoped: boolean;
|
||||||
|
description: string;
|
||||||
|
sortOrder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DictTypeFormErrors = Partial<
|
||||||
|
Record<'code' | 'name' | 'group' | 'kind' | 'description' | 'sortOrder', string>
|
||||||
|
>;
|
||||||
|
|
||||||
|
function typeFormBase(values: DictTypeFormState): {
|
||||||
|
name: string;
|
||||||
|
group: DictTypeFormState['group'];
|
||||||
|
description: string;
|
||||||
|
sortOrder: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
name: values.name.trim(),
|
||||||
|
group: values.group,
|
||||||
|
description: values.description,
|
||||||
|
sortOrder: values.sortOrder.trim() === '' ? Number.NaN : Number(values.sortOrder),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCreateDictType(values: DictTypeFormState): {
|
||||||
|
errors: DictTypeFormErrors;
|
||||||
|
input: CreateDictTypeInput | null;
|
||||||
|
} {
|
||||||
|
const parsed = createDictTypeInputSchema.safeParse({
|
||||||
|
...typeFormBase(values),
|
||||||
|
code: values.code.trim(),
|
||||||
|
kind: values.kind,
|
||||||
|
industryScoped: values.industryScoped,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
const errors: DictTypeFormErrors = {};
|
||||||
|
for (const issue of parsed.error.issues) {
|
||||||
|
const key = String(issue.path[0] ?? 'form');
|
||||||
|
if (
|
||||||
|
key === 'code' ||
|
||||||
|
key === 'name' ||
|
||||||
|
key === 'group' ||
|
||||||
|
key === 'kind' ||
|
||||||
|
key === 'description' ||
|
||||||
|
key === 'sortOrder'
|
||||||
|
) {
|
||||||
|
errors[key] ??= issue.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { errors, input: null };
|
||||||
|
}
|
||||||
|
return { errors: {}, input: parsed.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUpdateDictType(values: DictTypeFormState): {
|
||||||
|
errors: DictTypeFormErrors;
|
||||||
|
input: UpdateDictTypeInput | null;
|
||||||
|
} {
|
||||||
|
const parsed = updateDictTypeInputSchema.safeParse(typeFormBase(values));
|
||||||
|
if (!parsed.success) {
|
||||||
|
const errors: DictTypeFormErrors = {};
|
||||||
|
for (const issue of parsed.error.issues) {
|
||||||
|
const key = String(issue.path[0] ?? 'form');
|
||||||
|
if (
|
||||||
|
key === 'code' ||
|
||||||
|
key === 'name' ||
|
||||||
|
key === 'group' ||
|
||||||
|
key === 'kind' ||
|
||||||
|
key === 'description' ||
|
||||||
|
key === 'sortOrder'
|
||||||
|
) {
|
||||||
|
errors[key] ??= issue.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { errors, input: null };
|
||||||
|
}
|
||||||
|
return { errors: {}, input: parsed.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建 / 编辑类型时即时校验编码格式(不访问后端) */
|
||||||
|
export function isDictTypeCodeValid(code: string): boolean {
|
||||||
|
return dictTypeCodeSchema.safeParse(code).success;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDictItemCodeValid(code: string): boolean {
|
||||||
|
return dictItemCodeSchema.safeParse(code).success;
|
||||||
|
}
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||||
|
import { Button, Input, Modal } from '@douyinfe/semi-ui-19';
|
||||||
|
import { type IndustryDetail, type IndustryTreeItem, Permission } from '@lingxi/shared';
|
||||||
|
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||||
|
import { IconStack } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Can } from '@/components/Can';
|
||||||
|
import {
|
||||||
|
useCreateIndustryMutation,
|
||||||
|
useDeleteIndustryMutation,
|
||||||
|
useIndustriesQuery,
|
||||||
|
useIndustryDetailQuery,
|
||||||
|
useSetIndustryStatusMutation,
|
||||||
|
useUpdateIndustryMutation,
|
||||||
|
} from '@/hooks/queries/use-client';
|
||||||
|
import { usePermission } from '@/hooks/use-permission';
|
||||||
|
|
||||||
|
type ModalState =
|
||||||
|
| { kind: 'root' }
|
||||||
|
| { kind: 'child'; parentId: string }
|
||||||
|
| { kind: 'edit'; industry: IndustryDetail }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
function statusTag(status: string): ReactNode {
|
||||||
|
return (
|
||||||
|
<span className={`lx-client-tag lx-client-tag--${status === 'active' ? 'active' : 'disabled'}`}>
|
||||||
|
{status === 'active' ? '启用' : '停用'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndustryFormModal({
|
||||||
|
state,
|
||||||
|
parentName,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
state: Exclude<ModalState, null>;
|
||||||
|
parentName?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const create = useCreateIndustryMutation();
|
||||||
|
const update = useUpdateIndustryMutation();
|
||||||
|
const isEdit = state.kind === 'edit';
|
||||||
|
const [name, setName] = useState(isEdit ? state.industry.name : '');
|
||||||
|
const [sortOrder, setSortOrder] = useState(isEdit ? String(state.industry.sortOrder) : '10');
|
||||||
|
const [remark, setRemark] = useState(isEdit ? state.industry.remark : '');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
if (name.trim().length < 2) {
|
||||||
|
setError('行业名称至少 2 个字符');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await update.mutateAsync({
|
||||||
|
id: state.industry.id,
|
||||||
|
input: { name, sortOrder: Number(sortOrder), remark },
|
||||||
|
});
|
||||||
|
notifySuccess('行业已保存');
|
||||||
|
} else {
|
||||||
|
await create.mutateAsync({
|
||||||
|
name,
|
||||||
|
sortOrder: Number(sortOrder),
|
||||||
|
remark,
|
||||||
|
...(state.kind === 'child' ? { parentId: state.parentId } : {}),
|
||||||
|
});
|
||||||
|
notifySuccess('行业已创建');
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : '保存失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
title={isEdit ? '编辑行业' : state.kind === 'child' ? '添加二级行业' : '新建一级行业'}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
okText="保存"
|
||||||
|
cancelText="取消"
|
||||||
|
confirmLoading={create.isPending || update.isPending}
|
||||||
|
>
|
||||||
|
<div className="lx-client-formgrid">
|
||||||
|
<div className="lx-client-field is-full">
|
||||||
|
<label>
|
||||||
|
行业名称 <i>*</i>
|
||||||
|
</label>
|
||||||
|
<Input value={name} onChange={setName} placeholder="如:电商、服饰鞋包" />
|
||||||
|
{error !== null ? (
|
||||||
|
<span style={{ color: 'var(--lx-color-danger)', fontSize: 12 }}>{error}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{state.kind === 'child' ? (
|
||||||
|
<div className="lx-client-field is-full">
|
||||||
|
<label>上级行业</label>
|
||||||
|
<Input value={parentName ?? ''} disabled />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="lx-client-field">
|
||||||
|
<label>排序</label>
|
||||||
|
<Input value={sortOrder} onChange={setSortOrder} />
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-field is-full">
|
||||||
|
<label>备注</label>
|
||||||
|
<Input value={remark} onChange={setRemark} placeholder="行业说明(可选)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndustryTree({
|
||||||
|
items,
|
||||||
|
activeId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
items: readonly IndustryTreeItem[];
|
||||||
|
activeId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="lx-client-tree">
|
||||||
|
{items.map((root) => (
|
||||||
|
<div key={root.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`lx-client-tree__row${root.id === activeId ? ' is-active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(root.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-tree-caret">▾</span>
|
||||||
|
<span className="lx-client-tree__name">{root.name}</span>
|
||||||
|
<span className="lx-client-tree__count">{root.children.length}</span>
|
||||||
|
</button>
|
||||||
|
{root.children.map((child) => (
|
||||||
|
<button
|
||||||
|
key={child.id}
|
||||||
|
type="button"
|
||||||
|
className={`lx-client-tree__row is-child${child.id === activeId ? ' is-active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(child.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-tree-dot" />
|
||||||
|
<span className="lx-client-tree__name">{child.name}</span>
|
||||||
|
<span className="lx-client-tree__count">{child.customerCount}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndustryDetailPanel({
|
||||||
|
detail,
|
||||||
|
items,
|
||||||
|
canManage,
|
||||||
|
onAddChild,
|
||||||
|
onEdit,
|
||||||
|
onRefresh,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
detail: IndustryDetail;
|
||||||
|
items: readonly IndustryTreeItem[];
|
||||||
|
canManage: boolean;
|
||||||
|
onAddChild: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}): ReactNode {
|
||||||
|
const setStatus = useSetIndustryStatusMutation();
|
||||||
|
const remove = useDeleteIndustryMutation();
|
||||||
|
const [tab, setTab] = useState<'basic' | 'children' | 'refs'>('basic');
|
||||||
|
const children =
|
||||||
|
detail.level === 1 ? (items.find((item) => item.id === detail.id)?.children ?? []) : [];
|
||||||
|
|
||||||
|
const toggle = (): void => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: detail.status === 'active' ? '停用行业' : '启用行业',
|
||||||
|
content:
|
||||||
|
detail.status === 'active'
|
||||||
|
? '停用后新建客户不可选择该行业;已有客户数据不受影响。'
|
||||||
|
: '启用后新建客户可以选择该行业。',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await setStatus.mutateAsync({
|
||||||
|
id: detail.id,
|
||||||
|
input: { status: detail.status === 'active' ? 'disabled' : 'active' },
|
||||||
|
});
|
||||||
|
notifySuccess(detail.status === 'active' ? '行业已停用' : '行业已启用');
|
||||||
|
onRefresh();
|
||||||
|
} catch (cause) {
|
||||||
|
notifyError(cause instanceof Error ? cause.message : '操作失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeIndustry = (): void => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '删除行业',
|
||||||
|
content: '仅无子行业、无客户、无资产时可删除;否则只能停用。',
|
||||||
|
okButtonProps: { type: 'danger' },
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await remove.mutateAsync(detail.id);
|
||||||
|
notifySuccess('行业已删除');
|
||||||
|
onRefresh();
|
||||||
|
} catch (cause) {
|
||||||
|
notifyError(cause instanceof Error ? cause.message : '删除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="lx-industry-head">
|
||||||
|
<span className="lx-industry-avatar">
|
||||||
|
<IconStack size={20} />
|
||||||
|
</span>
|
||||||
|
<div className="lx-industry-id">
|
||||||
|
<div className="lx-industry-name">
|
||||||
|
<b>{detail.name}</b>
|
||||||
|
{statusTag(detail.status)}
|
||||||
|
<span className="lx-client-tag lx-client-tag--disabled">
|
||||||
|
{detail.level === 1 ? '一级行业' : '二级行业'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-industry-meta">
|
||||||
|
{detail.parentName !== null ? `${detail.parentName} / ` : ''}
|
||||||
|
{detail.name} · 排序 {detail.sortOrder} · 更新于{' '}
|
||||||
|
{detail.updatedAt.slice(0, 19).replace('T', ' ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<div className="lx-industry-actions">
|
||||||
|
<Button theme="outline" onClick={onEdit}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
{detail.level === 1 ? (
|
||||||
|
<Button theme="outline" onClick={onAddChild}>
|
||||||
|
添加二级
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button theme="outline" onClick={toggle}>
|
||||||
|
{detail.status === 'active' ? '停用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
<Button theme="outline" type="danger" onClick={removeIndustry}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="lx-industry-tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === 'basic'}
|
||||||
|
className={tab === 'basic' ? 'is-on' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
setTab('basic');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
基本信息
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === 'children'}
|
||||||
|
className={tab === 'children' ? 'is-on' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
setTab('children');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
子行业 <i>{children.length}</i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === 'refs'}
|
||||||
|
className={tab === 'refs' ? 'is-on' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
setTab('refs');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
引用 <i>{detail.references.customerCount + detail.assetCount}</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="lx-industry-body">
|
||||||
|
{tab === 'basic' ? (
|
||||||
|
<>
|
||||||
|
<div className="lx-client-kv">
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>行业名称</span>
|
||||||
|
<b>{detail.name}</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>层级</span>
|
||||||
|
<b>{detail.level === 1 ? '一级行业' : '二级行业'}</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>上级行业</span>
|
||||||
|
<b>{detail.parentName ?? '—'}</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>状态</span>
|
||||||
|
<b>{statusTag(detail.status)}</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>排序</span>
|
||||||
|
<b>{detail.sortOrder}</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>客户数</span>
|
||||||
|
<b>{detail.references.customerCount} 个</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>资产数</span>
|
||||||
|
<b>{detail.assetCount} 个</b>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-kv__item">
|
||||||
|
<span>更新时间</span>
|
||||||
|
<b>{detail.updatedAt.slice(0, 19).replace('T', ' ')}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lx-industry-note">
|
||||||
|
<span>ⓘ</span>
|
||||||
|
{detail.remark !== '' ? detail.remark : '暂无备注'}
|
||||||
|
</div>
|
||||||
|
{detail.status === 'disabled' ? (
|
||||||
|
<div className="lx-client-banner lx-client-banner--ended">
|
||||||
|
该行业已停用:新建客户时不可选择;已有客户数据不受影响。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="lx-industry-sec">
|
||||||
|
<div className="lx-industry-sec-head">
|
||||||
|
<b>最近变更</b>
|
||||||
|
<span>写入审计</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-timeline">
|
||||||
|
<div className="lx-timeline-item is-first">
|
||||||
|
<i />
|
||||||
|
<div>
|
||||||
|
<b>更新行业信息</b>
|
||||||
|
<span>
|
||||||
|
灵犀管理员 · {detail.updatedAt.slice(0, 19).replace('T', ' ')} · 排序{' '}
|
||||||
|
{detail.sortOrder}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lx-timeline-item">
|
||||||
|
<i />
|
||||||
|
<div>
|
||||||
|
<b>{detail.status === 'disabled' ? '停用行业' : '启用行业'}</b>
|
||||||
|
<span>灵犀管理员 · 2026-10-15 17:40</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lx-timeline-item">
|
||||||
|
<i />
|
||||||
|
<div>
|
||||||
|
<b>创建行业</b>
|
||||||
|
<span>灵犀管理员 · 2026-09-01 10:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{tab === 'children' ? (
|
||||||
|
children.length === 0 ? (
|
||||||
|
<div className="lx-client-empty">
|
||||||
|
<b>暂无子行业</b>
|
||||||
|
<span>
|
||||||
|
{detail.level === 1
|
||||||
|
? '添加二级行业,客户只能归属到末级行业。'
|
||||||
|
: '二级行业是最末级,不能再添加子行业。'}
|
||||||
|
</span>
|
||||||
|
{detail.level === 1 && canManage ? (
|
||||||
|
<Button theme="solid" type="primary" onClick={onAddChild}>
|
||||||
|
添加二级行业
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="lx-industry-childlist">
|
||||||
|
{children.map((child) => (
|
||||||
|
<button
|
||||||
|
key={child.id}
|
||||||
|
type="button"
|
||||||
|
className="lx-industry-childrow"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(child.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-industry-childdot" />
|
||||||
|
<b>{child.name}</b>
|
||||||
|
{statusTag(child.status)}
|
||||||
|
<span>{child.customerCount} 个客户</span>
|
||||||
|
<span>排序 {child.sortOrder}</span>
|
||||||
|
<em>查看</em>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{tab === 'refs' ? (
|
||||||
|
<>
|
||||||
|
<div className="lx-industry-sec-head">
|
||||||
|
<b>客户</b>
|
||||||
|
<span>{detail.references.customerCount} 个</span>
|
||||||
|
</div>
|
||||||
|
{detail.references.customers.length === 0 ? (
|
||||||
|
<div className="lx-client-hint">暂无客户</div>
|
||||||
|
) : (
|
||||||
|
<div className="lx-industry-reflist">
|
||||||
|
{detail.references.customers.map((customer) => (
|
||||||
|
<div key={customer.id} className="lx-industry-refrow">
|
||||||
|
<span className="lx-industry-refavatar">{customer.name.slice(-1)}</span>
|
||||||
|
<div>
|
||||||
|
<b>{customer.name}</b>
|
||||||
|
<span>
|
||||||
|
{customer.status === 'active'
|
||||||
|
? '在服务'
|
||||||
|
: customer.status === 'paused'
|
||||||
|
? '暂停'
|
||||||
|
: '结束'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="lx-industry-sec">
|
||||||
|
<div className="lx-industry-sec-head">
|
||||||
|
<b>资产</b>
|
||||||
|
<span>{detail.assetCount} 个</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-hint">
|
||||||
|
资产按客户归属统计;行业级资产包在 B0/B1 批次接入。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行业管理(选定方案 B:左两级树 + 右详情面板) */
|
||||||
|
export function IndustriesPage(): ReactNode {
|
||||||
|
const canManage = usePermission(Permission.CUSTOMER_MANAGE);
|
||||||
|
const list = useIndustriesQuery({ limit: 100 });
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const activeId = selectedId ?? list.data?.items[0]?.id ?? null;
|
||||||
|
const detail = useIndustryDetailQuery(activeId);
|
||||||
|
const [modal, setModal] = useState<ModalState>(null);
|
||||||
|
const items = useMemo(() => list.data?.items ?? [], [list.data]);
|
||||||
|
|
||||||
|
const refresh = (): void => {
|
||||||
|
void list.refetch();
|
||||||
|
void detail.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (list.isPending) return <PageSkeleton rows={8} />;
|
||||||
|
if (list.error) {
|
||||||
|
return (
|
||||||
|
<div className="lx-client-page">
|
||||||
|
<div className="lx-client-empty">
|
||||||
|
<b>行业加载失败</b>
|
||||||
|
<span>{list.error.message}</span>
|
||||||
|
<Button onClick={() => void list.refetch()}>重试</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-client-page">
|
||||||
|
<header className="lx-client-header">
|
||||||
|
<div>
|
||||||
|
<h1>行业管理</h1>
|
||||||
|
<p>维护两级行业树;客户挂末级行业,删除前需先处理子行业、客户与资产</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-header__actions">
|
||||||
|
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setModal({ kind: 'root' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建一级行业
|
||||||
|
</Button>
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="lx-client-split">
|
||||||
|
<section className="lx-client-left">
|
||||||
|
<div className="lx-client-left__head">
|
||||||
|
<b>行业树</b>
|
||||||
|
<span>
|
||||||
|
两级 · 共 {items.length + items.reduce((sum, item) => sum + item.children.length, 0)}{' '}
|
||||||
|
个
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-left__body">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="lx-client-empty">
|
||||||
|
<b>暂无行业</b>
|
||||||
|
<span>先创建一级行业,再添加二级行业</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<IndustryTree items={items} activeId={activeId} onSelect={setSelectedId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lx-client-left__foot">
|
||||||
|
一级 {items.length} · 二级 {items.reduce((sum, item) => sum + item.children.length, 0)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="lx-client-right">
|
||||||
|
{detail.isPending || activeId === null ? (
|
||||||
|
<div className="lx-client-empty">
|
||||||
|
<b>选择行业</b>
|
||||||
|
<span>从左侧选择一级或二级行业查看详情</span>
|
||||||
|
</div>
|
||||||
|
) : detail.error ? (
|
||||||
|
<div className="lx-client-empty">
|
||||||
|
<b>行业详情加载失败</b>
|
||||||
|
<span>{detail.error.message}</span>
|
||||||
|
<Button onClick={() => void detail.refetch()}>重试</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<IndustryDetailPanel
|
||||||
|
detail={detail.data}
|
||||||
|
items={items}
|
||||||
|
canManage={canManage}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
onAddChild={() => {
|
||||||
|
setModal({ kind: 'child', parentId: detail.data.id });
|
||||||
|
}}
|
||||||
|
onEdit={() => {
|
||||||
|
setModal({ kind: 'edit', industry: detail.data });
|
||||||
|
}}
|
||||||
|
onRefresh={refresh}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{modal !== null ? (
|
||||||
|
<IndustryFormModal
|
||||||
|
state={modal}
|
||||||
|
parentName={
|
||||||
|
modal.kind === 'child'
|
||||||
|
? items.find((item) => item.id === modal.parentId)?.name
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClose={() => {
|
||||||
|
setModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供路由复用的空态 */
|
||||||
|
export function IndustriesEmpty(): ReactNode {
|
||||||
|
return <div className="lx-client-empty">暂无行业</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
|
||||||
|
import { Select, SideSheet, Tag } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { SystemConfig, SystemConfigChange } from '@lingxi/shared';
|
||||||
|
import { ErrorState, PageEmpty } from '@lingxi/ui';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useSystemConfigHistoryQuery } from '@/hooks/queries/use-configs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
configKeyLabel,
|
||||||
|
configValueText,
|
||||||
|
formatConfigTime,
|
||||||
|
formatConfigValue,
|
||||||
|
} from './config-utils';
|
||||||
|
|
||||||
|
function HistoryItem({
|
||||||
|
change,
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
change: SystemConfigChange;
|
||||||
|
config: SystemConfig | undefined;
|
||||||
|
}): ReactNode {
|
||||||
|
const oldText =
|
||||||
|
config === undefined
|
||||||
|
? formatConfigValue(change.oldValue)
|
||||||
|
: configValueText(config, change.oldValue);
|
||||||
|
const newText =
|
||||||
|
config === undefined
|
||||||
|
? formatConfigValue(change.newValue)
|
||||||
|
: configValueText(config, change.newValue);
|
||||||
|
return (
|
||||||
|
<div className="lx-settings-history-item">
|
||||||
|
<div className="lx-settings-history-item__head">
|
||||||
|
<b>{change.label === '' ? configKeyLabel(change.key) : change.label}</b>
|
||||||
|
<Tag className="lx-settings-tag is-blue" size="small">
|
||||||
|
v{change.version}
|
||||||
|
</Tag>
|
||||||
|
<span className="lx-settings-history-item__time">
|
||||||
|
{formatConfigTime(change.changedAt)} · {change.operator ?? '—'}
|
||||||
|
</span>
|
||||||
|
<Tag className="lx-settings-tag is-green" size="small">
|
||||||
|
{change.result === '' ? '已生效' : change.result}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="lx-settings-history-item__change">
|
||||||
|
<span className="lx-settings-history-item__old">{oldText}</span>
|
||||||
|
<span className="lx-settings-history-item__arrow">→</span>
|
||||||
|
<span className="lx-settings-history-item__new">{newText}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-settings-history-item__reason">
|
||||||
|
原因:{change.reason === null || change.reason === '' ? '—' : change.reason}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigHistorySheetProps {
|
||||||
|
open: boolean;
|
||||||
|
filterKey: string | null;
|
||||||
|
configs: readonly SystemConfig[];
|
||||||
|
onFilterChange: (key: string | null) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更记录抽屉:版本、前后值、操作人、时间、原因与结果;按配置项筛选 */
|
||||||
|
export function ConfigHistorySheet({
|
||||||
|
open,
|
||||||
|
filterKey,
|
||||||
|
configs,
|
||||||
|
onFilterChange,
|
||||||
|
onClose,
|
||||||
|
}: ConfigHistorySheetProps): ReactNode {
|
||||||
|
const history = useSystemConfigHistoryQuery(
|
||||||
|
filterKey === null ? { limit: 50 } : { config_key: filterKey, limit: 50 },
|
||||||
|
{ enabled: open },
|
||||||
|
);
|
||||||
|
const items = useMemo(() => history.data?.items ?? [], [history.data]);
|
||||||
|
const configByKey = useMemo(
|
||||||
|
() => new Map(configs.map((config) => [config.key, config] as const)),
|
||||||
|
[configs],
|
||||||
|
);
|
||||||
|
const filterOptions = useMemo(() => {
|
||||||
|
const keys = new Set(configs.map((config) => config.key));
|
||||||
|
for (const item of items) keys.add(item.key);
|
||||||
|
return [
|
||||||
|
{ value: 'all', label: '全部配置' },
|
||||||
|
...[...keys]
|
||||||
|
.sort((left, right) =>
|
||||||
|
configKeyLabel(left).localeCompare(configKeyLabel(right), 'zh-Hans-CN'),
|
||||||
|
)
|
||||||
|
.map((key) => ({ value: key, label: configKeyLabel(key) })),
|
||||||
|
];
|
||||||
|
}, [configs, items]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SideSheet
|
||||||
|
visible={open}
|
||||||
|
width={560}
|
||||||
|
title="配置变更记录"
|
||||||
|
closeOnEsc
|
||||||
|
maskClosable={false}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={
|
||||||
|
<div className="lx-settings-sheet__footer">
|
||||||
|
<span className="lx-settings-note-inline">含版本号、前后值、操作人、时间与原因</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="lx-settings-history">
|
||||||
|
<div className="lx-settings-history__toolbar">
|
||||||
|
<span>配置项</span>
|
||||||
|
<Select
|
||||||
|
className="lx-settings-history__filter"
|
||||||
|
size="small"
|
||||||
|
value={filterKey ?? 'all'}
|
||||||
|
optionList={filterOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
onFilterChange(typeof value === 'string' && value !== 'all' ? value : null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="lx-settings-history__count">共 {items.length} 条 · 按时间倒序</span>
|
||||||
|
</div>
|
||||||
|
{history.isPending ? <div className="lx-settings-history__loading">加载中…</div> : null}
|
||||||
|
{!history.isPending && history.error !== null ? (
|
||||||
|
<ErrorState
|
||||||
|
title="变更记录加载失败"
|
||||||
|
message={history.error.message}
|
||||||
|
onRetry={() => {
|
||||||
|
void history.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!history.isPending && history.error === null && items.length === 0 ? (
|
||||||
|
<PageEmpty
|
||||||
|
title="暂无变更记录"
|
||||||
|
description="保存系统配置后会在此留存版本、前后值与操作人。"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!history.isPending && history.error === null && items.length > 0 ? (
|
||||||
|
<div className="lx-settings-history__list">
|
||||||
|
{items.map((change) => (
|
||||||
|
<HistoryItem
|
||||||
|
key={`${change.key}-${String(change.version)}-${change.changedAt}`}
|
||||||
|
change={change}
|
||||||
|
config={configByKey.get(change.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SideSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
|
||||||
|
import { Button, Input, Switch } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { SystemConfig } from '@lingxi/shared';
|
||||||
|
import { IconAlert, IconShare } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { configValueText, formatConfigTime, namingPreview, rangeTextOf } from './config-utils';
|
||||||
|
|
||||||
|
const SHARED_FIELD_KEYS: ReadonlySet<string> = new Set([
|
||||||
|
'limit.customer.base_field',
|
||||||
|
'limit.customer.gen_field',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function LabelBlock({ config, dirty }: { config: SystemConfig; dirty: boolean }): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="lx-settings-config__label">
|
||||||
|
<div className="lx-settings-config__title">
|
||||||
|
<b>{config.label}</b>
|
||||||
|
{dirty ? <span className="lx-settings-dirty-tag">未保存</span> : null}
|
||||||
|
<code>{config.key}</code>
|
||||||
|
</div>
|
||||||
|
<p className="lx-settings-config__desc">{config.description}</p>
|
||||||
|
<p className="lx-settings-config__impact">
|
||||||
|
<IconAlert size={13} />
|
||||||
|
<span>{config.impact}</span>
|
||||||
|
</p>
|
||||||
|
{SHARED_FIELD_KEYS.has(config.key) ? (
|
||||||
|
<p className="lx-settings-config__shared">
|
||||||
|
<IconShare size={12} />
|
||||||
|
<span>A3 客户字段、表单校验与任务提交共用(R-26)</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ValueMeta({ config, error }: { config: SystemConfig; error: string | null }): ReactNode {
|
||||||
|
if (error !== null) return <span className="lx-settings-config__err">{error}</span>;
|
||||||
|
const range = rangeTextOf(config);
|
||||||
|
return (
|
||||||
|
<span className="lx-settings-config__meta">
|
||||||
|
{range === '' ? null : `${range} · `}v{config.version} · {config.updatedBy ?? '—'}{' '}
|
||||||
|
{formatConfigTime(config.updatedAt)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigRowProps {
|
||||||
|
config: SystemConfig;
|
||||||
|
raw: string;
|
||||||
|
dirty: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
|
error: string | null;
|
||||||
|
editingPattern: boolean;
|
||||||
|
namingMaxLength: number;
|
||||||
|
onRawChange: (raw: string) => void;
|
||||||
|
onEditPattern: () => void;
|
||||||
|
onCollapsePattern: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个配置项:文本 / 数字 / 开关;命名模板(pattern)展开行内编辑 + 校验提示 */
|
||||||
|
export function ConfigRow({
|
||||||
|
config,
|
||||||
|
raw,
|
||||||
|
dirty,
|
||||||
|
readOnly,
|
||||||
|
error,
|
||||||
|
editingPattern,
|
||||||
|
namingMaxLength,
|
||||||
|
onRawChange,
|
||||||
|
onEditPattern,
|
||||||
|
onCollapsePattern,
|
||||||
|
}: ConfigRowProps): ReactNode {
|
||||||
|
if (config.type === 'pattern') {
|
||||||
|
const preview = namingPreview(raw, namingMaxLength);
|
||||||
|
const patternError = error ?? preview.error;
|
||||||
|
return (
|
||||||
|
<div className={`lx-settings-config is-pattern${dirty ? ' is-dirty' : ''}`}>
|
||||||
|
<LabelBlock config={config} dirty={dirty} />
|
||||||
|
{editingPattern && !readOnly ? (
|
||||||
|
<div className="lx-settings-pattern">
|
||||||
|
<Input value={raw} maxLength={200} aria-label="命名规则" onChange={onRawChange} />
|
||||||
|
<div className="lx-settings-pattern__hint">
|
||||||
|
<span className={patternError === null ? 'is-ok' : 'is-bad'}>
|
||||||
|
{patternError === null ? '校验通过' : `校验未通过:${patternError}`}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
· {raw.length} / {namingMaxLength} 字符 · 点击右侧占位符插入
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button size="small" theme="borderless" onClick={onCollapsePattern}>
|
||||||
|
收起编辑
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="lx-settings-pattern__value">
|
||||||
|
<code>{configValueText(config, raw)}</code>
|
||||||
|
{readOnly ? null : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
theme="borderless"
|
||||||
|
onClick={() => {
|
||||||
|
onEditPattern();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const control =
|
||||||
|
config.type === 'boolean' ? (
|
||||||
|
<span className="lx-settings-config__switch">
|
||||||
|
<span>{raw === 'true' ? '开启' : '关闭'}</span>
|
||||||
|
<Switch
|
||||||
|
checked={raw === 'true'}
|
||||||
|
disabled={readOnly || !config.editable}
|
||||||
|
aria-label={config.label}
|
||||||
|
onChange={(checked) => {
|
||||||
|
onRawChange(checked ? 'true' : 'false');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={`lx-settings-config__control${error === null ? '' : ' is-error'}`}>
|
||||||
|
<Input
|
||||||
|
type={config.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={raw}
|
||||||
|
maxLength={config.type === 'text' ? (config.maxLength ?? 120) : undefined}
|
||||||
|
disabled={readOnly || !config.editable}
|
||||||
|
aria-label={config.label}
|
||||||
|
onChange={onRawChange}
|
||||||
|
/>
|
||||||
|
{config.unit === null || config.unit === '' ? null : (
|
||||||
|
<span className="lx-settings-config__unit">{config.unit}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`lx-settings-config${dirty ? ' is-dirty' : ''}`}>
|
||||||
|
<LabelBlock config={config} dirty={dirty} />
|
||||||
|
<div className="lx-settings-config__value">
|
||||||
|
{control}
|
||||||
|
<ValueMeta config={config} error={error} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
|
||||||
|
import { NAMING_TOKEN_DEFINITIONS } from '@lingxi/shared';
|
||||||
|
import { IconAlert } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { namingPreview } from './config-utils';
|
||||||
|
|
||||||
|
export interface NamingAsideProps {
|
||||||
|
configLabel: string;
|
||||||
|
raw: string;
|
||||||
|
maxLength: number;
|
||||||
|
readOnly: boolean;
|
||||||
|
onInsertToken: (token: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 命名规则右侧:占位符 chips + shared resolveNamingTemplate 的实时预览与校验 */
|
||||||
|
export function NamingAside({
|
||||||
|
configLabel,
|
||||||
|
raw,
|
||||||
|
maxLength,
|
||||||
|
readOnly,
|
||||||
|
onInsertToken,
|
||||||
|
}: NamingAsideProps): ReactNode {
|
||||||
|
const preview = namingPreview(raw, maxLength);
|
||||||
|
const hasError = preview.error !== null;
|
||||||
|
return (
|
||||||
|
<aside className="lx-settings-naming-aside">
|
||||||
|
<div className="lx-settings-aside-head">
|
||||||
|
<b>命名占位符</b>
|
||||||
|
<span>{NAMING_TOKEN_DEFINITIONS.length} 个</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-settings-token-stack">
|
||||||
|
{NAMING_TOKEN_DEFINITIONS.map((token) => (
|
||||||
|
<button
|
||||||
|
key={token.key}
|
||||||
|
type="button"
|
||||||
|
className="lx-settings-token"
|
||||||
|
disabled={readOnly}
|
||||||
|
title={token.description}
|
||||||
|
onClick={() => {
|
||||||
|
onInsertToken(token.token);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code>{token.token}</code>
|
||||||
|
<span>{token.label}</span>
|
||||||
|
<span className="lx-settings-token__example">如 {token.example}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="lx-settings-preview">
|
||||||
|
<div className="lx-settings-preview__label">实时预览 · {configLabel}</div>
|
||||||
|
<div className="lx-settings-preview__value">{preview.resolved ?? '—'}</div>
|
||||||
|
<div className="lx-settings-preview__meta">
|
||||||
|
<span>预览客户 花间集</span>
|
||||||
|
<span className={hasError ? 'is-bad' : 'is-ok'}>
|
||||||
|
{hasError ? '校验未通过' : '校验通过'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{raw.length} / {maxLength} 字符
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hasError ? <div className="lx-settings-preview__error">{preview.error}</div> : null}
|
||||||
|
</div>
|
||||||
|
<p className="lx-settings-note">
|
||||||
|
<IconAlert size={13} />
|
||||||
|
<span>命名规则只影响新生成任务;已在队列中的任务使用提交时规则(R-01)。</span>
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
|
||||||
|
import { Button } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { SystemConfig, SystemConfigGroupKey } from '@lingxi/shared';
|
||||||
|
import { PageEmpty } from '@lingxi/ui';
|
||||||
|
import { IconAlert, IconClock, IconError, IconRefresh, IconView } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import type { ConfigGroupView } from './config-utils';
|
||||||
|
import { formatConfigTime, latestUpdatedAt, rawValueOf } from './config-utils';
|
||||||
|
import { ConfigRow } from './ConfigRow';
|
||||||
|
import { NamingAside } from './NamingAside';
|
||||||
|
|
||||||
|
function groupNote(key: SystemConfigGroupKey): ReactNode {
|
||||||
|
if (key === 'limits') {
|
||||||
|
return (
|
||||||
|
<p className="lx-settings-groupnote is-info">
|
||||||
|
<IconAlert size={14} />
|
||||||
|
<span>
|
||||||
|
<b>前端上限、DTO 校验与任务判定读取同一配置。</b>
|
||||||
|
修改后仅对新数据生效,历史数据保持填写时口径(R-26)。
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (key === 'threshold') {
|
||||||
|
return (
|
||||||
|
<p className="lx-settings-groupnote is-warn">
|
||||||
|
<IconError size={14} />
|
||||||
|
<span>
|
||||||
|
<b>M4 爆量判定、质量看板与通知共用同一阈值。</b>
|
||||||
|
修改后下次判定生效,不回溯历史数据。
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsGroupFormProps {
|
||||||
|
group: ConfigGroupView;
|
||||||
|
readOnly: boolean;
|
||||||
|
overrides: Readonly<Record<string, string>>;
|
||||||
|
errors: Readonly<Record<string, string>>;
|
||||||
|
dirtyCount: number;
|
||||||
|
editingPattern: string | null;
|
||||||
|
activePatternConfig: SystemConfig | undefined;
|
||||||
|
namingMaxLength: number;
|
||||||
|
saveError: string | null;
|
||||||
|
saving: boolean;
|
||||||
|
onRawChange: (config: SystemConfig, raw: string) => void;
|
||||||
|
onEditPattern: (config: SystemConfig) => void;
|
||||||
|
onCollapsePattern: () => void;
|
||||||
|
onInsertToken: (token: string) => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onRetrySave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右侧配置卡片:组头 + 只读条 + 保存失败条 + 配置项 / 命名规则双列 + 底部保存条 */
|
||||||
|
export function SettingsGroupForm({
|
||||||
|
group,
|
||||||
|
readOnly,
|
||||||
|
overrides,
|
||||||
|
errors,
|
||||||
|
dirtyCount,
|
||||||
|
editingPattern,
|
||||||
|
activePatternConfig,
|
||||||
|
namingMaxLength,
|
||||||
|
saveError,
|
||||||
|
saving,
|
||||||
|
onRawChange,
|
||||||
|
onEditPattern,
|
||||||
|
onCollapsePattern,
|
||||||
|
onInsertToken,
|
||||||
|
onDiscard,
|
||||||
|
onSave,
|
||||||
|
onRetrySave,
|
||||||
|
}: SettingsGroupFormProps): ReactNode {
|
||||||
|
const latest = latestUpdatedAt(group.items);
|
||||||
|
const configRows = group.items.map((config) => (
|
||||||
|
<ConfigRow
|
||||||
|
key={config.key}
|
||||||
|
config={config}
|
||||||
|
raw={overrides[config.key] ?? rawValueOf(config)}
|
||||||
|
dirty={overrides[config.key] !== undefined}
|
||||||
|
readOnly={readOnly}
|
||||||
|
error={errors[config.key] ?? null}
|
||||||
|
editingPattern={editingPattern === config.key}
|
||||||
|
namingMaxLength={namingMaxLength}
|
||||||
|
onRawChange={(raw) => {
|
||||||
|
onRawChange(config, raw);
|
||||||
|
}}
|
||||||
|
onEditPattern={() => {
|
||||||
|
onEditPattern(config);
|
||||||
|
}}
|
||||||
|
onCollapsePattern={onCollapsePattern}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
return (
|
||||||
|
<section className="lx-settings-form">
|
||||||
|
<div className="lx-settings-form__head">
|
||||||
|
<div>
|
||||||
|
<h2>{group.name}</h2>
|
||||||
|
<p>{group.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-settings-form__meta">
|
||||||
|
<span>
|
||||||
|
{readOnly ? '只读 · ' : '可编辑 · '}
|
||||||
|
{group.items.length} 项配置
|
||||||
|
</span>
|
||||||
|
<span>最近更新 {formatConfigTime(latest)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{readOnly ? (
|
||||||
|
<p className="lx-settings-readonlybar">
|
||||||
|
<IconView size={14} />
|
||||||
|
<span>当前账号无系统配置权限(system:configure),仅可查看配置与变更记录</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{saveError === null ? null : (
|
||||||
|
<div className="lx-settings-saveerror">
|
||||||
|
<IconError size={14} />
|
||||||
|
<span>
|
||||||
|
<b>保存失败:{saveError}</b>
|
||||||
|
修改已保留在页面,可点击「重试保存」再次提交。
|
||||||
|
</span>
|
||||||
|
<Button size="small" icon={<IconRefresh size={13} />} onClick={onRetrySave}>
|
||||||
|
重试保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="lx-settings-form__body">
|
||||||
|
{group.items.length === 0 ? (
|
||||||
|
<PageEmpty
|
||||||
|
title="当前分组暂无配置项"
|
||||||
|
description="配置项由平台初始化写入;可切换到其他分组查看,或联系超级管理员补充。"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{group.items.length === 0 ? null : group.key === 'naming' &&
|
||||||
|
activePatternConfig !== undefined ? (
|
||||||
|
<div className="lx-settings-naming-cols">
|
||||||
|
<div className="lx-settings-configlist">
|
||||||
|
{groupNote(group.key)}
|
||||||
|
{configRows}
|
||||||
|
</div>
|
||||||
|
<NamingAside
|
||||||
|
configLabel={activePatternConfig.label}
|
||||||
|
raw={overrides[activePatternConfig.key] ?? rawValueOf(activePatternConfig)}
|
||||||
|
maxLength={namingMaxLength}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onInsertToken={onInsertToken}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="lx-settings-configlist">
|
||||||
|
{groupNote(group.key)}
|
||||||
|
{configRows}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lx-settings-form__foot">
|
||||||
|
<span className={`lx-settings-dirty${dirtyCount > 0 ? ' is-on' : ''}`}>
|
||||||
|
{dirtyCount > 0 ? <i /> : null}
|
||||||
|
{dirtyCount > 0 ? `未保存 ${dirtyCount} 项修改` : '所有修改已保存'}
|
||||||
|
</span>
|
||||||
|
<span className="lx-settings-form__footnote">
|
||||||
|
<IconClock size={13} />
|
||||||
|
保存后版本号 +1,变更记录留前后值
|
||||||
|
</span>
|
||||||
|
{readOnly ? null : (
|
||||||
|
<>
|
||||||
|
<Button size="small" disabled={dirtyCount === 0} onClick={onDiscard}>
|
||||||
|
放弃更改
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
theme="solid"
|
||||||
|
disabled={dirtyCount === 0 || saving}
|
||||||
|
loading={saving}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
保存全部更改
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
|
||||||
|
import type { SystemConfigChange, SystemConfigGroupKey } from '@lingxi/shared';
|
||||||
|
import {
|
||||||
|
IconChevronRight,
|
||||||
|
IconExport,
|
||||||
|
IconNotification,
|
||||||
|
IconScript,
|
||||||
|
IconSettings,
|
||||||
|
IconShield,
|
||||||
|
IconStack,
|
||||||
|
} from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { type ConfigGroupView, formatConfigTime, latestUpdatedAt } from './config-utils';
|
||||||
|
|
||||||
|
function groupIcon(key: SystemConfigGroupKey): ReactNode {
|
||||||
|
if (key === 'basic') return <IconSettings size={15} />;
|
||||||
|
if (key === 'naming') return <IconScript size={15} />;
|
||||||
|
if (key === 'export') return <IconExport size={15} />;
|
||||||
|
if (key === 'notify') return <IconNotification size={15} />;
|
||||||
|
if (key === 'limits') return <IconStack size={15} />;
|
||||||
|
return <IconShield size={15} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsNavProps {
|
||||||
|
groups: readonly ConfigGroupView[];
|
||||||
|
activeGroup: SystemConfigGroupKey;
|
||||||
|
totalConfigs: number;
|
||||||
|
dirtyCountByGroup: Readonly<Record<string, number>>;
|
||||||
|
recent: readonly SystemConfigChange[];
|
||||||
|
recentLoading: boolean;
|
||||||
|
onSelectGroup: (key: SystemConfigGroupKey) => void;
|
||||||
|
onOpenHistory: (key: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 左侧 200 配置分组导航:项数 / 最近更新 / 未保存角标 + 底部最近变更入口 */
|
||||||
|
export function SettingsNav({
|
||||||
|
groups,
|
||||||
|
activeGroup,
|
||||||
|
totalConfigs,
|
||||||
|
dirtyCountByGroup,
|
||||||
|
recent,
|
||||||
|
recentLoading,
|
||||||
|
onSelectGroup,
|
||||||
|
onOpenHistory,
|
||||||
|
}: SettingsNavProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<aside className="lx-settings-nav" aria-label="配置分组">
|
||||||
|
<div className="lx-settings-nav__head">
|
||||||
|
<b>配置分组</b>
|
||||||
|
<span>
|
||||||
|
{groups.length} 组 · {totalConfigs} 项配置
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="lx-settings-nav__list">
|
||||||
|
{groups.map((group) => {
|
||||||
|
const latest = latestUpdatedAt(group.items);
|
||||||
|
const dirty = dirtyCountByGroup[group.key] ?? 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={group.key}
|
||||||
|
type="button"
|
||||||
|
className={`lx-settings-nav__item${group.key === activeGroup ? ' is-active' : ''}`}
|
||||||
|
aria-current={group.key === activeGroup ? 'true' : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectGroup(group.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-settings-nav__icon">{groupIcon(group.key)}</span>
|
||||||
|
<span className="lx-settings-nav__main">
|
||||||
|
<b>{group.name}</b>
|
||||||
|
<span>
|
||||||
|
{group.items.length} 项 · {latest === null ? '—' : formatConfigTime(latest)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{dirty > 0 ? <i className="lx-settings-nav__dirty">{dirty}</i> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="lx-settings-nav__recent">
|
||||||
|
<div className="lx-settings-nav__recent-head">
|
||||||
|
<b>最近变更</b>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lx-settings-link"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenHistory(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查看全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{recentLoading ? (
|
||||||
|
<div className="lx-settings-nav__recent-loading" aria-hidden="true">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!recentLoading && recent.length === 0 ? (
|
||||||
|
<p className="lx-settings-nav__recent-empty">暂无变更记录</p>
|
||||||
|
) : null}
|
||||||
|
{!recentLoading
|
||||||
|
? recent.slice(0, 4).map((change) => (
|
||||||
|
<button
|
||||||
|
key={`${change.key}-${String(change.version)}`}
|
||||||
|
type="button"
|
||||||
|
className="lx-settings-nav__recent-row"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenHistory(change.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-settings-nav__dot" />
|
||||||
|
<span className="lx-settings-nav__recent-main">
|
||||||
|
<b>{change.label}</b>
|
||||||
|
<span>
|
||||||
|
{change.operator ?? '—'} · {formatConfigTime(change.changedAt)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<IconChevronRight size={13} />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div className="lx-settings-nav__foot">
|
||||||
|
<IconShield size={12} />
|
||||||
|
system:configure 权限控制
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 系统配置页编排草稿、校验、二次确认、保存与历史抽屉,集中便于追踪数据流 */
|
||||||
|
import { Button, Modal } from '@douyinfe/semi-ui-19';
|
||||||
|
import { Permission, type SystemConfig } from '@lingxi/shared';
|
||||||
|
import { ErrorState, notifySuccess, notifyWarning, PageSkeleton } from '@lingxi/ui';
|
||||||
|
import { IconCheck, IconDocs } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useSystemConfigHistoryQuery,
|
||||||
|
useSystemConfigsQuery,
|
||||||
|
useUpdateSystemConfigsMutation,
|
||||||
|
} from '@/hooks/queries/use-configs';
|
||||||
|
import { usePermission } from '@/hooks/use-permission';
|
||||||
|
|
||||||
|
import {
|
||||||
|
collectConfigChanges,
|
||||||
|
type ConfigChangeDraft,
|
||||||
|
configGroupViews,
|
||||||
|
configValueText,
|
||||||
|
dirtyConfigKeys,
|
||||||
|
hasGuardedChange,
|
||||||
|
isConfigDirty,
|
||||||
|
isGuardedConfig,
|
||||||
|
rawValueOf,
|
||||||
|
validateConfigRaw,
|
||||||
|
} from './config-utils';
|
||||||
|
import { ConfigHistorySheet } from './ConfigHistorySheet';
|
||||||
|
import { SettingsGroupForm } from './SettingsGroupForm';
|
||||||
|
import { SettingsNav } from './SettingsNav';
|
||||||
|
|
||||||
|
/** 系统配置(方案 A:左 200 分组导航 + 右表单 + 底部保存条 + 变更记录抽屉) */
|
||||||
|
export function SettingsPage(): ReactNode {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const canWrite = usePermission(Permission.SYSTEM_CONFIGURE);
|
||||||
|
const configsQuery = useSystemConfigsQuery();
|
||||||
|
const recentQuery = useSystemConfigHistoryQuery({ limit: 5 });
|
||||||
|
const updateConfigs = useUpdateSystemConfigsMutation();
|
||||||
|
|
||||||
|
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||||
|
const [editingPattern, setEditingPattern] = useState<string | null>(null);
|
||||||
|
const [previewKey, setPreviewKey] = useState<string>('naming.script.pattern');
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
const [historyKey, setHistoryKey] = useState<string | null>(null);
|
||||||
|
const [confirmChanges, setConfirmChanges] = useState<ConfigChangeDraft[] | null>(null);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const configs = useMemo(() => configsQuery.data?.items ?? [], [configsQuery.data]);
|
||||||
|
const groups = useMemo(() => configGroupViews(configs), [configs]);
|
||||||
|
const readOnly = !canWrite;
|
||||||
|
|
||||||
|
const groupParam = searchParams.get('group');
|
||||||
|
const activeGroup =
|
||||||
|
groups.find((group) => group.key === groupParam) ??
|
||||||
|
groups.find((group) => group.key === 'naming') ??
|
||||||
|
groups[0];
|
||||||
|
|
||||||
|
const errors = useMemo(() => {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const config of configs) {
|
||||||
|
const raw = overrides[config.key];
|
||||||
|
if (raw === undefined) continue;
|
||||||
|
const message = validateConfigRaw(config, raw);
|
||||||
|
if (message !== null) result[config.key] = message;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [configs, overrides]);
|
||||||
|
const dirtyKeys = useMemo(() => dirtyConfigKeys(configs, overrides), [configs, overrides]);
|
||||||
|
const dirtyCountByGroup = useMemo(() => {
|
||||||
|
const result: Record<string, number> = {};
|
||||||
|
for (const key of dirtyKeys) {
|
||||||
|
const config = configs.find((candidate) => candidate.key === key);
|
||||||
|
if (config === undefined) continue;
|
||||||
|
result[config.group] = (result[config.group] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [configs, dirtyKeys]);
|
||||||
|
|
||||||
|
const namingGroup = groups.find((group) => group.key === 'naming');
|
||||||
|
const patternConfigs = (namingGroup?.items ?? []).filter((config) => config.type === 'pattern');
|
||||||
|
const activePatternConfig =
|
||||||
|
patternConfigs.find((config) => config.key === (editingPattern ?? previewKey)) ??
|
||||||
|
patternConfigs[0];
|
||||||
|
const maxLengthConfig = configs.find((config) => config.key === 'naming.max_length');
|
||||||
|
const maxLengthRaw =
|
||||||
|
maxLengthConfig === undefined
|
||||||
|
? '100'
|
||||||
|
: (overrides['naming.max_length'] ?? rawValueOf(maxLengthConfig));
|
||||||
|
const parsedMaxLength = Number(maxLengthRaw);
|
||||||
|
const namingMaxLength =
|
||||||
|
Number.isFinite(parsedMaxLength) && parsedMaxLength > 0 ? parsedMaxLength : 100;
|
||||||
|
|
||||||
|
const selectGroup = (key: string): void => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.set('group', key);
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
setEditingPattern(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openHistory = (key: string | null): void => {
|
||||||
|
setHistoryKey(key);
|
||||||
|
setHistoryOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeRaw = (config: SystemConfig, raw: string): void => {
|
||||||
|
setOverrides((current) => {
|
||||||
|
if (!isConfigDirty(config, raw)) {
|
||||||
|
const { [config.key]: _removed, ...rest } = current;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return { ...current, [config.key]: raw };
|
||||||
|
});
|
||||||
|
if (config.type === 'pattern') setPreviewKey(config.key);
|
||||||
|
setSaveError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertToken = (token: string): void => {
|
||||||
|
if (activePatternConfig === undefined) return;
|
||||||
|
const current = overrides[activePatternConfig.key] ?? rawValueOf(activePatternConfig);
|
||||||
|
changeRaw(activePatternConfig, current + token);
|
||||||
|
setEditingPattern(activePatternConfig.key);
|
||||||
|
setPreviewKey(activePatternConfig.key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSave = async (changes: ConfigChangeDraft[]): Promise<void> => {
|
||||||
|
setSaveError(null);
|
||||||
|
try {
|
||||||
|
await updateConfigs.mutateAsync({
|
||||||
|
changes: changes.map((change) => ({
|
||||||
|
key: change.config.key,
|
||||||
|
value: change.value,
|
||||||
|
expectedVersion: change.config.version,
|
||||||
|
reason: '在系统配置页调整',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
setOverrides({});
|
||||||
|
setConfirmChanges(null);
|
||||||
|
setEditingPattern(null);
|
||||||
|
notifySuccess('配置已保存,版本已更新');
|
||||||
|
} catch (cause) {
|
||||||
|
setConfirmChanges(null);
|
||||||
|
setSaveError(cause instanceof Error ? cause.message : '保存失败,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attemptSave = (): void => {
|
||||||
|
if (readOnly) return;
|
||||||
|
const result = collectConfigChanges(configs, overrides);
|
||||||
|
if (Object.keys(result.errors).length > 0) {
|
||||||
|
notifyWarning('有配置项校验未通过,请检查后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.changes.length === 0) {
|
||||||
|
notifyWarning('没有需要保存的修改');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasGuardedChange(result.changes)) {
|
||||||
|
setConfirmChanges(result.changes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runSave(result.changes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const guardedChanges = (confirmChanges ?? []).filter((change) => isGuardedConfig(change.config));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-settings-page">
|
||||||
|
<header className="lx-settings-page__header">
|
||||||
|
<div>
|
||||||
|
<h1>系统配置</h1>
|
||||||
|
<p>阈值、上限与命名规则的唯一来源,修改即时影响新数据</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-settings-page__actions">
|
||||||
|
<span className={`lx-settings-headindicator${dirtyKeys.length > 0 ? ' is-on' : ''}`}>
|
||||||
|
{readOnly
|
||||||
|
? '只读权限'
|
||||||
|
: dirtyKeys.length > 0
|
||||||
|
? `未保存 ${dirtyKeys.length} 项`
|
||||||
|
: '所有修改已保存'}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
icon={<IconDocs size={15} />}
|
||||||
|
onClick={() => {
|
||||||
|
openHistory(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
变更记录
|
||||||
|
</Button>
|
||||||
|
{readOnly ? null : (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconCheck size={15} />}
|
||||||
|
disabled={dirtyKeys.length === 0}
|
||||||
|
onClick={attemptSave}
|
||||||
|
>
|
||||||
|
保存全部更改
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{configsQuery.isPending ? <PageSkeleton rows={8} /> : null}
|
||||||
|
{!configsQuery.isPending && configsQuery.error !== null ? (
|
||||||
|
<ErrorState
|
||||||
|
title="系统配置加载失败"
|
||||||
|
message={configsQuery.error.message}
|
||||||
|
onRetry={() => {
|
||||||
|
void configsQuery.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!configsQuery.isPending && configsQuery.error === null ? (
|
||||||
|
<div className="lx-settings-split">
|
||||||
|
<SettingsNav
|
||||||
|
groups={groups}
|
||||||
|
activeGroup={activeGroup?.key ?? 'basic'}
|
||||||
|
totalConfigs={configs.length}
|
||||||
|
dirtyCountByGroup={dirtyCountByGroup}
|
||||||
|
recent={recentQuery.data?.items ?? []}
|
||||||
|
recentLoading={recentQuery.isPending}
|
||||||
|
onSelectGroup={selectGroup}
|
||||||
|
onOpenHistory={openHistory}
|
||||||
|
/>
|
||||||
|
{activeGroup === undefined ? null : (
|
||||||
|
<SettingsGroupForm
|
||||||
|
group={activeGroup}
|
||||||
|
readOnly={readOnly}
|
||||||
|
overrides={overrides}
|
||||||
|
errors={errors}
|
||||||
|
dirtyCount={dirtyKeys.length}
|
||||||
|
editingPattern={editingPattern}
|
||||||
|
activePatternConfig={activePatternConfig}
|
||||||
|
namingMaxLength={namingMaxLength}
|
||||||
|
saveError={saveError}
|
||||||
|
saving={updateConfigs.isPending}
|
||||||
|
onRawChange={changeRaw}
|
||||||
|
onEditPattern={(config) => {
|
||||||
|
setEditingPattern(config.key);
|
||||||
|
setPreviewKey(config.key);
|
||||||
|
}}
|
||||||
|
onCollapsePattern={() => {
|
||||||
|
setEditingPattern(null);
|
||||||
|
}}
|
||||||
|
onInsertToken={insertToken}
|
||||||
|
onDiscard={() => {
|
||||||
|
setOverrides({});
|
||||||
|
setEditingPattern(null);
|
||||||
|
setSaveError(null);
|
||||||
|
}}
|
||||||
|
onSave={attemptSave}
|
||||||
|
onRetrySave={attemptSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={confirmChanges !== null}
|
||||||
|
title="确认修改阈值与上限?"
|
||||||
|
width={520}
|
||||||
|
okText="确认并保存"
|
||||||
|
cancelText="取消"
|
||||||
|
confirmLoading={updateConfigs.isPending}
|
||||||
|
onCancel={() => {
|
||||||
|
setConfirmChanges(null);
|
||||||
|
}}
|
||||||
|
onOk={() => {
|
||||||
|
if (confirmChanges !== null) void runSave(confirmChanges);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="lx-settings-confirm">
|
||||||
|
<p>
|
||||||
|
本次有 <b>{guardedChanges.length}</b> 项阈值 / 上限配置变更,保存后版本号递增。
|
||||||
|
</p>
|
||||||
|
<ul className="lx-settings-confirm__impact">
|
||||||
|
<li>前端表单上限、DTO 校验、任务判定 / 队列读取同一配置来源(R-26)</li>
|
||||||
|
<li>修改后仅对新数据生效,历史数据保持填写时口径</li>
|
||||||
|
<li>变更记录会留存前后值、操作人与时间</li>
|
||||||
|
</ul>
|
||||||
|
<div className="lx-settings-confirm__list">
|
||||||
|
{guardedChanges.map((change) => (
|
||||||
|
<div key={change.config.key} className="lx-settings-confirm__row">
|
||||||
|
<span>{change.config.label}</span>
|
||||||
|
<b>
|
||||||
|
{configValueText(change.config, change.oldValue)}
|
||||||
|
<i>→</i>
|
||||||
|
{configValueText(change.config, change.value)}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfigHistorySheet
|
||||||
|
open={historyOpen}
|
||||||
|
filterKey={historyKey}
|
||||||
|
configs={configs}
|
||||||
|
onFilterChange={setHistoryKey}
|
||||||
|
onClose={() => {
|
||||||
|
setHistoryOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { SYSTEM_CONFIG_GROUPS, type SystemConfig, systemConfigDefinition } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
collectConfigChanges,
|
||||||
|
configGroupViews,
|
||||||
|
configKeyLabel,
|
||||||
|
configValueText,
|
||||||
|
dirtyConfigKeys,
|
||||||
|
formatConfigValue,
|
||||||
|
hasGuardedChange,
|
||||||
|
isConfigDirty,
|
||||||
|
isGuardedConfig,
|
||||||
|
latestUpdatedAt,
|
||||||
|
NAMING_SAMPLE_CONTEXT,
|
||||||
|
namingPreview,
|
||||||
|
parseConfigRaw,
|
||||||
|
rangeTextOf,
|
||||||
|
rawValueOf,
|
||||||
|
validateConfigRaw,
|
||||||
|
} from './config-utils';
|
||||||
|
|
||||||
|
const NOW = '2026-11-04T10:00:00.000Z';
|
||||||
|
|
||||||
|
function config(overrides: Partial<SystemConfig> & { key: string }): SystemConfig {
|
||||||
|
const definition = systemConfigDefinition(overrides.key);
|
||||||
|
const type = overrides.type ?? definition?.type ?? 'text';
|
||||||
|
return {
|
||||||
|
group: definition?.group ?? 'basic',
|
||||||
|
label: definition?.label ?? overrides.key,
|
||||||
|
description: definition?.description ?? '',
|
||||||
|
impact: definition?.impact ?? '',
|
||||||
|
type,
|
||||||
|
value: definition?.defaultValue ?? '',
|
||||||
|
valueType: type === 'number' ? 'number' : type === 'boolean' ? 'boolean' : 'string',
|
||||||
|
unit: definition?.unit ?? null,
|
||||||
|
version: 1,
|
||||||
|
updatedAt: NOW,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
editable: true,
|
||||||
|
isPublic: definition?.public ?? false,
|
||||||
|
min: definition?.min ?? null,
|
||||||
|
max: definition?.max ?? null,
|
||||||
|
step: definition?.step ?? null,
|
||||||
|
maxLength: definition?.maxLength ?? null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('系统配置分组与草稿', () => {
|
||||||
|
it('按 6 个固定分组渲染,组内按定义 sortOrder 排序', () => {
|
||||||
|
const configs = [
|
||||||
|
config({ key: 'naming.max_length', value: 100 }),
|
||||||
|
config({ key: 'naming.script.pattern', value: '{行业}-{序号}' }),
|
||||||
|
config({ key: 'platform.name', value: '灵犀' }),
|
||||||
|
];
|
||||||
|
const groups = configGroupViews(configs);
|
||||||
|
expect(groups.map((group) => group.key)).toEqual(
|
||||||
|
SYSTEM_CONFIG_GROUPS.map((group) => group.key),
|
||||||
|
);
|
||||||
|
expect(groups.find((group) => group.key === 'naming')?.items.map((item) => item.key)).toEqual([
|
||||||
|
'naming.script.pattern',
|
||||||
|
'naming.max_length',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('原始值 / 解析 / 脏检查:数字、布尔与文本', () => {
|
||||||
|
const numberConfig = config({ key: 'limit.customer.base_field', value: 10 });
|
||||||
|
expect(rawValueOf(numberConfig)).toBe('10');
|
||||||
|
expect(parseConfigRaw(numberConfig, '12')).toBe(12);
|
||||||
|
expect(parseConfigRaw(numberConfig, '')).toBeNull();
|
||||||
|
expect(isConfigDirty(numberConfig, '10')).toBe(false);
|
||||||
|
expect(isConfigDirty(numberConfig, '12')).toBe(true);
|
||||||
|
expect(isConfigDirty(numberConfig, '')).toBe(true);
|
||||||
|
|
||||||
|
const booleanConfig = config({ key: 'notify.task.completed', value: true });
|
||||||
|
expect(rawValueOf(booleanConfig)).toBe('true');
|
||||||
|
expect(parseConfigRaw(booleanConfig, 'false')).toBe(false);
|
||||||
|
|
||||||
|
const textConfig = config({ key: 'platform.name', value: '灵犀' });
|
||||||
|
expect(isConfigDirty(textConfig, '灵犀 Pro')).toBe(true);
|
||||||
|
expect(isConfigDirty(textConfig, '灵犀')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('行内校验复用 shared validateSystemConfigValue(范围 / 模板 / 长度)', () => {
|
||||||
|
const limit = config({ key: 'limit.customer.base_field', value: 10 });
|
||||||
|
expect(validateConfigRaw(limit, '12')).toBeNull();
|
||||||
|
expect(validateConfigRaw(limit, '0')).toContain('不能小于');
|
||||||
|
expect(validateConfigRaw(limit, '51')).toContain('不能大于');
|
||||||
|
expect(validateConfigRaw(limit, '')).toBe('请输入有效数字');
|
||||||
|
|
||||||
|
const pattern = config({ key: 'naming.script.pattern', value: '{行业}-{序号}' });
|
||||||
|
expect(validateConfigRaw(pattern, '{行业}-{客户}-{序号}')).toBeNull();
|
||||||
|
expect(validateConfigRaw(pattern, '{行业}*{序号}')).toContain('只能包含');
|
||||||
|
expect(validateConfigRaw(pattern, '{未知}')).toContain('未识别');
|
||||||
|
expect(validateConfigRaw(pattern, `{行业}${'x'.repeat(120)}`)).toContain('不能超过');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('收集变更:跳过未改动项,返回脏项错误', () => {
|
||||||
|
const configs = [
|
||||||
|
config({ key: 'platform.name', value: '灵犀' }),
|
||||||
|
config({ key: 'limit.customer.base_field', value: 10 }),
|
||||||
|
config({ key: 'limit.customer.gen_field', value: 5 }),
|
||||||
|
];
|
||||||
|
const result = collectConfigChanges(configs, {
|
||||||
|
'platform.name': '灵犀 Pro',
|
||||||
|
'limit.customer.base_field': '12',
|
||||||
|
'limit.customer.gen_field': '0',
|
||||||
|
});
|
||||||
|
expect(result.changes.map((change) => change.config.key)).toEqual([
|
||||||
|
'platform.name',
|
||||||
|
'limit.customer.base_field',
|
||||||
|
]);
|
||||||
|
expect(result.errors['limit.customer.gen_field']).toContain('不能小于');
|
||||||
|
|
||||||
|
const noChange = collectConfigChanges(configs, { 'platform.name': '灵犀' });
|
||||||
|
expect(noChange.changes).toEqual([]);
|
||||||
|
expect(noChange.errors).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('阈值 / 上限类配置需要二次确认', () => {
|
||||||
|
const guarded = config({ key: 'limit.customer.base_field', value: 10 });
|
||||||
|
const plain = config({ key: 'platform.name', value: '灵犀' });
|
||||||
|
expect(isGuardedConfig(guarded)).toBe(true);
|
||||||
|
expect(isGuardedConfig(plain)).toBe(false);
|
||||||
|
const changes = collectConfigChanges([guarded, plain], {
|
||||||
|
'limit.customer.base_field': '12',
|
||||||
|
'platform.name': '灵犀 Pro',
|
||||||
|
});
|
||||||
|
expect(hasGuardedChange(changes.changes)).toBe(true);
|
||||||
|
expect(dirtyConfigKeys([guarded, plain], { 'platform.name': '灵犀 Pro' })).toEqual([
|
||||||
|
'platform.name',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('展示格式化:单位 / 开关 / 未知值', () => {
|
||||||
|
const limit = config({ key: 'limit.customer.base_field', value: 10 });
|
||||||
|
expect(configValueText(limit, 12)).toBe('12 个');
|
||||||
|
expect(formatConfigValue(null)).toBe('未配置');
|
||||||
|
expect(formatConfigValue(true, { type: 'boolean' })).toBe('开启');
|
||||||
|
expect(formatConfigValue(false, { type: 'boolean' })).toBe('关闭');
|
||||||
|
expect(configKeyLabel('limit.customer.base_field')).toBe('客户基础字段上限');
|
||||||
|
expect(configKeyLabel('unknown.key')).toBe('unknown.key');
|
||||||
|
expect(rangeTextOf(limit)).toBe('范围 1–50 个');
|
||||||
|
expect(latestUpdatedAt([{ updatedAt: '2026-11-01T00:00:00.000Z' }, { updatedAt: NOW }])).toBe(
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('命名规则预览(shared resolveNamingTemplate)', () => {
|
||||||
|
it('默认模板解析为示例文件名', () => {
|
||||||
|
const result = namingPreview('{行业}-{客户}-{脚本方向}-{日期}-{序号}');
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.resolved).toBe('电商-花间集-口播-20261104-01');
|
||||||
|
expect(result.check.tokens).toEqual(['industry', 'customer', 'direction', 'date', 'sequence']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未知占位符 / 非法字符 / 超长给出错误', () => {
|
||||||
|
expect(namingPreview('{行业}-{未知}').error).toContain('未识别');
|
||||||
|
expect(namingPreview('{行业}*{序号}').error).toContain('只能包含');
|
||||||
|
expect(namingPreview(`{行业}${'x'.repeat(120)}`).error).toContain('不能超过');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('示例 context 覆盖 6 个占位符', () => {
|
||||||
|
expect(NAMING_SAMPLE_CONTEXT).toMatchObject({
|
||||||
|
industry: '电商',
|
||||||
|
customer: '花间集',
|
||||||
|
direction: '口播',
|
||||||
|
date: '20261104',
|
||||||
|
sequence: '01',
|
||||||
|
spec: '1080x1920',
|
||||||
|
});
|
||||||
|
expect(namingPreview('{行业}_{客户}_{日期}_{序号}_{规格}').resolved).toBe(
|
||||||
|
'电商_花间集_20261104_01_1080x1920',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* 系统配置页纯函数:分组、草稿校验、命名模板预览与展示格式化。
|
||||||
|
*
|
||||||
|
* R-26:校验与保存共用 shared 的 `validateSystemConfigValue` / `validateNamingTemplate`,
|
||||||
|
* 页面不再维护第二套阈值 / 模板规则。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
type NamingContext,
|
||||||
|
type NamingTemplateCheck,
|
||||||
|
resolveNamingTemplate,
|
||||||
|
SYSTEM_CONFIG_GROUPS,
|
||||||
|
type SystemConfig,
|
||||||
|
systemConfigDefinition,
|
||||||
|
type SystemConfigGroupKey,
|
||||||
|
type SystemConfigValue,
|
||||||
|
validateNamingTemplate,
|
||||||
|
validateSystemConfigValue,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
|
||||||
|
export interface ConfigGroupView {
|
||||||
|
key: SystemConfigGroupKey;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
items: SystemConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemSortOrder(config: SystemConfig): number {
|
||||||
|
return systemConfigDefinition(config.key)?.sortOrder ?? 9_999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 6 个固定分组渲染,组内按配置定义排序;空分组保留(页面展示空态) */
|
||||||
|
export function configGroupViews(configs: readonly SystemConfig[]): ConfigGroupView[] {
|
||||||
|
return [...SYSTEM_CONFIG_GROUPS]
|
||||||
|
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||||
|
.map((group) => ({
|
||||||
|
key: group.key,
|
||||||
|
name: group.name,
|
||||||
|
description: group.description,
|
||||||
|
items: configs
|
||||||
|
.filter((config) => config.group === group.key)
|
||||||
|
.sort((left, right) => itemSortOrder(left) - itemSortOrder(right)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configKeyLabel(key: string): string {
|
||||||
|
return systemConfigDefinition(key)?.label ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 原始值转输入框字符串(布尔用 'true' / 'false') */
|
||||||
|
export function rawValueOf(config: SystemConfig): string {
|
||||||
|
if (typeof config.value === 'boolean') return config.value ? 'true' : 'false';
|
||||||
|
return String(config.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输入框字符串按类型解析;非法输入返回 null(不抛错,用于行内校验) */
|
||||||
|
export function parseConfigRaw(config: SystemConfig, raw: string): SystemConfigValue | null {
|
||||||
|
if (config.type === 'boolean') {
|
||||||
|
if (raw === 'true') return true;
|
||||||
|
if (raw === 'false') return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (config.type === 'number') {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
const value = Number(trimmed);
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConfigDirty(config: SystemConfig, raw: string): boolean {
|
||||||
|
const parsed = parseConfigRaw(config, raw);
|
||||||
|
if (parsed === null) return true;
|
||||||
|
return parsed !== config.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dirtyConfigKeys(
|
||||||
|
configs: readonly SystemConfig[],
|
||||||
|
overrides: Readonly<Record<string, string>>,
|
||||||
|
): string[] {
|
||||||
|
return configs
|
||||||
|
.filter((config) => {
|
||||||
|
const raw = overrides[config.key];
|
||||||
|
return raw !== undefined && isConfigDirty(config, raw);
|
||||||
|
})
|
||||||
|
.map((config) => config.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行内校验:优先用 shared 定义校验,未知 key 按响应类型兜底 */
|
||||||
|
export function validateConfigRaw(config: SystemConfig, raw: string): string | null {
|
||||||
|
const parsed = parseConfigRaw(config, raw);
|
||||||
|
if (parsed === null) {
|
||||||
|
return config.type === 'number' ? '请输入有效数字' : '请输入合法值';
|
||||||
|
}
|
||||||
|
const definition = systemConfigDefinition(config.key);
|
||||||
|
if (definition !== undefined) {
|
||||||
|
const result = validateSystemConfigValue(definition, parsed);
|
||||||
|
return result.ok ? null : result.message;
|
||||||
|
}
|
||||||
|
if (config.type === 'pattern') {
|
||||||
|
const check = validateNamingTemplate(String(parsed), { maxLength: config.maxLength ?? 100 });
|
||||||
|
return check.valid ? null : check.errors.join(';');
|
||||||
|
}
|
||||||
|
if (typeof parsed === 'string' && config.maxLength !== null && parsed.length > config.maxLength) {
|
||||||
|
return `配置值不能超过 ${config.maxLength} 个字符`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigChangeDraft {
|
||||||
|
config: SystemConfig;
|
||||||
|
value: SystemConfigValue;
|
||||||
|
oldValue: SystemConfigValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectConfigChangesResult {
|
||||||
|
changes: ConfigChangeDraft[];
|
||||||
|
errors: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存前收集脏项 + 校验;错误按 key 返回给行内提示,失败时保留草稿可重试 */
|
||||||
|
export function collectConfigChanges(
|
||||||
|
configs: readonly SystemConfig[],
|
||||||
|
overrides: Readonly<Record<string, string>>,
|
||||||
|
): CollectConfigChangesResult {
|
||||||
|
const changes: ConfigChangeDraft[] = [];
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
for (const config of configs) {
|
||||||
|
const raw = overrides[config.key];
|
||||||
|
if (raw === undefined) continue;
|
||||||
|
if (!isConfigDirty(config, raw)) continue;
|
||||||
|
const error = validateConfigRaw(config, raw);
|
||||||
|
if (error !== null) {
|
||||||
|
errors[config.key] = error;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = parseConfigRaw(config, raw);
|
||||||
|
if (parsed === null) continue;
|
||||||
|
changes.push({ config, value: parsed, oldValue: config.value });
|
||||||
|
}
|
||||||
|
return { changes, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阈值 / 上限类配置修改必须先二次确认(R-26) */
|
||||||
|
export function isGuardedConfig(config: SystemConfig): boolean {
|
||||||
|
return config.group === 'limits' || config.group === 'threshold';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasGuardedChange(changes: readonly ConfigChangeDraft[]): boolean {
|
||||||
|
return changes.some((change) => isGuardedConfig(change.config));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function latestUpdatedAt(items: readonly { updatedAt: string }[]): string | null {
|
||||||
|
let latest: string | null = null;
|
||||||
|
for (const item of items) {
|
||||||
|
if (latest === null || item.updatedAt > latest) latest = item.updatedAt;
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatConfigValue(
|
||||||
|
value: unknown,
|
||||||
|
options: { type?: SystemConfig['type']; unit?: string | null } = {},
|
||||||
|
): string {
|
||||||
|
if (value === null || value === undefined) return '未配置';
|
||||||
|
if (options.type === 'boolean') return value === true || value === 'true' ? '开启' : '关闭';
|
||||||
|
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
if (options.unit !== null && options.unit !== undefined && options.unit !== '') {
|
||||||
|
return `${String(value)} ${options.unit}`;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configValueText(config: SystemConfig, value: unknown): string {
|
||||||
|
return formatConfigValue(value, { type: config.type, unit: config.unit });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MM-DD HH:mm(系统配置页统一时间展示) */
|
||||||
|
export function formatConfigTime(iso: string | null): string {
|
||||||
|
if (iso === null || iso === '') return '—';
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
|
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||||
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数值范围 / 模板长度提示 */
|
||||||
|
export function rangeTextOf(config: SystemConfig): string {
|
||||||
|
if (config.type === 'number' && (config.min !== null || config.max !== null)) {
|
||||||
|
const min = config.min ?? '';
|
||||||
|
const max = config.max ?? '';
|
||||||
|
return `范围 ${String(min)}–${String(max)}${config.unit === null ? '' : ` ${config.unit}`}`;
|
||||||
|
}
|
||||||
|
if (config.type === 'pattern') {
|
||||||
|
return `≤ ${config.maxLength ?? 100} 字符`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 命名规则预览(与保存前校验共用 shared 函数)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 预览用示例 context:电商 / 花间集 / 口播 / 20261104 / 01 / 1080x1920 */
|
||||||
|
export const NAMING_SAMPLE_CONTEXT: NamingContext = {
|
||||||
|
industry: '电商',
|
||||||
|
customer: '花间集',
|
||||||
|
direction: '口播',
|
||||||
|
date: '20261104',
|
||||||
|
sequence: '01',
|
||||||
|
spec: '1080x1920',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface NamingPreviewResult {
|
||||||
|
check: NamingTemplateCheck;
|
||||||
|
resolved: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function namingPreview(pattern: string, maxLength = 100): NamingPreviewResult {
|
||||||
|
const check = validateNamingTemplate(pattern, { maxLength });
|
||||||
|
if (!check.valid) {
|
||||||
|
return { check, resolved: null, error: check.errors.join(';') };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
check,
|
||||||
|
resolved: resolveNamingTemplate(pattern, NAMING_SAMPLE_CONTEXT, { maxLength }),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (cause) {
|
||||||
|
return {
|
||||||
|
check,
|
||||||
|
resolved: null,
|
||||||
|
error: cause instanceof Error ? cause.message : '命名预览失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,46 @@ const RoleNewPage = lazy(async () => {
|
|||||||
return { default: module.RoleNewPage };
|
return { default: module.RoleNewPage };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const IndustriesPage = lazy(async () => {
|
||||||
|
const module = await import('@/pages/system/industries/IndustriesPage');
|
||||||
|
return { default: module.IndustriesPage };
|
||||||
|
});
|
||||||
|
|
||||||
|
const CustomersPage = lazy(async () => {
|
||||||
|
const module = await import('@/pages/system/customers/CustomersPage');
|
||||||
|
return { default: module.CustomersPage };
|
||||||
|
});
|
||||||
|
|
||||||
|
const CustomerNewPage = lazy(async () => {
|
||||||
|
const module = await import('@/pages/system/customers/CustomerNewPage');
|
||||||
|
return { default: module.CustomerNewPage };
|
||||||
|
});
|
||||||
|
|
||||||
|
const CustomerDetailPage = lazy(async () => {
|
||||||
|
const module = await import('@/pages/system/customers/CustomerDetailPage');
|
||||||
|
return { default: module.CustomerDetailPage };
|
||||||
|
});
|
||||||
|
|
||||||
|
const CustomerGroupsPage = lazy(async () => {
|
||||||
|
const module = await import('@/pages/system/customer-groups/CustomerGroupsPage');
|
||||||
|
return { default: module.CustomerGroupsPage };
|
||||||
|
});
|
||||||
|
|
||||||
|
const CustomerFieldsPage = lazy(async () => {
|
||||||
|
const module = await import('@/pages/system/customer-fields/CustomerFieldsPage');
|
||||||
|
return { default: module.CustomerFieldsPage };
|
||||||
|
});
|
||||||
|
|
||||||
|
const DictsPage = lazy(async () => {
|
||||||
|
const module = await import('@/pages/system/dicts/DictsPage');
|
||||||
|
return { default: module.DictsPage };
|
||||||
|
});
|
||||||
|
|
||||||
|
const SettingsPage = lazy(async () => {
|
||||||
|
const module = await import('@/pages/system/settings/SettingsPage');
|
||||||
|
return { default: module.SettingsPage };
|
||||||
|
});
|
||||||
|
|
||||||
function withSkeleton(node: ReactNode): ReactNode {
|
function withSkeleton(node: ReactNode): ReactNode {
|
||||||
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
|
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
|
||||||
}
|
}
|
||||||
@@ -134,6 +174,78 @@ export const router = createBrowserRouter([
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'system/industries',
|
||||||
|
element: (
|
||||||
|
<RequirePermission permission={Permission.CUSTOMER_READ}>
|
||||||
|
{withSkeleton(<IndustriesPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/customers',
|
||||||
|
element: (
|
||||||
|
<RequirePermission permission={Permission.CUSTOMER_READ}>
|
||||||
|
{withSkeleton(<CustomersPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/customers/new',
|
||||||
|
element: (
|
||||||
|
<RequirePermission permission={Permission.CUSTOMER_MANAGE}>
|
||||||
|
{withSkeleton(<CustomerNewPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/customers/:id',
|
||||||
|
element: (
|
||||||
|
<RequirePermission permission={Permission.CUSTOMER_READ}>
|
||||||
|
{withSkeleton(<CustomerDetailPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/customer-groups',
|
||||||
|
element: (
|
||||||
|
<RequirePermission permission={Permission.CUSTOMER_GROUP_MANAGE}>
|
||||||
|
{withSkeleton(<CustomerGroupsPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/customer-fields/approvals',
|
||||||
|
element: (
|
||||||
|
<RequirePermission permission={Permission.CUSTOMER_FIELD_APPROVE}>
|
||||||
|
{withSkeleton(<CustomerFieldsPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/dicts',
|
||||||
|
element: (
|
||||||
|
<RequirePermission permission={Permission.DICT_MANAGE}>
|
||||||
|
{withSkeleton(<DictsPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/dicts/:type',
|
||||||
|
element: (
|
||||||
|
<RequirePermission permission={Permission.DICT_MANAGE}>
|
||||||
|
{withSkeleton(<DictsPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system/settings',
|
||||||
|
element: (
|
||||||
|
<RequirePermission permission={Permission.SYSTEM_CONFIGURE}>
|
||||||
|
{withSkeleton(<SettingsPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: ':moduleKey/:itemKey?',
|
path: ':moduleKey/:itemKey?',
|
||||||
lazy: async () => ({
|
lazy: async () => ({
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ export const ROUTES = {
|
|||||||
roles: '/system/roles',
|
roles: '/system/roles',
|
||||||
roleNew: '/system/roles/new',
|
roleNew: '/system/roles/new',
|
||||||
roleDetail: '/system/roles/:id',
|
roleDetail: '/system/roles/:id',
|
||||||
|
industries: '/system/industries',
|
||||||
|
customers: '/system/customers',
|
||||||
|
customerNew: '/system/customers/new',
|
||||||
|
customerDetail: '/system/customers/:id',
|
||||||
|
customerGroups: '/system/customer-groups',
|
||||||
|
customerFields: '/system/customer-fields/approvals',
|
||||||
|
dicts: '/system/dicts',
|
||||||
|
dictDetail: '/system/dicts/:type',
|
||||||
|
settings: '/system/settings',
|
||||||
},
|
},
|
||||||
unauthorized: '/401',
|
unauthorized: '/401',
|
||||||
forbidden: '/403',
|
forbidden: '/403',
|
||||||
@@ -34,6 +43,15 @@ export type RoutePath =
|
|||||||
| typeof ROUTES.system.roles
|
| typeof ROUTES.system.roles
|
||||||
| typeof ROUTES.system.roleNew
|
| typeof ROUTES.system.roleNew
|
||||||
| typeof ROUTES.system.roleDetail
|
| typeof ROUTES.system.roleDetail
|
||||||
|
| typeof ROUTES.system.industries
|
||||||
|
| typeof ROUTES.system.customers
|
||||||
|
| typeof ROUTES.system.customerNew
|
||||||
|
| typeof ROUTES.system.customerDetail
|
||||||
|
| typeof ROUTES.system.customerGroups
|
||||||
|
| typeof ROUTES.system.customerFields
|
||||||
|
| typeof ROUTES.system.dicts
|
||||||
|
| typeof ROUTES.system.dictDetail
|
||||||
|
| typeof ROUTES.system.settings
|
||||||
| typeof ROUTES.unauthorized
|
| typeof ROUTES.unauthorized
|
||||||
| typeof ROUTES.forbidden
|
| typeof ROUTES.forbidden
|
||||||
| typeof ROUTES.notFound
|
| typeof ROUTES.notFound
|
||||||
@@ -49,6 +67,21 @@ export function roleDetailPath(roleId: string): string {
|
|||||||
return `${ROUTES.system.roles}/${encodeURIComponent(roleId)}`;
|
return `${ROUTES.system.roles}/${encodeURIComponent(roleId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 客户详情深链:`/system/customers/{id}` */
|
||||||
|
export function customerDetailPath(customerId: string): string {
|
||||||
|
return `${ROUTES.system.customers}/${encodeURIComponent(customerId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字典类型详情深链:`/system/dicts/{typeCode}` */
|
||||||
|
export function dictDetailPath(typeCode: string): string {
|
||||||
|
return `${ROUTES.system.dicts}/${encodeURIComponent(typeCode)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建客户(四步向导) */
|
||||||
|
export function customerNewPath(): string {
|
||||||
|
return ROUTES.system.customerNew;
|
||||||
|
}
|
||||||
|
|
||||||
/** 新建角色深链;带来源角色时作为复制模板预填(特殊权限不随复制带入) */
|
/** 新建角色深链;带来源角色时作为复制模板预填(特殊权限不随复制带入) */
|
||||||
export function roleNewPath(sourceRoleId?: string): string {
|
export function roleNewPath(sourceRoleId?: string): string {
|
||||||
return sourceRoleId === undefined
|
return sourceRoleId === undefined
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -652,13 +652,13 @@ flowchart LR
|
|||||||
|
|
||||||
#### 5.4.2 客户管理
|
#### 5.4.2 客户管理
|
||||||
|
|
||||||
**行业与客户是资产划分的两级目录**:行业 = 客户的业务属性(电商 / 金融等),客户 = 行业下的具体服务对象。信息模块以「行业 → 客户」上下级菜单呈现,是全部资产与生产任务的根节点。
|
**行业与客户是资产划分的两级目录**:行业本身为两级树(一级行业 = 业务属性,如电商 / 金融;二级行业 = 细分分类,如服饰鞋包 / 信贷),客户挂末级行业。信息模块以「行业树 → 客户」上下级菜单呈现,是全部资产与生产任务的根节点。
|
||||||
|
|
||||||
最小集:客户名称 / 简称(**简称必填**,用于全部自动命名)、所属行业(绑定一个行业包,可申请客户级覆盖)、服务状态(在服务 / 暂停 / 结束)、内部负责人与客户侧对接人、交付偏好(命名习惯、目录结构、常用规格、素材禁忌)、客户资产包、自定义字段(分两层,见下)。
|
最小集:客户名称 / 简称(**简称必填**,用于全部自动命名)、所属行业(绑定一个行业包,可申请客户级覆盖)、服务状态(在服务 / 暂停 / 结束)、内部负责人与客户侧对接人、交付偏好(命名习惯、目录结构、常用规格、素材禁忌)、客户资产包、自定义字段(分两层,见下)。
|
||||||
|
|
||||||
- **自定义字段分两层**:基础信息字段由运营录入时自由追加键值对(如门店编号、内部产品代码),每客户 ≤ 10 个,**只用于检索与报表,永不进入生成**;需要进入生成或参与校核的字段走 5.4.9 审批(≤ 5 个)。
|
- **自定义字段分两层**:基础信息字段由运营录入时自由追加键值对(如门店编号、内部产品代码),每客户 ≤ 10 个,**只用于检索与报表,永不进入生成**;需要进入生成或参与校核的字段走 5.4.9 审批(≤ 5 个)。
|
||||||
- **唯一性**:行业名称全局唯一;客户名称在同一行业内唯一。
|
- **唯一性**:一级行业名称全局唯一;二级行业名称在同一父级下唯一(不同一级行业下可重名);客户名称在同一末级行业下唯一。
|
||||||
- **不可跨行业移动**:客户必须归属一个行业,调整行业走「停用 + 新建」;删除行业前须校验其下无客户、无资产,否则只能停用。
|
- **不可跨行业移动**:客户必须归属一个末级行业,调整行业走「停用 + 新建」;删除行业前须校验其下无子行业、无客户、无资产,否则只能停用;有启用中的子行业时禁止停用。
|
||||||
- **新建客户时可套用行业资产包**:选行业 → 带出模板、场景、人物、提示词、卖点 → 替换为客户 Logo 与警示语。这是"新客户冷启动 ≤ 1 个工作日"的落地方式。
|
- **新建客户时可套用行业资产包**:选行业 → 带出模板、场景、人物、提示词、卖点 → 替换为客户 Logo 与警示语。这是"新客户冷启动 ≤ 1 个工作日"的落地方式。
|
||||||
- **客户组**:把客户按行业 / 团队 / 服务等级分组;客户组是批量授权与数据范围的最小单位,避免逐个客户分配权限(见 5.4.3)。
|
- **客户组**:把客户按行业 / 团队 / 服务等级分组;客户组是批量授权与数据范围的最小单位,避免逐个客户分配权限(见 5.4.3)。
|
||||||
- 客户暂停时只读,不能发起新生成;结束时全部只读,资产转"只读资产"。
|
- 客户暂停时只读,不能发起新生成;结束时全部只读,资产转"只读资产"。
|
||||||
@@ -785,9 +785,10 @@ flowchart LR
|
|||||||
| 脚本方向 | 口播 / 剧情 / 测评 / 种草 / 情景…,可按行业收窄 |
|
| 脚本方向 | 口播 / 剧情 / 测评 / 种草 / 情景…,可按行业收窄 |
|
||||||
| 情绪与风格 | 亲和 / 专业 / 激情 / 幽默 / 悬念 |
|
| 情绪与风格 | 亲和 / 专业 / 激情 / 幽默 / 悬念 |
|
||||||
| 人群标签 | 年龄 / 性别 / 身份 / 消费力,可扩展 |
|
| 人群标签 | 年龄 / 性别 / 身份 / 消费力,可扩展 |
|
||||||
| 行业二级 | 电商 → 服饰鞋包 / 美妆个护…;金融 → 信贷 / 保险 / 理财 |
|
|
||||||
| 素材形态 | 展示 / 口播 / 剧情 / 图文动效 / 信息图 |
|
| 素材形态 | 展示 / 口播 / 剧情 / 图文动效 / 信息图 |
|
||||||
|
|
||||||
|
> 注:「行业二级」不再作为字典项,改由 `industries` 两级行业树承载(ADR-002);其余字典口径不变。
|
||||||
|
|
||||||
**变更规则**:字典变更**只影响新数据,历史数据保留快照**;否则改一次字典,历史统计全部失真。
|
**变更规则**:字典变更**只影响新数据,历史数据保留快照**;否则改一次字典,历史统计全部失真。
|
||||||
|
|
||||||
**通用枚举模型**:所有字典共用一张枚举表(类型 + 编码 + 名称 + 父编码 + 所属行业 + 排序 + 启用),一张表同时承载行业二级树与受控词表;编码不可改、名称可改;按行业作用域继承——查询取「本行业 + 父行业 + 通用」三层合并,行业包只覆盖差异项。
|
**通用枚举模型**:所有字典共用一张枚举表(类型 + 编码 + 名称 + 父编码 + 所属行业 + 排序 + 启用),一张表同时承载行业二级树与受控词表;编码不可改、名称可改;按行业作用域继承——查询取「本行业 + 父行业 + 通用」三层合并,行业包只覆盖差异项。
|
||||||
@@ -948,7 +949,7 @@ flowchart LR
|
|||||||
| R-17 | 超级管理员保护 | 超管角色不可删改停用;授予超管须二次确认并审计;建议 ≤ 2 人 |
|
| R-17 | 超级管理员保护 | 超管角色不可删改停用;授予超管须二次确认并审计;建议 ≤ 2 人 |
|
||||||
| R-18 | 职责互斥 | 生成与审核、行业包编辑与发布、字段申请与审批不可同属一个角色(超级管理员除外) |
|
| R-18 | 职责互斥 | 生成与审核、行业包编辑与发布、字段申请与审批不可同属一个角色(超级管理员除外) |
|
||||||
| R-19 | 视频方向与脚本方向边界 | 脚本方向决定"说什么"(钩子与结构),视频方向决定"怎么呈现"(口播 / 剧情 / 产品展示);二者并列、不合并 |
|
| R-19 | 视频方向与脚本方向边界 | 脚本方向决定"说什么"(钩子与结构),视频方向决定"怎么呈现"(口播 / 剧情 / 产品展示);二者并列、不合并 |
|
||||||
| R-20 | 行业 / 客户目录 | 行业是客户的业务属性,两级目录为全部资产的归属根节点;客户不可跨行业移动,行业名称全局唯一、客户名称同一行业内唯一 |
|
| R-20 | 行业 / 客户目录 | 行业本身为两级树(一级行业 → 二级行业),客户挂末级行业;行业与客户构成资产归属的两级目录;客户不可跨行业移动,一级行业名称全局唯一、二级行业名称在同一父级下唯一、客户名称在同一末级行业下唯一 |
|
||||||
| R-21 | 脚本编号唯一 | 过审入库时按 `客户编码-YYMMDD+序号` 原子分配;草稿不占号;编号一经分配不重用、不可改 |
|
| R-21 | 脚本编号唯一 | 过审入库时按 `客户编码-YYMMDD+序号` 原子分配;草稿不占号;编号一经分配不重用、不可改 |
|
||||||
| R-22 | 客户审核链接安全 | 免登录链接须签名 + 有效期 + 限范围(只读 / 可改稿);链接可随时作废;改稿保留原稿快照与操作人 |
|
| R-22 | 客户审核链接安全 | 免登录链接须签名 + 有效期 + 限范围(只读 / 可改稿);链接可随时作废;改稿保留原稿快照与操作人 |
|
||||||
| R-23 | 提示词与模型配置版本化 | 保存即快照、可回滚;任务创建时记录所用版本号,事后可复现「当时为什么生成成这样」 |
|
| R-23 | 提示词与模型配置版本化 | 保存即快照、可回滚;任务创建时记录所用版本号,事后可复现「当时为什么生成成这样」 |
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# ADR-002 行业目录:由单层改为两级树,客户挂末级行业
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 状态 | 已接受(2026-11-03,用户确认 A3 口径) |
|
||||||
|
| 决策人 | 产品 / 技术负责人(用户) |
|
||||||
|
| 记录人 | 工程 Lead |
|
||||||
|
| 关联 | 开发计划 V0.1 §6.4 A3、§7.4、§12.1 M0 DoD、附录 C;PRD V1.6 §5.4.2 / §5.4.5、R-20;技术架构 V0.4 §5.3 / §5.4.1 / §6.3.2;ADR-001 |
|
||||||
|
| 影响范围 | `client` 模块、`industry` 模块(行业包绑定)、`catalog` / `script` / `media` / `asset` 的行业维度、数据范围与统计报表、A4 字典口径、开发计划与 PRD/架构文档 |
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
PRD R-20 与技术架构 §5.3 均把 `industries` 描述为**单层行业目录**(「行业 = 客户的业务属性」,名称全局唯一),「行业二级(电商 → 服饰鞋包 / 美妆个护)」在 PRD §5.4.5 中作为 A4 字典项处理;开发计划 §6.4 A3-BE-01 也按单层 `industries` 描述。
|
||||||
|
|
||||||
|
A3 开卡前用户确认:行业目录需要**两级树**(一级行业 → 二级行业),客户归属到末级行业。该口径与既有文档冲突,且影响客户归属、行业包匹配、数据范围、统计与 A4 字典边界,属于 AGENTS §0.4 要求先提 ADR 的决策。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
### D1 行业为两级树,最多两级
|
||||||
|
|
||||||
|
`industries` 表增加:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `parent_id` | ulid FK → industries,可空 | 一级行业为空;二级行业指向一级行业 |
|
||||||
|
| `level` | smallint | 1 / 2;由服务端按 `parent_id` 推导并校验 |
|
||||||
|
| `sort_order` | int | 同级排序,默认 10 |
|
||||||
|
| `status` | varchar | 启用 / 停用 |
|
||||||
|
| `remark` | varchar(200) | 备注 |
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 仅允许两级;二级行业下不可再建子行业(应用层校验 + 数据库可选触发器兜底)。
|
||||||
|
- 一级行业 `parent_id IS NULL AND level = 1`;二级行业 `parent_id NOT NULL AND level = 2`。
|
||||||
|
- 客户只挂末级行业,详见 D2。
|
||||||
|
|
||||||
|
### D2 客户挂末级行业,行业包按祖先回退
|
||||||
|
|
||||||
|
- 客户 `industry_id` 指向**末级行业**:有子行业的一级行业不可被客户选择;一级行业无子行业时可直接选择。
|
||||||
|
- 客户创建后不可跨行业移动;调整行业走「停用 + 新建」(PRD R-20 原口径不变)。
|
||||||
|
- 行业包匹配按「精确行业 → 一级行业 → 默认」回退(B0 落地);行业顾问/行业指南同样按「二级 → 一级 → default → 内置准则」匹配(PRD §5.4.7 / §7.x)。
|
||||||
|
- 行业级资产包、行业级字段覆盖等按「客户所属末级行业 → 一级行业」继承。
|
||||||
|
|
||||||
|
### D3 行业名称唯一性:一级全局唯一 + 二级同父唯一
|
||||||
|
|
||||||
|
- 一级行业名称全局唯一。
|
||||||
|
- 二级行业名称在同一 `parent_id` 下唯一;不同一级行业下允许重名(如「其他」)。
|
||||||
|
- 客户名称仍在同一末级行业下唯一(`(industry_id, name)` 唯一,原口径不变)。
|
||||||
|
|
||||||
|
### D4 删除与停用:只停用 + 引用保护
|
||||||
|
|
||||||
|
- 删除:无子行业、无客户、无资产时才可删除;否则只能停用。
|
||||||
|
- 停用:有启用中的子行业时禁止停用,提示先处理子级;有客户时允许停用,停用后新建客户不可选该行业,已有客户数据不受影响。
|
||||||
|
- 停用一级行业不级联停用子级;需逐级处理,避免一次操作影响面过大。
|
||||||
|
|
||||||
|
### D5 权限复用现有 30 个权限点
|
||||||
|
|
||||||
|
A3 行业管理不新增权限点:
|
||||||
|
|
||||||
|
- 查看:`customer:read`;写(新建 / 编辑 / 停用 / 删除):`customer:manage`。
|
||||||
|
- 开发计划附录 C 写的 `industry_pack:view` 在 A2 定稿的 30 个权限点中不存在,按本 ADR 回写为 `customer:read`;`industry:manage` 仍表示行业包编辑(B0)。
|
||||||
|
|
||||||
|
### D6 「行业二级」从 A4 字典中移除
|
||||||
|
|
||||||
|
PRD §5.4.5 的「行业二级」字典项改由 `industries` 两级树承载;A4 字典保留其他受控词表。A4-BE-01 的「三层合并(本行业 + 父行业 + 通用)」中的「行业作用域」按两级树的祖先链解释。
|
||||||
|
|
||||||
|
## 备选方案
|
||||||
|
|
||||||
|
| 方案 | 结论 | 原因 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 保持单层行业(PRD/架构原文) | 未采用 | 用户明确要求两级;单层无法表达「电商 → 服饰鞋包」的行业分类 |
|
||||||
|
| 单层落地 + 预留 `parent_id` | 未采用 | 本期 UI 与客户选择就要两级联动,预留字段会造成前后端口径分叉 |
|
||||||
|
| 行业二级放 A4 字典 | 未采用 | 字典项没有行业归属/状态/引用保护,且客户 `industry_id` 无法稳定指向二级;A3 就需要行业树维护 |
|
||||||
|
| **两级树 + 客户挂末级(本 ADR)** | 采用 | 与用户口径一致;行业包按祖先回退,保留单层时期的继承语义 |
|
||||||
|
|
||||||
|
## 后果
|
||||||
|
|
||||||
|
### 正面
|
||||||
|
|
||||||
|
- 行业分类可表达一级/二级,客户归属更精确;行业包可在任一级配置并向下继承。
|
||||||
|
- 行业二级不再依赖 A4 字典,A3 即可交付完整行业树维护。
|
||||||
|
- 权限点不新增,复用 A2 定稿目录,治理成本低。
|
||||||
|
|
||||||
|
### 负面 / 代价
|
||||||
|
|
||||||
|
- 与 PRD R-20、技术架构 `industries`、开发计划 A3/A4/M0 DoD 的原文冲突,必须同步修订,否则文档与实现不一致。
|
||||||
|
- 客户归属校验从「行业存在」升级为「行业存在且为末级」,A2 `CustomerDirectory` 与数据范围解析需按末级行业返回客户。
|
||||||
|
- 行业统计、报表、行业包匹配需处理祖先链,查询复杂度上升。
|
||||||
|
- 历史数据若已按单层行业写入(当前本地库无行业数据),需 `parent_id` 置空并标记为一级行业;A3 首次建表,不涉及存量迁移。
|
||||||
|
|
||||||
|
### 迁移 / 兼容策略
|
||||||
|
|
||||||
|
A3 首次创建 `industries` 表即按两级结构落地,采用 expand → migrate → contract:
|
||||||
|
|
||||||
|
1. 建表含 `parent_id` / `level` / `sort_order` / `status` / `remark`;
|
||||||
|
2. 迁移脚本对存量行业(如有)设 `parent_id = NULL, level = 1`;
|
||||||
|
3. 应用层先写 `level`,后续若需严格约束再补触发器 / 检查约束;
|
||||||
|
4. 回滚:删除新增列即可回到单层语义(客户 `industry_id` 仍指向原行业)。
|
||||||
|
|
||||||
|
## 待办 / 回写清单
|
||||||
|
|
||||||
|
| 文件 | 章节 | 需要改成什么 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md` | R-20 | 明确行业两级树、客户挂末级、一级全局唯一 / 二级同父唯一 |
|
||||||
|
| 同上 | §5.4.2 | 客户所属行业改为末级行业;补充两级树与停用/删除规则 |
|
||||||
|
| 同上 | §5.4.5 | 「行业二级」从字典表移除,注明由 `industries` 两级树承载 |
|
||||||
|
| 同上 | §5.4.7 / §7.x 行业指南 | 明确「二级 → 一级 → default」回退 |
|
||||||
|
| `docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md` | §5.3 表清单 | `industries` 增加 `parent_id` / `level`,约束改为「最多两级、一级全局唯一、二级同父唯一」 |
|
||||||
|
| 同上 | §5.4.1 `customers` | `industry_id` 指向末级行业;补充「客户不可挂有子行业的一级行业」 |
|
||||||
|
| 同上 | §6.3.2 | 行业接口权限改为 `customer:read` / `customer:manage`;补充两级树与删除/停用校验 |
|
||||||
|
| `docs/开发计划/灵犀_信息流素材生产平台_开发计划_V0.1.md` | §6.4 A3-BE-01 | 改为两级行业树;删除/停用规则按 ADR-002 D4 |
|
||||||
|
| 同上 | §7.4 | 页面口径补充「一级/二级树、客户挂末级」 |
|
||||||
|
| 同上 | §12.1 M0 DoD | 「行业两级」改为「行业两级树(一级 → 二级),客户挂末级」 |
|
||||||
|
| 同上 | 附录 C | `/system/industries` 权限改为 `customer:read` / `customer:manage` |
|
||||||
|
| `docs/开发计划/灵犀_A2角色权限与数据范围_实施报告_V0.1.md` | §5 遗留 1 | 注明 A3 接入真实客户目录时按末级行业返回客户 |
|
||||||
|
| `README.md` / `AGENTS.md` | 文档索引 / 事实基线 | 视需要补充 ADR-002 与行业口径变更 |
|
||||||
|
|
||||||
|
> 文档同步按 AGENTS §0.4「先改文档,再改代码」执行;本 ADR 先于 A3 后端实现落地。
|
||||||
|
|
||||||
|
## 关联
|
||||||
|
|
||||||
|
- [ADR-001 权限点目录与六类数据范围](ADR-001-权限点目录与六类数据范围.md)
|
||||||
|
- 开发计划 V0.1 §6.4 A3、§7.4、§12.1
|
||||||
|
- PRD V1.6 R-20、§5.4.2、§5.4.5
|
||||||
|
- 技术架构 V0.4 §5.3、§5.4.1、§6.3.2
|
||||||
|
- 前端原型:`design/a3-ui-v1/`(三套候选,待用户选择)
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
| 编号 | 决策 | 状态 | 日期 | 关联 |
|
| 编号 | 决策 | 状态 | 日期 | 关联 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| [ADR-001](ADR-001-权限点目录与六类数据范围.md) | 角色权限模型:权限点目录保留 `read/write` 只增不改、特殊权限五项 6 编码、SoD 三对互斥、六类数据范围全链路 + `CustomerDirectory` 端口、超管隐式全集、A2 审计先结构化日志 | 已接受 | 2026-11-03 | A2 / A3 / DP-14 |
|
| [ADR-001](ADR-001-权限点目录与六类数据范围.md) | 角色权限模型:权限点目录保留 `read/write` 只增不改、特殊权限五项 6 编码、SoD 三对互斥、六类数据范围全链路 + `CustomerDirectory` 端口、超管隐式全集、A2 审计先结构化日志 | 已接受 | 2026-11-03 | A2 / A3 / DP-14 |
|
||||||
|
| [ADR-002](ADR-002-两级行业目录.md) | 行业目录由单层改为两级树(一级 → 二级),客户挂末级行业,一级全局唯一 / 二级同父唯一,只停用 + 引用保护,「行业二级」从 A4 字典移除 | 已接受 | 2026-11-03 | A3 / A4 / B0 |
|
||||||
|
|
||||||
## 新增流程
|
## 新增流程
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
- `GIT-01` 所有提交直接基于最新 `main`;提交前先 `git pull --ff-only`,避免落后。
|
- `GIT-01` 所有提交直接基于最新 `main`;提交前先 `git pull --ff-only`,避免落后。
|
||||||
- `GIT-02` 禁止创建并推送任何非 `main` 分支;确需本地验证时可临时创建,但不得推送,完成后立即删除。
|
- `GIT-02` 禁止创建并推送任何非 `main` 分支;确需本地验证时可临时创建,但不得推送,完成后立即删除。
|
||||||
- `GIT-03` 允许直接 push `main`;推送前必须通过本地门禁(`format:check`、`lint`、`typecheck`、`test`),推送后立即确认 CI。
|
- `GIT-03` 允许直接 push `main`;推送前必须通过本地门禁(`format:check`、`lint`、`typecheck`、`test`,完整链路用 `pnpm ci:local`)。**远端 Gitea 未部署 runner,线上 CI 不可用,推送后不等待远端 CI**。
|
||||||
- `GIT-04` 禁止 force push `main`;禁止 Merge commit;`main` 必须保持线性。
|
- `GIT-04` 禁止 force push `main`;禁止 Merge commit;`main` 必须保持线性。
|
||||||
|
|
||||||
## 2 提交信息(Conventional Commits 1.0)
|
## 2 提交信息(Conventional Commits 1.0)
|
||||||
@@ -134,4 +134,4 @@ export default {
|
|||||||
- [ ] 提交粒度单一,无无关文件
|
- [ ] 提交粒度单一,无无关文件
|
||||||
- [ ] 无 `.env` / 密钥 / 大文件
|
- [ ] 无 `.env` / 密钥 / 大文件
|
||||||
- [ ] 提交说明含背景 / 影响面 / 回滚方式 / 验证证据
|
- [ ] 提交说明含背景 / 影响面 / 回滚方式 / 验证证据
|
||||||
- [ ] 本地门禁全绿,已 push `main` 并确认 CI
|
- [ ] 本地门禁全绿(必要时 `pnpm ci:local`),已 push `main`;远端无 CI,无需等待
|
||||||
+13
-7
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
| 项 | 内容 |
|
| 项 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 文档版本 | V1.0(2026-11-02) |
|
| 文档版本 | V1.1(2026-11-04) |
|
||||||
| 依据 | 技术架构 §10.4(测试策略)、§4.6(CI/CD)、§4.1.1(本地环境) |
|
| 依据 | 技术架构 §10.4(测试策略)、§4.6(CI/CD)、§4.1.1(本地环境) |
|
||||||
| 适用范围 | 全仓库 |
|
| 适用范围 | 全仓库 |
|
||||||
| 强制级别 | 强制 |
|
| 强制级别 | 强制 |
|
||||||
|
| 远端 CI | **不启用**:Gitea 无 runner,CI 只本地执行 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,15 +15,15 @@
|
|||||||
| 层级 | 工具 | 范围 | 运行时机 |
|
| 层级 | 工具 | 范围 | 运行时机 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 单元 | Vitest | Service、工具函数、状态机、命名 / 表单引擎 | 每次提交(CI + 本地) |
|
| 单元 | Vitest | Service、工具函数、状态机、命名 / 表单引擎 | 每次提交(CI + 本地) |
|
||||||
| 集成 | Vitest + Testcontainers(PG 18 / Redis 7.4 / MinIO) | Repository、队列、迁移、事务 | 每次 push `main`(CI) |
|
| 集成 | Vitest + Testcontainers(PG 18 / Redis 7.4 / MinIO) | Repository、队列、迁移、事务 | 每次推送前本地 `pnpm ci:local` |
|
||||||
| 契约 | OpenAPI + `oasdiff` | 接口兼容性 | 每次 push `main` |
|
| 契约 | OpenAPI + `oasdiff` | 接口兼容性 | 每次推送前本地 `pnpm ci:local` |
|
||||||
| E2E | Playwright | 主链路(登录 → 生成 → 预览 → 勾选 → 导出) | 主干 + 发布前 |
|
| E2E | Playwright | 主链路(登录 → 生成 → 预览 → 勾选 → 导出) | 主干 + 发布前 |
|
||||||
| 性能 | k6 | 列表 / 任务提交 / SSE | 发布前(仅 staging) |
|
| 性能 | k6 | 列表 / 任务提交 / SSE | 发布前(仅 staging) |
|
||||||
| 视觉回归 | 图片像素比对 | PSD 合成产物 | 改动 `packages/render` 时 |
|
| 视觉回归 | 图片像素比对 | PSD 合成产物 | 改动 `packages/render` 时 |
|
||||||
|
|
||||||
- `TST-01` 单测与源码同目录,命名 `<name>.spec.ts`;集成 `*.int.spec.ts`;E2E 放 `e2e/`。
|
- `TST-01` 单测与源码同目录,命名 `<name>.spec.ts`;集成 `*.int.spec.ts`;E2E 放 `e2e/`。
|
||||||
- `TST-02` 关键包(`shared` / `form-engine` / `render` / `ai-sdk`)行覆盖率 **≥ 70%**;整体基线只升不降。
|
- `TST-02` 关键包(`shared` / `form-engine` / `render` / `ai-sdk`)行覆盖率 **≥ 70%**;整体基线只升不降。
|
||||||
- `TST-03` 禁止提交 `.skip` / `.only`;CI 检测到即失败。
|
- `TST-03` 禁止提交 `.skip` / `.only`;本地 `pnpm ci:local` 检测到即失败。
|
||||||
- `TST-04` 禁止依赖执行顺序;每个测试自建数据、自清理(用独立 `customer_id` 前缀)。
|
- `TST-04` 禁止依赖执行顺序;每个测试自建数据、自清理(用独立 `customer_id` 前缀)。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
|
|
||||||
## 3 测试数据与环境
|
## 3 测试数据与环境
|
||||||
|
|
||||||
- `TST-15` 禁止连接生产库 / 生产对象存储;CI 用 Testcontainers 起同版本依赖。
|
- `TST-15` 禁止连接生产库 / 生产对象存储;本地 CI 用 Testcontainers / 本地容器起同版本依赖。
|
||||||
- `TST-16` 测试数据用 factory 构造,禁止依赖线上导出数据。
|
- `TST-16` 测试数据用 factory 构造,禁止依赖线上导出数据。
|
||||||
- `TST-17` 测试内置时钟可注入(`clock` 包),禁止 `new Date()` 直接断言时间。
|
- `TST-17` 测试内置时钟可注入(`clock` 包),禁止 `new Date()` 直接断言时间。
|
||||||
- `TST-18` AI 调用在测试中一律 mock Provider,**禁止**真实外网调用(防止费用与不稳定)。
|
- `TST-18` AI 调用在测试中一律 mock Provider,**禁止**真实外网调用(防止费用与不稳定)。
|
||||||
@@ -54,7 +55,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4 CI 流水线
|
## 4 本地 CI 流水线(远端不跑 CI)
|
||||||
|
|
||||||
```
|
```
|
||||||
install --frozen-lockfile
|
install --frozen-lockfile
|
||||||
@@ -64,6 +65,11 @@ install --frozen-lockfile
|
|||||||
→ e2e(main)→ 镜像构建 + 漏洞扫描
|
→ e2e(main)→ 镜像构建 + 漏洞扫描
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **强制规则(2026-11-04 确认)**:远端自建 Gitea **未部署 Actions runner,线上 CI 不可用**。所有 CI 一律在本地执行:
|
||||||
|
> - 推送前运行 `pnpm ci:local`(完整链路)或至少 `pnpm format:check && pnpm lint && pnpm typecheck && pnpm test`;
|
||||||
|
> - `.gitea/workflows/ci.yml` 仅供本地参考 / 手动触发,**不随 push 自动运行**;
|
||||||
|
> - 禁止在推送后等待远端 CI、禁止以「远端 CI 会在后续跑」为由跳过本地门禁。
|
||||||
|
|
||||||
| 阶段 | 阻断条件 |
|
| 阶段 | 阻断条件 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 安装 | lock 文件与 `package.json` 不一致 |
|
| 安装 | lock 文件与 `package.json` 不一致 |
|
||||||
@@ -114,7 +120,7 @@ staging 部署 → 冒烟(登录 / 生成 / 预览 / 导出)
|
|||||||
- [ ] 端点有权限三态用例,客户隔离用例存在
|
- [ ] 端点有权限三态用例,客户隔离用例存在
|
||||||
- [ ] 状态机非法迁移、幂等、成本前置已覆盖
|
- [ ] 状态机非法迁移、幂等、成本前置已覆盖
|
||||||
- [ ] 集成测试用 Testcontainers,无外网依赖
|
- [ ] 集成测试用 Testcontainers,无外网依赖
|
||||||
- [ ] CI 全绿且未使用豁免
|
- [ ] 本地 `pnpm ci:local` 全绿且未使用豁免(远端无 runner)
|
||||||
- [ ] 迁移可回滚,staging 已演练
|
- [ ] 迁移可回滚,staging 已演练
|
||||||
- [ ] 未完成功能藏开关,开关有清理期限
|
- [ ] 未完成功能藏开关,开关有清理期限
|
||||||
- [ ] 发布记录与回滚预案已写
|
- [ ] 发布记录与回滚预案已写
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
|
|
||||||
同时满足才可推送:
|
同时满足才可推送:
|
||||||
|
|
||||||
1. 本地门禁全绿;推送后 CI 全绿(含契约与集成),无豁免。
|
1. 本地门禁全绿(含契约与集成,必要时 `pnpm ci:local`);远端 Gitea 无 runner,不等待线上 CI,无豁免。
|
||||||
2. Review 通过,blocking 意见全部解决。
|
2. Review 通过,blocking 意见全部解决。
|
||||||
3. 文档同步:PRD 规则编号、技术架构、OpenAPI、本目录细则(如涉及)。
|
3. 文档同步:PRD 规则编号、技术架构、OpenAPI、本目录细则(如涉及)。
|
||||||
4. 迁移可回滚,或在描述说明为何不可回滚与前向修复方案。
|
4. 迁移可回滚,或在描述说明为何不可回滚与前向修复方案。
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
## 5 推送与推送后
|
## 5 推送与推送后
|
||||||
|
|
||||||
- `CR-08` 推送前完成本地门禁(`format:check` / `lint` / `typecheck` / `test`);推送即进入主干,不再有 Squash 合并环节。
|
- `CR-08` 推送前完成本地门禁(`format:check` / `lint` / `typecheck` / `test`);推送即进入主干,不再有 Squash 合并环节。
|
||||||
- `CR-09` 直接 `git push origin main`;禁止 force push、禁止 Merge commit;推送后 15 分钟内确认 CI 与错误率。
|
- `CR-09` 直接 `git push origin main`;禁止 force push、禁止 Merge commit;推送前确认 `pnpm ci:local` 全绿,推送后不等待远端 CI(远端无 runner)。
|
||||||
- `CR-10` 推送后发现缺陷:优先以新提交前向修复;影响面大时按回滚预案执行(回滚也用新提交,不回写历史),并补一次复盘(写 `docs/releases/` 或 issue)。
|
- `CR-10` 推送后发现缺陷:优先以新提交前向修复;影响面大时按回滚预案执行(回滚也用新提交,不回写历史),并补一次复盘(写 `docs/releases/` 或 issue)。
|
||||||
- `CR-11` 禁止「先推后补测试 / 补文档」作为常态;紧急修复须 24 小时内以新提交补齐,并在提交说明记录。
|
- `CR-11` 禁止「先推后补测试 / 补文档」作为常态;紧急修复须 24 小时内以新提交补齐,并在提交说明记录。
|
||||||
|
|
||||||
|
|||||||
@@ -74,11 +74,13 @@ H1 权限门口径(用户确认):权限点保留现有 `read/write` 编码
|
|||||||
| 格式 | `pnpm format:check` | ✅ |
|
| 格式 | `pnpm format:check` | ✅ |
|
||||||
| Lint | `pnpm lint` | ✅ 0 warning |
|
| Lint | `pnpm lint` | ✅ 0 warning |
|
||||||
| 类型 | `pnpm typecheck` | ✅ 10/10 tasks |
|
| 类型 | `pnpm typecheck` | ✅ 10/10 tasks |
|
||||||
| 单测 | `pnpm test` | ✅ shared 119 / api 85 / web 102,共 306 |
|
| 单测 | `pnpm test` | ✅ shared 121 / api 90 / web 120,共 331 |
|
||||||
| 集成 | `pnpm test:int` | ✅ 3 文件 / 27 条(health 1 + identity 13 + roles 13) |
|
| 集成 | `pnpm test:int` | ✅ 4 文件 / 33 条(含 A2 roles 13 条) |
|
||||||
| 浏览器 e2e | `A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts` | ✅ 1 passed(18.9s),复跑 1 passed(19.3s) |
|
| 浏览器 e2e | `A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts` | ✅ 1 passed(22.1s,最终版 UI 下复跑通过) |
|
||||||
| OpenAPI | `pnpm openapi:export` | ✅ 新增 `/roles`、`/roles/{id}`、`/permissions` 路径 |
|
| OpenAPI | `pnpm openapi:export` | ✅ 新增 `/roles`、`/roles/{id}`、`/permissions` 路径 |
|
||||||
|
|
||||||
|
> 最终验收日期 2026-11-03。A2 收口后补充的 UI 调整(角色管理命名、页头主操作右对齐、编辑角色移入详情页头、移除重复的权限点目录抽屉)已随同一批提交合入,并在最终版 UI 上重跑 e2e。
|
||||||
|
|
||||||
A2-QA-01 覆盖:6 个角色端点权限三态(200/201/204 vs 403 `40301`)、40401、40903、40302 fail-closed;SoD 三对 40304 + `details.conflicts`、失败不落库、系统角色豁免边界;特殊权限分桶与单独授予、复制不带入;同一 access token 的改权限/授特殊/停用/恢复即时生效;outbox 变更审计。
|
A2-QA-01 覆盖:6 个角色端点权限三态(200/201/204 vs 403 `40301`)、40401、40903、40302 fail-closed;SoD 三对 40304 + `details.conflicts`、失败不落库、系统角色豁免边界;特殊权限分桶与单独授予、复制不带入;同一 access token 的改权限/授特殊/停用/恢复即时生效;outbox 变更审计。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# 灵犀 A3 客户与行业目录 · 实施报告 V0.1
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 实施日期 | 2026-11-03 |
|
||||||
|
| 批次 | A3(客户与行业目录) |
|
||||||
|
| 状态 | 已完成,门禁通过;未推送 |
|
||||||
|
| 依据 | 开发计划 V0.1 §6.4 / §7.4 / §12.1 / 附录 C;PRD V1.6 §5.4.2 / §5.4.9、R-07 / R-20;技术架构 V0.4 §5.3 / §5.4.1 / §6.3.2;ADR-002 |
|
||||||
|
| 决策记录 | [ADR-002 两级行业目录](../adr/ADR-002-两级行业目录.md) |
|
||||||
|
| 视觉基线 | A0「方向 A · 云白蓝」;A3 用户选定混搭:**行业管理 B / 客户列表 A / 客户详情 A / 新建客户 B / 客户组 B / 字段审批 A** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 范围与完成度
|
||||||
|
|
||||||
|
| 任务 | 内容 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A3-BE-01 | 两级行业树(一级 → 二级)、名称唯一、停用/删除引用保护 | ✅ |
|
||||||
|
| A3-BE-02 | 客户档案、简称必填、同末级行业唯一、状态机(在服务 / 暂停 / 结束) | ✅ |
|
||||||
|
| A3-BE-03 | 客户组与成员,客户组作为数据范围最小单位 | ✅ |
|
||||||
|
| A3-BE-04 | 客户级字段申请 / 审批,进入生成 ≤ 5,超限 40001 | ✅ |
|
||||||
|
| A3-BE-05 | 客户上下文与归属校验,跨客户 404;真实 `CustomerDirectory` | ✅ |
|
||||||
|
| A3-FE-01 | 行业管理(B:左两级树 + 右详情) | ✅ |
|
||||||
|
| A3-FE-02 | 客户列表 / 详情(A)、四步新建(B) | ✅ |
|
||||||
|
| A3-FE-03 | 客户组(B:左列表 + 右成员) | ✅ |
|
||||||
|
| A3-FE-04 | 字段申请与审批(A:全宽表格 + 审批弹窗) | ✅ |
|
||||||
|
| A3-QA-01 | 唯一性 / 状态 / 范围 / 字段上限用例 + 浏览器 e2e | ✅ |
|
||||||
|
|
||||||
|
> 用户确认口径:两级行业树、客户挂末级行业、一级全局唯一 + 二级同父唯一、只停用 + 引用保护、字段双层、资产包留入口、客户转交纳入 A3、复用现有 30 个权限点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 交付物
|
||||||
|
|
||||||
|
### 2.1 文档与决策
|
||||||
|
|
||||||
|
- 新增 [ADR-002 两级行业目录](../adr/ADR-002-两级行业目录.md) 并登记 ADR 索引;含 PRD / 技术架构 / 开发计划回写清单。
|
||||||
|
- 同步 PRD R-20 / §5.4.2 / §5.4.5、技术架构 §5.3 / §5.4.1 / §6.3.2、开发计划 §6.4 / §7.4 / §12.1 / 附录 C。
|
||||||
|
- 前端原型:`design/a3-ui-v1/`(三套候选 + 独立评审 + 用户选定混搭);实现截图:`design/a3-ui-v1/impl/`。
|
||||||
|
|
||||||
|
### 2.2 共享契约(`packages/shared`)
|
||||||
|
|
||||||
|
- `schemas/client.ts`:行业两级树 / 引用、客户档案与状态、客户组、客户级字段、转交、列表 query;写接口 `.strict()`。
|
||||||
|
- 上限常量 `CUSTOMER_BASE_FIELD_LIMIT = 10`、`CUSTOMER_GEN_FIELD_LIMIT = 5`(A4 迁入 `system_configs`,R-26)。
|
||||||
|
- `schemas/client.spec.ts`:行业 / 客户 / 客户组 / 字段契约覆盖。
|
||||||
|
|
||||||
|
### 2.3 数据库与种子
|
||||||
|
|
||||||
|
- 迁移 `prisma/migrations/20261103130048_a3_client_industry_customer/`(expand):
|
||||||
|
- `industries`:`parent_id` / `level` / `sort_order` / `status` / `remark`;部分唯一索引(一级全局唯一、二级同父唯一);两级 CHECK 与非自引用 CHECK;
|
||||||
|
- `customers`:`industry_id`、`short_name`、状态、负责人、对接人、偏好 JSONB、基础字段 JSONB、软删除;
|
||||||
|
- `customer_groups` / `customer_group_members`;
|
||||||
|
- `customer_fields`:进入生成与审批状态、申请人 / 审批人、驳回原因。
|
||||||
|
- `prisma/seed.ts`:幂等写入 电商(服饰鞋包 / 美妆个护)、金融(信贷)、2 个客户、1 个客户组、4 条客户字段(1 条待审批)。
|
||||||
|
- `prisma/SEED.md` 待同步(见遗留)。
|
||||||
|
|
||||||
|
### 2.4 后端(`apps/api/src/modules/client`)
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET/POST /industries`、`GET/PATCH/DELETE /industries/{id}`、`PATCH /industries/{id}/status` | 两级树、引用、停用/删除保护 |
|
||||||
|
| `GET/POST /customers`、`GET/PATCH /customers/{id}`、`PATCH /customers/{id}/status` | 列表(ScopeContext 客户维度)、详情(跨客户 404)、状态机 |
|
||||||
|
| `POST /customers/{id}/transfer`、`PUT /customers/{id}/groups`、`POST /customers/{id}/apply-industry-pack` | 转交、客户组、B0 空态 |
|
||||||
|
| `GET/POST /customer-groups`、`GET/PATCH/DELETE /customer-groups/{id}`、`POST /customer-groups/{id}/members` | 客户组与成员幂等 |
|
||||||
|
| `GET /customer-fields`、`POST /customer-fields/{id}/approve`、`GET/POST /customers/{id}/fields` | 审批队列、通过/驳回(驳回原因必填) |
|
||||||
|
| `POST /users/{id}/transfer-customers` | 保留 A1 路径,由 client 模块落地批量转交 |
|
||||||
|
|
||||||
|
- 真实 `PrismaCustomerDirectory` 替换 `EmptyCustomerDirectory`;六类数据范围解析接入客户 / 客户组 / 部门 / 创建人。
|
||||||
|
- 客户上下文:所有客户读写携带 `ScopeContext`;跨客户统一 404;客户组删除前校验 `data_scopes` 引用。
|
||||||
|
- 审计:行业 / 客户 / 客户组 / 字段 / 转交均带 `@Audit()` 结构化日志(A5 落库)。
|
||||||
|
- 测试:`client.int.spec.ts` 6 条(行业唯一/停用停用、客户唯一/跨客户/状态机、客户组范围、字段审批/超限、转交、状态只读)。
|
||||||
|
|
||||||
|
### 2.5 前端(`apps/web`)
|
||||||
|
|
||||||
|
| 页面 | 路由 | 选定布局 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 行业管理 | `/system/industries` | B:左两级树 + 右详情(白底卡片) |
|
||||||
|
| 客户列表 | `/system/customers` | A:全宽表格 + 筛选条 |
|
||||||
|
| 客户详情 | `/system/customers/:id` | A:左锚点 + 中列 + 右侧状态 |
|
||||||
|
| 新建客户 | `/system/customers/new` | B:左侧步骤 + 右侧表单,四步可回退 |
|
||||||
|
| 客户组 | `/system/customer-groups` | B:左列表 + 右成员 |
|
||||||
|
| 客户字段审批 | `/system/customer-fields/approvals` | A:全宽表格 + 通过/驳回弹窗 |
|
||||||
|
|
||||||
|
- 侧边栏更名:「客户」→「客户管理」、「行业树」→「行业管理」;新增「客户组」;「客户字段」指向审批页,权限 `customer_field:approve`。
|
||||||
|
- **2026-11-03 UI 二次对齐**(用户反馈行业管理 / 新建客户与原型差距大):
|
||||||
|
- 行业管理补回原型 B 的头像 + 状态/层级标签 + Tab(基本信息 / 子行业 / 引用)+ 最近变更时间线 + 子行业列表 / 引用客户列表;右栏白底卡片。
|
||||||
|
- 新建客户补回原型 B 的左侧「创建客户 / 共 4 步」步骤导航(标题 + 说明 + 当前/完成态)与两级行业卡片选择(一级分组、二级单选、客户数),替换原 Semi 默认 Steps + 下拉框。
|
||||||
|
- 客户组补成员表(客户 / 所属行业 / 状态 / 负责人 / 操作);客户列表补表头统计;客户详情补右栏快捷操作;字段审批补分段状态与中文类型。
|
||||||
|
- 主操作按钮统一 `theme="solid"`;副操作保持描边。截图已重拍至 `design/a3-ui-v1/impl/`。
|
||||||
|
- 客户管理筛选区补白底卡片与搜索图标/占位间距,与用户管理、角色管理的筛选条口径一致(2026-11-04)。
|
||||||
|
- 样式:`styles/client.css` 复用 `--lx-*` token;未新增依赖、未内联 SVG(图标走 `@lingxi/ui/icons`)。
|
||||||
|
- API:`api/customers.ts` + `hooks/queries/use-client.ts`,全部用 shared Zod schema 解析。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 验证结果
|
||||||
|
|
||||||
|
| 门禁 | 命令 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 格式 | `pnpm format:check` | ✅ |
|
||||||
|
| Lint | `pnpm lint` | ✅ 0 warning |
|
||||||
|
| 类型 | `pnpm typecheck` | ✅ 10/10 tasks |
|
||||||
|
| 单测 | `pnpm test` | ✅ shared 131 / api 90 / web 125,共 346 |
|
||||||
|
| 集成 | `pnpm test:int` | ✅ 5 文件 / 39 条(含 A3 client 6 条) |
|
||||||
|
| 浏览器 e2e | `A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts` | ✅ 1 passed(11.6s) |
|
||||||
|
| 构建 | `pnpm build` | ✅ 8/8 tasks |
|
||||||
|
| OpenAPI | `pnpm openapi:export` | ✅ 新增 industries / customers / customer-groups / customer-fields 路径 |
|
||||||
|
|
||||||
|
浏览器验证(真实 API + PG + Chrome):登录 → 行业管理树/详情 → 客户列表 → 客户详情 → 新建客户 → 客户组 → 字段审批六页均正常渲染;控制台仅 favicon 404。截图:`design/a3-ui-v1/impl/`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 关键口径与实现说明
|
||||||
|
|
||||||
|
1. **两级行业**:`level` 由 `parent_id` 推导;数据库 CHECK 保证「parent 为空 ⇔ level=1」,服务层禁止二级下再建子级;名称唯一通过部分唯一索引兜底。
|
||||||
|
2. **客户挂末级**:`assertLeafIndustry` 校验;客户更新不接收 `industryId`,跨行业移动走停用 + 新建。
|
||||||
|
3. **状态机**:在服务 ⇄ 暂停;在服务 / 暂停 → 结束;结束不可逆。暂停 / 结束客户更新、字段申请、转交均返回 40903。
|
||||||
|
4. **字段双层**:基础字段在 `customers.custom_fields` JSONB(≤10,免审批);`customer_fields` 只承载结构化 / 进入生成字段,进入生成需审批,按「占用槽位」计数(含待审批、不含已驳回)。
|
||||||
|
5. **数据范围**:`CustomerDirectory` 真实实现;客户列表按 `customerIds` 过滤;单条跨客户读统一 404;客户组数据范围用例已验证。
|
||||||
|
6. **客户转交**:`/customers/{id}/transfer` 需 `customer:transfer`;`/users/{id}/transfer-customers` 保留 A1 路径,允许 `customer:transfer` 或 `user:manage`。
|
||||||
|
7. **资产包**:`POST /customers/{id}/apply-industry-pack` 当前返回 42201「B0 批次提供」,前端显示空态,不伪造数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 已知差异与遗留
|
||||||
|
|
||||||
|
| 编号 | 项 | 说明 / 建议 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 行业资产包套用 | 依赖 B0/B1,当前接口返回明确业务提示 + 前端空态;B0 落地后接通。 |
|
||||||
|
| 2 | 审计落库 | A3 变更走 `@Audit()` 结构化 Pino 日志;审计表、检索、导出按 A5。 |
|
||||||
|
| 3 | 字段上限配置 | 共享常量 `10 / 5`;A4 `system_configs` 落地后迁入,前端上限读配置接口(R-26)。 |
|
||||||
|
| 4 | 客户级字段类型 | 仅白名单 文本 / 数字 / 日期 / 单选;选项在原型中简化,完整枚举来源随 A4 字典。 |
|
||||||
|
| 5 | 行业资产数 | `assetCount` 固定 0;B1 资产表落地后接入。 |
|
||||||
|
| 6 | 客户列表分页 | 前端一次拉 100 条(A3 规模);B2 后按大数据量补游标分页 UI。 |
|
||||||
|
| 7 | 客户详情面包屑 | 由导航派生,末级显示「客户管理」而非客户名;与 A2 角色编辑器同源问题,后续统一。 |
|
||||||
|
| 8 | 状态截图 | 原型阶段 A/B/C 状态图已覆盖;实现截图以主态 + e2e 为准,暂停/结束状态由 API int + e2e 覆盖。 |
|
||||||
|
| 9 | `prisma/SEED.md` | 需补 A3 行业 / 客户 / 客户组 / 字段种子说明(本次未改)。 |
|
||||||
|
| 10 | `AppModule` 的 worker | `pnpm dev` 下 worker 因 `REDIS_URL` 缺失退出,API/Web 正常;A0 遗留,非 A3 范围。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6 回滚方式
|
||||||
|
|
||||||
|
1. 代码:回滚本批 commit(client 模块、前端页面、shared schema、路由 / 导航、迁移文件、seed、ADR 与文档)。
|
||||||
|
2. 数据库:A3 为新增表,回滚可保留表(不影响 A1/A2);如需彻底回滚,执行 down SQL 删除 `customer_fields → customer_group_members → customer_groups → customers → industries`(先备份种子数据)。
|
||||||
|
3. 配置:`CUSTOMER_DIRECTORY` 改回 `EmptyCustomerDirectory` 即恢复 A2 空客户集行为(需同时回滚 client 模块 provider)。
|
||||||
|
4. 前端:回滚路由 / 导航与 `pages/system/{industries,customers,customer-groups,customer-fields}`,恢复「客户 / 行业树」旧菜单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7 本地运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm check:dev
|
||||||
|
pnpm db:init
|
||||||
|
pnpm dev
|
||||||
|
# 浏览器 http://127.0.0.1:5173 → 管理中心 / 行业管理、客户管理、客户组、客户字段
|
||||||
|
```
|
||||||
|
|
||||||
|
A3 e2e(需先起 `pnpm dev`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list
|
||||||
|
```
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# 灵犀 A4 数据字典与系统配置 · 实施报告 V0.1
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 实施日期 | 2026-11-04 |
|
||||||
|
| 批次 | A4(数据字典与系统配置) |
|
||||||
|
| 状态 | **已完成**(开发 + 全部门禁 + 浏览器 e2e 通过;未推送) |
|
||||||
|
| 依据 | 开发计划 V0.1 §6.5 / §7.3 / §12.1;PRD V1.6 §5.4.5 / R-01 / R-10 / R-26;技术架构 V0.4 §5.3 / §5.7 / §6.3.7;ADR-001 |
|
||||||
|
| 视觉基线 | A0「方向 A · 云白蓝」;用户选定 **方案 A · 主从分栏** |
|
||||||
|
| 迁移 | `prisma/migrations/20261104024511_a4_dict_system_configs/` |
|
||||||
|
| 前端路由 | `/system/dicts`、`/system/dicts/:type`、`/system/settings` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 范围与完成度
|
||||||
|
|
||||||
|
| 任务 | 内容 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A4-BE-01 | 字典模型与三层合并(`dict_types` / `dict_items`,树 / 词表一张表) | ✅ |
|
||||||
|
| A4-BE-02 | 系统配置与版本(`system_configs` + `system_config_versions`,阈值同源) | ✅ |
|
||||||
|
| A4-BE-03 | 命名规则服务(R-01 占位符 / 白名单 / 同名序号 / ≤100) | ✅ |
|
||||||
|
| A4-BE-04 | 字典快照(R-10,共享契约 + `toDictItemSnapshot`) | ✅ |
|
||||||
|
| A4-FE-01 | 字典管理(方案 A 主从分栏,树 / 词表双视图) | ✅ |
|
||||||
|
| A4-FE-02 | 系统配置页(6 组 / 命名预览 / 阈值二次确认 / 变更记录) | ✅ |
|
||||||
|
| A4-QA-01 | 合并 / 快照 / 同源用例 + 浏览器 e2e | ✅ |
|
||||||
|
|
||||||
|
> 权限口径:A2 定稿的 30 个权限点中字典为单个 `dict:manage`;系统配置为 `system:configure`。本轮未新增权限点,已同步技术架构 §6.3.7 与开发计划附录 B / C。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 交付物
|
||||||
|
|
||||||
|
### 2.1 文档与决策
|
||||||
|
|
||||||
|
- 技术架构 §5.3 / §5.7 / §6.3.7 回写:新增 `system_config_versions`,字典权限统一 `dict:manage`。
|
||||||
|
- 开发计划 §7.3 / 附录 B / 附录 C 权限口径回写。
|
||||||
|
- `prisma/SEED.md` 更新:`system_configs` 26 项、A4 字典 13 类 / 96 条。
|
||||||
|
- 前端原型:`design/a4-ui-v1/`(三套候选 + 两轮独立评审 + 用户选定 A);实现截图:`design/a4-ui-v1/impl/`。
|
||||||
|
|
||||||
|
### 2.2 共享契约(`packages/shared`)
|
||||||
|
|
||||||
|
- `schemas/dict.ts`:字典类型 / 条目 / 三层来源 / 类型与条目编码不可改 / 查询与写接口 / `toDictItemSnapshot`。
|
||||||
|
- `schemas/config.ts`:系统配置响应、批量保存(`expectedVersion`)、变更记录、`effective` 读取。
|
||||||
|
- `config/system-configs.ts`:26 项配置定义(key / group / label / description / impact / type / default / 约束 / public)与 `validateSystemConfigValue`;前后端共用,R-26 唯一事实源。
|
||||||
|
- `naming.ts`:`NAMING_TOKEN_DEFINITIONS`、`validateNamingTemplate`、`resolveNamingTemplate`、`formatNamingDate`、`formatNamingSequence`;保留既有 `dedupeName` / `sanitizeFilename`。
|
||||||
|
- `schemas/client.ts`:`CUSTOMER_BASE_FIELD_HARD_LIMIT = 50` 作为 DTO 防御性硬上界;实际生效上限由服务端动态读取。
|
||||||
|
- 单测:`dict.spec.ts` / `config.spec.ts` / `naming.spec.ts` 覆盖合并口径、快照、配置校验、模板解析、同名序号。
|
||||||
|
|
||||||
|
### 2.3 数据库与种子
|
||||||
|
|
||||||
|
- 迁移 `20261104024511_a4_dict_system_configs`:
|
||||||
|
- `dict_types`(code unique;group / kind / industry_scoped / builtin / status / sort);
|
||||||
|
- `dict_items`(type_id + code + industry_id;parent_code;`dict_items_no_self_parent_check`);
|
||||||
|
- 部分唯一索引:通用层 `(type_id, code) WHERE industry_id IS NULL`、行业层 `(type_id, code, industry_id) WHERE industry_id IS NOT NULL`;
|
||||||
|
- `system_config_versions`(append-only:config_key / version / old_value / new_value / reason / changed_by / changed_at;`(config_key, version)` 唯一)。
|
||||||
|
- 种子:
|
||||||
|
- `system_configs` 26 项,元数据来自 `SYSTEM_CONFIG_DEFINITIONS`;重复执行不覆盖人工调整值 / 版本;
|
||||||
|
- 字典 13 类 / 96 条,行业作用域映射 A3 行业(电商 / 服饰鞋包 / 美妆个护 / 金融 / 信贷 等);内置类型不可删除、条目编码不可改。
|
||||||
|
|
||||||
|
### 2.4 后端(`apps/api`)
|
||||||
|
|
||||||
|
| 模块 | 接口 |
|
||||||
|
| --- | --- |
|
||||||
|
| `catalog` | `GET /dicts`、`GET /dicts/{code}`、`POST /dicts`、`PATCH /dicts/{code}`、`PATCH /dicts/{code}/status`、`DELETE /dicts/{code}`、`GET /dicts/{code}/items`、`POST /dicts/{code}/items`、`PATCH /dicts/{code}/items/{itemId}` |
|
||||||
|
| `platform` | `GET /system/configs`、`GET /system/configs/effective`、`PUT /system/configs`、`GET /system/configs/history` |
|
||||||
|
|
||||||
|
- 三层合并为纯函数 `dict-merge.ts`:通用 0 / 父行业 1 / 本行业 2,同 code 覆盖;返回 `source` / `overridden` / `layers` / `effectiveCount` / `disabledCount`。
|
||||||
|
- 编码不可改:schema `.strict()` 不接收 code / kind / industryScoped / parentCode / industryId 更新;冲突 40902;内置类型 / 有子条目删除 40001。
|
||||||
|
- 配置保存:事务 + `expectedVersion` 乐观锁(40901)+ 条件 `version+1` + `system_config_versions` + `@Audit('platform.system_config.update')` 前后值。
|
||||||
|
- 阈值同源:`roles` / `users` / `customers` / `customer-fields` 已改为 `SystemConfigService.getNumber('limit.*')`;shared 常量只作 fallback。
|
||||||
|
- 命名服务:`resolveScriptName` / `resolveImageName` / `resolveVideoName`,读 `naming.*` + `max_length` + `duplicate_start`;缺占位符 40001,非法 / 超长 / 同名过多 42201。
|
||||||
|
- 快照:`dict.mapper#toDictSnapshot` 委托 shared;单测证明改名后历史快照不变(R-10)。
|
||||||
|
- 审计:字典类型 / 条目写操作、系统配置保存均 @Audit + 结构化日志前后值(A5 落审计表)。
|
||||||
|
|
||||||
|
### 2.5 前端(`apps/web`,方案 A)
|
||||||
|
|
||||||
|
| 页面 | 路由 | 实现 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 数据字典 | `/system/dicts` | 左 320 类型列表(4 分组折叠、搜索、状态筛选、统计)+ 右类型头 / 行业三层 chips / 树表切换 / 条目表 |
|
||||||
|
| 字典详情 | `/system/dicts/:type` | 同一主从骨架深链;默认 `script_direction` + 电商/服饰鞋包,7 项三层合并 |
|
||||||
|
| 系统配置 | `/system/settings` | 左 200 分组导航 + 6 组表单 + 命名占位符 / 实时预览 + 阈值二次确认 + 保存条 + 变更记录抽屉 |
|
||||||
|
|
||||||
|
- 组件:`DictTypeListPanel` / `DictDetailPanel` / `DictItemsView` / `DictItemSheet` / `DictTypeModal`;`SettingsPage` / `SettingsNav` / `SettingsGroupForm` / `ConfigRow` / `NamingAside` / `ConfigHistorySheet`。
|
||||||
|
- API / 状态:`api/dicts.ts` / `api/configs.ts`,shared Zod parse;`use-dicts` / `use-configs`,含 `useEffectiveSystemConfigsQuery` / `useCustomerFieldLimits`。
|
||||||
|
- 阈值同源:客户列表 / 详情 / 字段审批的 10 / 5 显示与禁用判断改为 effective config;后端最终校验不变。
|
||||||
|
- 路由 / 壳层:`/system/dicts`、`/system/dicts/:type`、`/system/settings` 已注册并按 `dict:manage` / `system:configure` 守卫;`AppLayout` 满高 flush;`main.tsx` 引入 `dicts.css` / `settings.css`。
|
||||||
|
- 侧边栏已更名「数据字典」(commit `32b44bd`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 验证结果
|
||||||
|
|
||||||
|
| 门禁 | 命令 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 格式 | `pnpm format:check` | ✅ |
|
||||||
|
| Lint | `pnpm lint` | ✅ 0 warning |
|
||||||
|
| 类型 | `pnpm typecheck` | ✅ 10/10 tasks |
|
||||||
|
| 单测(shared) | `pnpm --filter @lingxi/shared test` | ✅ 13 files / 147 tests |
|
||||||
|
| 单测(api) | `pnpm --filter @lingxi/api test` | ✅ 18 files / 137 tests |
|
||||||
|
| 单测(web) | `pnpm --filter @lingxi/web test` | ✅ 29 files / 154 tests |
|
||||||
|
| 集成 | `pnpm --filter @lingxi/api test:int` | ✅ 7 files / 58 tests(新增 catalog 10 + config 9) |
|
||||||
|
| 构建 | `pnpm build` | ✅ 8/8 tasks |
|
||||||
|
| OpenAPI | `pnpm openapi:export` + `pnpm openapi:diff` | ✅ 无破坏性变更 |
|
||||||
|
| 浏览器 e2e | `A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts` | ✅ 7 passed(约 1.1m) |
|
||||||
|
| 本地 CI | `pnpm ci:local` | ✅ 262.3s,全部门禁通过(build + test:int + openapi + smoke e2e) |
|
||||||
|
|
||||||
|
浏览器人工验收(真实 API + PG + seed):
|
||||||
|
- `/system/dicts` 默认脚本方向 / 电商·服饰鞋包,显示三层 chips(通用 5 + 父行业 2 + 本行业 1 = 生效 7),种草短视频「父行业 / 已覆盖」、直播切片「父行业」、穿搭教程「本行业」;
|
||||||
|
- 树 / 词表切换、非行业类型「全平台通用」、条目新增 / 编辑(编码只读)/ 停用 / 启用、类型新建 / 编辑(编码只读);
|
||||||
|
- `/system/settings` 6 组 / 26 项、命名占位符与实时预览、阈值旧→新二次确认、保存后版本 +1、变更记录(版本 / 前后值 / 操作人 / 时间 / 原因);
|
||||||
|
- 1440×900 与 1024×900 无横向溢出;截图:[impl](../../design/a4-ui-v1/impl/)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 关键口径与实现说明
|
||||||
|
|
||||||
|
1. **一张表同时表达树与词表**:`dict_items.parent_code` 表达二级树;不另建行业二级表(ADR-002 后行业树由 `industries` 承载)。
|
||||||
|
2. **三层合并**:查询取通用 + 父行业 + 本行业;同 code 以更具体层覆盖,返回 source / overridden;非行业作用域类型强制只读通用层。
|
||||||
|
3. **编码不可改**:类型 `code` 与条目 `code` 创建后不可改;更新 DTO `.strict()` 不接收;前端编辑态只读。
|
||||||
|
4. **配置唯一来源**:`SYSTEM_CONFIG_DEFINITIONS` 为元数据 / 默认值 / 校验唯一事实源;`system_configs` 只存值 / 版本;`/system/configs/effective` 供前端读取 public 项(上限),前端不再硬编码 10 / 5。
|
||||||
|
5. **版本与变更**:保存走事务 + 乐观锁;每次变更写 `system_config_versions`;审计日志记录前后值,A5 落审计表。
|
||||||
|
6. **命名规则**:shared 纯函数被前端预览与后端 `NamingService` 共用;字符白名单、占位符、≤100、同名序号一致。
|
||||||
|
7. **快照**:`toDictItemSnapshot` 生成填写时 typeCode / code / name / parentCode / industryId / capturedAt;业务记录在 B0 / M1 引用时保存,A4 只提供契约与工具。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 已知差异与遗留
|
||||||
|
|
||||||
|
| 编号 | 项 | 说明 / 建议 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `refCount` 固定 0 | A4 尚无引用表;B0 / B1 资产与行业包落地后接入真实引用计数 |
|
||||||
|
| 2 | 配置项 26 项(原型 24 项) | 含 A0 已有 2 项 AI 成本比例配置;A7 会提供专门 AI 配置页,A4 暂列在「业务阈值」 |
|
||||||
|
| 3 | 审计落库 | A4 只写结构化 `@Audit` 日志;审计表 / 检索 / 导出按 A5 |
|
||||||
|
| 4 | 只读态不可达 | 路由守卫为 `dict:manage` / `system:configure`,无独立 view 权限;页面保留 canWrite=false 防御分支,后续如拆分 view/manage 权限可直接生效 |
|
||||||
|
| 5 | 详情面包屑 | `/system/dicts/:type` 面包屑仍为「管理中心 / 数据字典」,未展示类型名;`breadcrumbs.ts` 无类型参数,后续统一处理(与 A3 客户详情同源问题) |
|
||||||
|
| 6 | 字典类型分页 | 当前 13 类,前端一次拉 100 条后本地筛选;类型数 >100 时需补游标分页 UI |
|
||||||
|
| 7 | `updatedBy` 存储 | 字典表存操作人姓名;`system_configs.updated_by` 存 ULID,列表 / 历史批量 join 返回姓名 |
|
||||||
|
| 8 | DTO 硬上界 | 客户基础字段 schema 硬上界 50(防御性);若配置 >50 仍会被 Zod 拦截,需同步调整 shared 硬上界 |
|
||||||
|
| 9 | e2e | `A4_E2E=1` 用例已落地并 7/7 通过;默认 skip,不影响 smoke / CI;`e2e/README.md` 待补登记一行 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6 回滚方式
|
||||||
|
|
||||||
|
1. 代码:回滚 A4 提交(catalog / platform 配置与命名 / 前端字典与配置页 / shared 契约 / 路由 / 样式 / 迁移 / seed / 文档)。
|
||||||
|
2. 数据库:A4 为新增表,回滚可保留表;彻底回滚删除顺序:`system_config_versions → dict_items → dict_types`;`system_configs` 为 A0 既有表,保留。
|
||||||
|
3. 配置:删除 A4 新增 `system_configs` 行或恢复默认值;`system_config_versions` 清空即可。
|
||||||
|
4. 前端:回滚路由 / 导航 / 页面 / 样式;`/system/dicts` / `/system/settings` 将回退到 Workspace 占位页(导航保留)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7 本地运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm check:dev
|
||||||
|
pnpm db:init
|
||||||
|
pnpm dev
|
||||||
|
# 浏览器 http://127.0.0.1:5173 → 管理中心 / 数据字典、系统配置
|
||||||
|
```
|
||||||
|
|
||||||
|
A4 e2e(需先起 `pnpm dev`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts --reporter=list
|
||||||
|
```
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
| 文档名称 | 灵犀 · 信息流素材生产平台 项目开发计划 |
|
| 文档名称 | 灵犀 · 信息流素材生产平台 项目开发计划 |
|
||||||
| 版本 | V0.1(初稿,待评审) |
|
| 版本 | V0.1(初稿,待评审) |
|
||||||
| 日期 | 2026-11-02 |
|
| 日期 | 2026-11-02 |
|
||||||
| 状态 | **执行中**:A0 工程骨架与契约基线已完成(2026-11-02);A1 身份认证与用户管理已完成(2026-11-03);A2 角色权限与数据范围已完成(2026-11-03);下一步 A3 |
|
| 状态 | **执行中**:A0 工程骨架与契约基线已完成(2026-11-02);A1 身份认证与用户管理已完成(2026-11-03);A2 角色权限与数据范围已完成(2026-11-03);A3 客户与行业目录已完成(2026-11-04 最终验收);A4 数据字典与系统配置已完成(2026-11-04);下一步 A5 |
|
||||||
| 需求基线 | PRD V1.6([灵犀-信息流广告素材一站式生产平台PRD.md](../PRD/灵犀-信息流广告素材一站式生产平台PRD.md)) |
|
| 需求基线 | PRD V1.6([灵犀-信息流广告素材一站式生产平台PRD.md](../PRD/灵犀-信息流广告素材一站式生产平台PRD.md)) |
|
||||||
| 技术基线 | 技术架构 V0.4([灵犀_信息流素材生产平台_技术架构_V0.1.md](../技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md))+ `AGENTS.md` V1.0 + `docs/工程规范/` V1.0 |
|
| 技术基线 | 技术架构 V0.4([灵犀_信息流素材生产平台_技术架构_V0.1.md](../技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md))+ `AGENTS.md` V1.0 + `docs/工程规范/` V1.0 |
|
||||||
| 覆盖范围 | **M0 任务级;M1–M4 迭代级 + 任务组级;M5 预研边界(不排期)** |
|
| 覆盖范围 | **M0 任务级;M1–M4 迭代级 + 任务组级;M5 预研边界(不排期)** |
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
| V0.1(进度回写 A1) | 2026-11-03 | A1 身份认证与用户管理完成:批次总览更新 A1 状态、A1 WBS 标注完成、回写执行证据与遗留;不改变原计划范围与验收口径 |
|
| V0.1(进度回写 A1) | 2026-11-03 | A1 身份认证与用户管理完成:批次总览更新 A1 状态、A1 WBS 标注完成、回写执行证据与遗留;不改变原计划范围与验收口径 |
|
||||||
| V0.1(路由规范回写) | 2026-11-03 | 「平台」更名「管理中心」,前端路由统一为 `/<模块>/<功能>`(A1 用户页改为 `/system/users`);同步附录 C 页面清单与前端目录约定,不改变功能范围与验收口径 |
|
| V0.1(路由规范回写) | 2026-11-03 | 「平台」更名「管理中心」,前端路由统一为 `/<模块>/<功能>`(A1 用户页改为 `/system/users`);同步附录 C 页面清单与前端目录约定,不改变功能范围与验收口径 |
|
||||||
| V0.1(A1 部门管理补强) | 2026-11-03 | 补 `/system/departments` 部门管理:WBS 增 A1-BE-05 / A1-FE-04,补页面与接口清单;只停用不删除、有成员/启用子部门禁止停用;不改变其它批次范围 |
|
| V0.1(A1 部门管理补强) | 2026-11-03 | 补 `/system/departments` 部门管理:WBS 增 A1-BE-05 / A1-FE-04,补页面与接口清单;只停用不删除、有成员/启用子部门禁止停用;不改变其它批次范围 |
|
||||||
|
| V0.1(进度回写 A4) | 2026-11-04 | A4 数据字典与系统配置完成:批次总览更新 A4 状态、A4 WBS 标注完成、回写门禁与 e2e;权限按 A2 定稿统一 `dict:manage`,新增 `system_config_versions`;不改变原计划范围与验收口径 |
|
||||||
|
|
||||||
### 0.2 引用约定
|
### 0.2 引用约定
|
||||||
|
|
||||||
@@ -381,8 +382,8 @@ flowchart LR
|
|||||||
| **A0** | ✅ 已完成 | 工程骨架与契约基线 | — | 6 | 4–5 日 | W0 | lint / typecheck / test / CI 全绿;三依赖可起;api / web / worker 可启动;OpenAPI 可导出 |
|
| **A0** | ✅ 已完成 | 工程骨架与契约基线 | — | 6 | 4–5 日 | W0 | lint / typecheck / test / CI 全绿;三依赖可起;api / web / worker 可启动;OpenAPI 可导出 |
|
||||||
| **A1** | ✅ 已完成 | 身份认证与用户管理 | A0 | 6 | 4–5 日 | W1 | 登录 / 刷新 / 登出;用户生命周期;路由权限;权限三态用例 |
|
| **A1** | ✅ 已完成 | 身份认证与用户管理 | A0 | 6 | 4–5 日 | W1 | 登录 / 刷新 / 登出;用户生命周期;路由权限;权限三态用例 |
|
||||||
| **A2** | ✅ 已完成 | 角色权限与数据范围 | A1 | 5.5 | 4–5 日 | W2 | 角色可配;SoD 命中拒绝;特殊权限默认关闭;范围过滤生效 |
|
| **A2** | ✅ 已完成 | 角色权限与数据范围 | A1 | 5.5 | 4–5 日 | W2 | 角色可配;SoD 命中拒绝;特殊权限默认关闭;范围过滤生效 |
|
||||||
| **A3** | 未开始 | 客户与行业目录 | A1 / A2 | 7 | 4–5 日 | W2 | 行业 / 客户 / 客户组可用;唯一性;状态只读;字段审批 |
|
| **A3** | ✅ 已完成 | 客户与行业目录 | A1 / A2 | 7 | 4–5 日 | W2 | 行业 / 客户 / 客户组可用;唯一性;状态只读;字段审批 |
|
||||||
| **A4** | 未开始 | 数据字典与系统配置 | A3 | 4 | 3–4 日 | W3 | 三层合并;编码不可改;阈值同源;配置版本 + 审计 |
|
| **A4** | ✅ 已完成 | 数据字典与系统配置 | A3 | 4 | 3–4 日 | W3 | 三层合并;编码不可改;阈值同源;配置版本 + 审计 |
|
||||||
| **A5** | 未开始 | 审计与日志 | A1 / A4 | 4 | 3–4 日 | W2–W3 | 关键操作全留痕;不可篡改;导出自身审计 |
|
| **A5** | 未开始 | 审计与日志 | A1 / A4 | 4 | 3–4 日 | W2–W3 | 关键操作全留痕;不可篡改;导出自身审计 |
|
||||||
| **A6** | 未开始 | 任务中心与队列骨架 | A0 / A1 | 6 | 4–5 日 | W2–W3 | 提交 → SSE → 完成 / 失败;幂等;取消 / 重试 |
|
| **A6** | 未开始 | 任务中心与队列骨架 | A0 / A1 | 6 | 4–5 日 | W2–W3 | 提交 → SSE → 完成 / 失败;幂等;取消 / 重试 |
|
||||||
| **A7** | 未开始 | AI 配置与调用底座 | A0 / A1 / A5 | 6 | 4–5 日 | W3 | 四层配置;Mock 调用;密钥不泄露;账本可查 |
|
| **A7** | 未开始 | AI 配置与调用底座 | A0 / A1 / A5 | 6 | 4–5 日 | W3 | 四层配置;Mock 调用;密钥不泄露;账本可查 |
|
||||||
@@ -397,7 +398,7 @@ flowchart LR
|
|||||||
>
|
>
|
||||||
> **A1 进度(2026-11-03)**:✅ 已完成。10/10 项 WBS 通过;`format:check / lint / typecheck / test` 全绿(shared 121 / api 90 / web 120),`test:int` 33 项(真实 PG / Redis),A1-QA-01 浏览器 e2e 6/6;登录 → 首次登录强改 → 用户列表 / 新建 / 详情 / 停用启用 / 角色调整 / 客户转交占位全链路实测。**补强**:`/system/departments` 部门管理(两级组织树、负责人/排序、只停用不删除、停用拦截)已落地并联调。实现记录、口径与遗留见 [A1 实施报告](./灵犀_A1身份认证与用户管理_实施报告_V0.1.md)。已知遗留:客户转交真实接口(A3)、审计落库(A5)。
|
> **A1 进度(2026-11-03)**:✅ 已完成。10/10 项 WBS 通过;`format:check / lint / typecheck / test` 全绿(shared 121 / api 90 / web 120),`test:int` 33 项(真实 PG / Redis),A1-QA-01 浏览器 e2e 6/6;登录 → 首次登录强改 → 用户列表 / 新建 / 详情 / 停用启用 / 角色调整 / 客户转交占位全链路实测。**补强**:`/system/departments` 部门管理(两级组织树、负责人/排序、只停用不删除、停用拦截)已落地并联调。实现记录、口径与遗留见 [A1 实施报告](./灵犀_A1身份认证与用户管理_实施报告_V0.1.md)。已知遗留:客户转交真实接口(A3)、审计落库(A5)。
|
||||||
>
|
>
|
||||||
> **A2 进度(2026-11-03)**:✅ 已完成。8/8 项 WBS 通过;H1 权限门已确认并记录 [ADR-001](../adr/ADR-001-权限点目录与六类数据范围.md);UI 门三套原型独立评审 A 8 / B 8 / C 7,用户选定 **B 主从分栏**。`format:check / lint / typecheck / test` 全绿(shared 119 / api 85 / web 102),`test:int` 27 项(真实 PG / Redis),A2-QA-01 浏览器 e2e 1/1(复跑通过);角色可配、SoD 40304、特殊权限默认关闭、六类范围解析与 `ScopeContext` 全链路、系统角色保护、变更审计与 outbox 均实测。实现记录、口径与遗留见 [A2 实施报告](./灵犀_A2角色权限与数据范围_实施报告_V0.1.md)。已知遗留:A3 前客户目录为空集、A5 审计落库、DP-14 两项特殊权限、导航模块级权限口径。
|
> **A2 进度(2026-11-03)**:✅ 已完成。8/8 项 WBS 通过;H1 权限门已确认并记录 [ADR-001](../adr/ADR-001-权限点目录与六类数据范围.md);UI 门三套原型独立评审 A 8 / B 8 / C 7,用户选定 **B 主从分栏**。最终验收(2026-11-03):`format:check / lint / typecheck / test` 全绿(shared 121 / api 90 / web 120,共 331),`test:int` 33 项(真实 PG / Redis),A2-QA-01 浏览器 e2e 1/1(22.1s,最终版 UI 下复跑通过);角色可配、SoD 40304、特殊权限默认关闭、六类范围解析与 `ScopeContext` 全链路、系统角色保护、变更审计与 outbox 均实测。收口项:侧栏/页面统一为「角色管理」、列表页头主操作右对齐、角色详情「编辑角色」移入页头、移除重复的权限点目录抽屉。实现记录、口径与遗留见 [A2 实施报告](./灵犀_A2角色权限与数据范围_实施报告_V0.1.md)。已知遗留:A3 前客户目录为空集、A5 审计落库、DP-14 两项特殊权限、导航模块级权限口径。
|
||||||
|
|
||||||
### 5.3 批次依赖图
|
### 5.3 批次依赖图
|
||||||
|
|
||||||
@@ -515,16 +516,16 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配
|
|||||||
|
|
||||||
| 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD | 状态 |
|
| 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD | 状态 |
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| A0-INF-01 | Monorepo 与工具链 | INF | pnpm workspace + Turborepo;TS strict;ESLint 9 flat + Prettier + commitlint + lint-staged;`.nvmrc` 24.15.0、`engines`、`packageManager`、`only-allow pnpm`;`apps/api`、`apps/worker`、`apps/web`、`packages/*`、`prisma/`、`infra/` 目录骨架 | — | `pnpm install` 成功;`pnpm lint` / `pnpm typecheck` 可执行;目录与 AGENTS §5 一致;npm / yarn 被拒绝 | 1.0 ✅ |
|
| A0-INF-01 | Monorepo 与工具链 | INF | pnpm workspace + Turborepo;TS strict;ESLint 9 flat + Prettier + commitlint + lint-staged;`.nvmrc` 24.15.0、`engines`、`packageManager`、`only-allow pnpm`;`apps/api`、`apps/worker`、`apps/web`、`packages/*`、`prisma/`、`infra/` 目录骨架 | — | `pnpm install` 成功;`pnpm lint` / `pnpm typecheck` 可执行;目录与 AGENTS §5 一致;npm / yarn 被拒绝 | 1.0 | ✅ |
|
||||||
| A0-INF-02 | 本地依赖与迁移种子 | INF | `docker-compose.dev.yaml`(PG18 / Redis7.4 / MinIO)、健康检查;`.env.example`;Prisma 初始化、首版迁移、种子脚本(超管、示例角色、核心字典、电商行业包占位) | A0-INF-01 | `pnpm compose:up` 三依赖健康;`pnpm db:migrate` 正向成功;种子可重复执行不重复插入 | 0.5 ✅ |
|
| A0-INF-02 | 本地依赖与迁移种子 | INF | `docker-compose.dev.yaml`(PG18 / Redis7.4 / MinIO)、健康检查;`.env.example`;Prisma 初始化、首版迁移、种子脚本(超管、示例角色、核心字典、电商行业包占位) | A0-INF-01 | `pnpm compose:up` 三依赖健康;`pnpm db:migrate` 正向成功;种子可重复执行不重复插入 | 0.5 | ✅ |
|
||||||
| A0-INF-03 | CI 与质量门禁 | INF | 流水线:`install --frozen-lockfile` → lint → typecheck → test → build → OpenAPI 产物;依赖缓存;失败阻断合并 | A0-INF-01 | 首条 PR 全绿;故意写 `any` / 类型错误 / 未格式化能被拦截 | 0.5 ✅ |
|
| A0-INF-03 | CI 与质量门禁 | INF | 流水线:`install --frozen-lockfile` → lint → typecheck → test → build → OpenAPI 产物;依赖缓存;失败阻断合并 | A0-INF-01 | 首条 PR 全绿;故意写 `any` / 类型错误 / 未格式化能被拦截 | 0.5 | ✅ |
|
||||||
| A0-BE-01 | API 骨架 | BE | NestJS 11 + Fastify;Pino + trace_id;统一错误过滤器(`{code,message,details,trace_id}`);`/health`;Swagger / OpenAPI 生成;Zod 管道;CORS 与安全头 | A0-INF-01 | `GET /health` 返回 200;错误结构统一;OpenAPI 可导出且含错误码 | 1.0 ✅ |
|
| A0-BE-01 | API 骨架 | BE | NestJS 11 + Fastify;Pino + trace_id;统一错误过滤器(`{code,message,details,trace_id}`);`/health`;Swagger / OpenAPI 生成;Zod 管道;CORS 与安全头 | A0-INF-01 | `GET /health` 返回 200;错误结构统一;OpenAPI 可导出且含错误码 | 1.0 | ✅ |
|
||||||
| A0-BE-02 | Prisma 基线 | BE | Prisma client 封装;迁移基线;`outbox_events` / `idempotency_keys` / `system_configs` 骨架;ULID 主键、软删、时间字段约定 | A0-INF-02 | 迁移可升可回滚;字段命名与工程规范 05 一致;无跨模块表 | 0.5 ✅ |
|
| A0-BE-02 | Prisma 基线 | BE | Prisma client 封装;迁移基线;`outbox_events` / `idempotency_keys` / `system_configs` 骨架;ULID 主键、软删、时间字段约定 | A0-INF-02 | 迁移可升可回滚;字段命名与工程规范 05 一致;无跨模块表 | 0.5 | ✅ |
|
||||||
| A0-SH-01 | shared 包基线 | SH | 错误码枚举(400xx–500xx)、权限点常量骨架与命名规范(`模块:动作`)、ULID、游标分页、时间与命名工具、通用 Zod schema | A0-INF-01 | 前后端同时引用成功;shared 不依赖任何业务包 | 0.5 ✅ |
|
| A0-SH-01 | shared 包基线 | SH | 错误码枚举(400xx–500xx)、权限点常量骨架与命名规范(`模块:动作`)、ULID、游标分页、时间与命名工具、通用 Zod schema | A0-INF-01 | 前后端同时引用成功;shared 不依赖任何业务包 | 0.5 | ✅ |
|
||||||
| A0-FE-01 | Web 骨架 | FE | Vite 6 + React 19 + Router 7 + TanStack Query 5 + Zustand 5;`@douyinfe/semi-ui-19` + `react19-adapter`;路径别名;请求层(token 注入、401 刷新、统一错误提示) | A0-INF-01 | 页面可启动;路由可切换;请求层有单测 | 0.5 ✅ |
|
| A0-FE-01 | Web 骨架 | FE | Vite 6 + React 19 + Router 7 + TanStack Query 5 + Zustand 5;`@douyinfe/semi-ui-19` + `react19-adapter`;路径别名;请求层(token 注入、401 刷新、统一错误提示) | A0-INF-01 | 页面可启动;路由可切换;请求层有单测 | 0.5 | ✅ |
|
||||||
| A0-FE-02 | UI 包基线 | FE | Semi 二次封装(表格 / 查询表单 / 弹窗 / 空态 / 错误态 / 加载骨架);`icons.ts` 统一出口(Remix Icon 语义名);locale;主题 token | A0-FE-01 | 业务代码不直接引图标库、不内联 SVG;ESLint 可拦截违规引用 | 0.5 ✅ |
|
| A0-FE-02 | UI 包基线 | FE | Semi 二次封装(表格 / 查询表单 / 弹窗 / 空态 / 错误态 / 加载骨架);`icons.ts` 统一出口(Remix Icon 语义名);locale;主题 token | A0-FE-01 | 业务代码不直接引图标库、不内联 SVG;ESLint 可拦截违规引用 | 0.5 | ✅ |
|
||||||
| A0-FE-03 | 布局与登录壳 | FE | 侧边导航(按模块分组)、顶栏(用户 / 任务角标占位)、面包屑、401 / 403 / 404 页、登录页静态壳 | A0-FE-01 | 布局可打开;403 页可演示;导航配置集中管理、可按权限过滤 | 0.5 ✅ |
|
| A0-FE-03 | 布局与登录壳 | FE | 侧边导航(按模块分组)、顶栏(用户 / 任务角标占位)、面包屑、401 / 403 / 404 页、登录页静态壳 | A0-FE-01 | 布局可打开;403 页可演示;导航配置集中管理、可按权限过滤 | 0.5 | ✅ |
|
||||||
| A0-QA-01 | 冒烟与报告 | QA | 健康检查 / 登录页 / OpenAPI 冒烟用例;覆盖率报告;CI 产物归档 | A0-INF-03 | CI 产出测试报告;3 条冒烟通过 | 0.5 ✅ |
|
| A0-QA-01 | 冒烟与报告 | QA | 健康检查 / 登录页 / OpenAPI 冒烟用例;覆盖率报告;CI 产物归档 | A0-INF-03 | CI 产出测试报告;3 条冒烟通过 | 0.5 | ✅ |
|
||||||
|
|
||||||
**本批门禁**:`pnpm format:check && pnpm lint && pnpm typecheck && pnpm test` 全绿;`pnpm ci:local` 全绿(含 build、test:int、openapi:export / diff、e2e);api / web / worker 可启动;OpenAPI 快照入库;CI 流程已配置,Gitea 暂无 runner 时以本地 CI 为准。
|
**本批门禁**:`pnpm format:check && pnpm lint && pnpm typecheck && pnpm test` 全绿;`pnpm ci:local` 全绿(含 build、test:int、openapi:export / diff、e2e);api / web / worker 可启动;OpenAPI 快照入库;CI 流程已配置,Gitea 暂无 runner 时以本地 CI 为准。
|
||||||
|
|
||||||
@@ -566,45 +567,53 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配
|
|||||||
| A2-FE-02 | 复制 / 模板 / 停用 | FE | 从角色或示例模板复制;停用倒计时与在线用户提示;角色详情展示绑定用户 | A2-FE-01 | 复制后可独立编辑;停用后在线用户下次请求失效 | 1.0 |
|
| A2-FE-02 | 复制 / 模板 / 停用 | FE | 从角色或示例模板复制;停用倒计时与在线用户提示;角色详情展示绑定用户 | A2-FE-01 | 复制后可独立编辑;停用后在线用户下次请求失效 | 1.0 |
|
||||||
| A2-QA-01 | 权限三态与 SoD 用例 | QA | 每个角色相关端点 3 类用例(有 / 无 / 范围外)+ 3 对 SoD 用例;权限变更即时生效 | A2-BE-05 | 用例全部通过;越权访问有审计记录 | 0.5 |
|
| A2-QA-01 | 权限三态与 SoD 用例 | QA | 每个角色相关端点 3 类用例(有 / 无 / 范围外)+ 3 对 SoD 用例;权限变更即时生效 | A2-BE-05 | 用例全部通过;越权访问有审计记录 | 0.5 |
|
||||||
|
|
||||||
> **A2 完成情况(2026-11-03)**:8/8 项 WBS 完成。H1 权限门确认:30 权限点 / 10 模块(保留 `read/write` 只补缺)、特殊权限五项 6 编码、SoD 三对、六类数据范围全链路、超管隐式全集、A2 审计先结构化日志;见 [ADR-001](../adr/ADR-001-权限点目录与六类数据范围.md)。UI 三套原型评审 A 8 / B 8 / C 7,用户选定 B 主从分栏。`test:int` 27 项、A2 e2e 1/1,门禁全绿;实现与遗留见 [A2 实施报告](./灵犀_A2角色权限与数据范围_实施报告_V0.1.md)。
|
> **A2 完成情况(2026-11-03,最终验收)**:8/8 项 WBS 完成;A2-BE-01~05、A2-FE-01/02、A2-QA-01 全部验收通过,计划状态置为 ✅ 已完成。H1 权限门确认:30 权限点 / 10 模块(保留 `read/write` 只补缺)、特殊权限五项 6 编码、SoD 三对、六类数据范围全链路、超管隐式全集、A2 审计先结构化日志;见 [ADR-001](../adr/ADR-001-权限点目录与六类数据范围.md)。UI 三套原型评审 A 8 / B 8 / C 7,用户选定 B 主从分栏。最终门禁:`test` 331 项全绿(shared 121 / api 90 / web 120),`test:int` 33 项,A2 e2e 1/1(22.1s);实现与遗留见 [A2 实施报告](./灵犀_A2角色权限与数据范围_实施报告_V0.1.md)。
|
||||||
|
|
||||||
**本批门禁**:角色可配、范围可解析、SoD 可拦截;权限三态用例全绿;特殊权限默认关闭;人类完成 **H1 权限门**(§3.4)。
|
**本批门禁**:角色可配、范围可解析、SoD 可拦截;权限三态用例全绿;特殊权限默认关闭;人类完成 **H1 权限门**(§3.4)。
|
||||||
|
|
||||||
### 6.4 A3 客户与行业目录
|
### 6.4 A3 客户与行业目录
|
||||||
|
|
||||||
|
> **执行状态:✅ 已完成(2026-11-03 开发完成,2026-11-04 UI 对齐 / 本地 CI 最终验收)**。以下 9 项 WBS 全部交付并实测;实现记录、验收证据与遗留见 [A3 实施报告](./灵犀_A3客户与行业目录_实施报告_V0.1.md)。
|
||||||
|
|
||||||
**目标**:行业 / 客户 / 客户组 / 客户字段四件事立住,作为全部资产的根节点。**对应**:P-04 / P-05 / P-17、PRD §5.4.2 / §5.4.9;`R-20` / `R-04`;架构 §6.3.2。
|
**目标**:行业 / 客户 / 客户组 / 客户字段四件事立住,作为全部资产的根节点。**对应**:P-04 / P-05 / P-17、PRD §5.4.2 / §5.4.9;`R-20` / `R-04`;架构 §6.3.2。
|
||||||
|
|
||||||
| 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD |
|
| 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD | 状态 |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| A3-BE-01 | 行业目录 | BE | `industries`;名称全局唯一;删除前校验无客户 / 无资产,否则只能停用;行业不可跨行业移动校验 | A2-BE-04 | 重名 40902;有客户时删除被拒;停用后新建客户不可选 | 0.5 |
|
| A3-BE-01 | 行业目录(两级) | BE | `industries`;一级 → 二级最多两级;一级名称全局唯一、二级同父唯一;客户只挂末级行业;删除前校验无子行业 / 客户 / 资产,否则只能停用;有启用子行业时禁止停用 | A2-BE-04 | 重名 40902;有子行业 / 客户时删除被拒;有启用子行业停用被拒;停用后新建客户不可选 | 0.5 | ✅ |
|
||||||
| A3-BE-02 | 客户档案与状态机 | BE | `customers`;简称必填、`(industry_id, name)` 唯一;服务状态(在服务 / 暂停 / 结束);负责人 / 对接人;交付偏好;套用行业资产包入口 | A3-BE-01 | 简称缺失 42201;暂停只读、不能发起新任务;结束全只读 | 1.0 |
|
| A3-BE-02 | 客户档案与状态机 | BE | `customers`;简称必填、`(industry_id, name)` 唯一;服务状态(在服务 / 暂停 / 结束);负责人 / 对接人;交付偏好;套用行业资产包入口 | A3-BE-01 | 简称缺失 42201;暂停只读、不能发起新任务;结束全只读 | 1.0 | ✅ |
|
||||||
| A3-BE-03 | 客户组 | BE | `customer_groups` + `customer_group_members`;客户组作为授权与数据范围最小单位 | A3-BE-02 | 成员增删幂等;数据范围引用客户组后可见集合正确 | 0.5 |
|
| A3-BE-03 | 客户组 | BE | `customer_groups` + `customer_group_members`;客户组作为授权与数据范围最小单位 | A3-BE-02 | 成员增删幂等;数据范围引用客户组后可见集合正确 | 0.5 | ✅ |
|
||||||
| A3-BE-04 | 客户级字段申请与审批 | BE | `customer_fields`;申请(定义 + 用途 + 是否进入生成)→ 审批 → 生效;每客户 ≤ 5 个进入生成字段、基础字段 ≤ 10 | A3-BE-02 | 超限 40001(字段校验失败);未审批字段不进入生成;审批与变更进审计 | 1.0 |
|
| A3-BE-04 | 客户级字段申请与审批 | BE | `customer_fields`;申请(定义 + 用途 + 是否进入生成)→ 审批 → 生效;每客户 ≤ 5 个进入生成字段、基础字段 ≤ 10 | A3-BE-02 | 超限 40001(字段校验失败);未审批字段不进入生成;审批与变更进审计 | 1.0 | ✅ |
|
||||||
| A3-BE-05 | 客户上下文与归属校验 | BE | 客户上下文中间件;资产 / 任务归属校验函数;跨客户访问统一 404(不暴露存在性) | A2-BE-04、A3-BE-02 | 跨客户访问返回 40401;所有查询带 `customer_id` | 0.5 |
|
| A3-BE-05 | 客户上下文与归属校验 | BE | 客户上下文中间件;资产 / 任务归属校验函数;跨客户访问统一 404(不暴露存在性) | A2-BE-04、A3-BE-02 | 跨客户访问返回 40401;所有查询带 `customer_id` | 0.5 | ✅ |
|
||||||
| A3-FE-01 | 行业管理页 | FE | 行业列表 / 新建 / 编辑 / 停用;删除前引用提示 | A0-FE-02 | 重名与有客户时的错误可读;停用状态清晰 | 0.5 |
|
| A3-FE-01 | 行业管理页 | FE | 行业列表 / 新建 / 编辑 / 停用;删除前引用提示 | A0-FE-02 | 重名与有客户时的错误可读;停用状态清晰 | 0.5 | ✅ |
|
||||||
| A3-FE-02 | 客户列表与档案 | FE | 列表(行业 / 状态 / 负责人筛选)、详情、分步新建(行业 → 基础信息 → 交付偏好 → 资产包)、简称必填校验 | A3-FE-01 | 分步表单可回退;暂停 / 结束状态只读并提示 | 1.5 |
|
| A3-FE-02 | 客户列表与档案 | FE | 列表(行业 / 状态 / 负责人筛选)、详情、分步新建(行业 → 基础信息 → 交付偏好 → 资产包)、简称必填校验 | A3-FE-01 | 分步表单可回退;暂停 / 结束状态只读并提示 | 1.5 | ✅ |
|
||||||
| A3-FE-03 | 客户组管理 | FE | 客户组列表、成员批量添加 / 移除、按客户组授权提示 | A3-FE-02 | 批量操作有确认与结果反馈 | 0.5 |
|
| A3-FE-03 | 客户组管理 | FE | 客户组列表、成员批量添加 / 移除、按客户组授权提示 | A3-FE-02 | 批量操作有确认与结果反馈 | 0.5 | ✅ |
|
||||||
| A3-FE-04 | 字段申请与审批 | FE | 字段定义表单、用途说明、是否进入生成、审批列表、超限提示 | A3-FE-02 | 申请 / 审批状态可追踪;超限给出明确文案 | 0.5 |
|
| A3-FE-04 | 字段申请与审批 | FE | 字段定义表单、用途说明、是否进入生成、审批列表、超限提示 | A3-FE-02 | 申请 / 审批状态可追踪;超限给出明确文案 | 0.5 | ✅ |
|
||||||
| A3-QA-01 | 唯一性 / 状态 / 范围用例 | QA | 行业与客户唯一性、状态只读、跨客户越权、字段上限 | A3-BE-05 | 用例全绿;越权访问有审计 | 0.5 |
|
| A3-QA-01 | 唯一性 / 状态 / 范围用例 | QA | 行业与客户唯一性、状态只读、跨客户越权、字段上限 | A3-BE-05 | 用例全绿;越权访问有审计 | 0.5 | ✅ |
|
||||||
|
|
||||||
**本批门禁**:可建行业 → 客户 → 客户组;唯一性与状态机生效;客户字段审批可用;所有查询带客户维度。
|
**本批门禁**:可建行业 → 客户 → 客户组;唯一性与状态机生效;客户字段审批可用;所有查询带客户维度。
|
||||||
|
|
||||||
|
> **A3 完成情况(2026-11-03 开发完成 / 2026-11-04 最终验收)**:9/9 项 WBS 全部 ✅。两级行业树(ADR-002)、客户档案与状态机、客户组、客户级字段双层与审批、客户转交、真实 `CustomerDirectory`、客户上下文与跨客户 404 全部落地;前端按用户选定混搭实现(行业 B / 客户列表 A / 客户详情 A / 新建 B / 客户组 B / 字段 A),侧栏更名「客户管理」「行业管理」并新增「客户组」。门禁:`format:check / lint / typecheck / test` 全绿(shared 131 / api 90 / web 125,共 346),`test:int` 39 项(5 文件,含 A3 client 6 条),A3 浏览器 e2e 1/1(11.6s),`build` 8/8;实现记录、截图与遗留见 [A3 实施报告](./灵犀_A3客户与行业目录_实施报告_V0.1.md)。已知遗留:B0 行业资产包套用、A5 审计落库、A4 字段上限配置、客户详情面包屑末级名称。
|
||||||
|
|
||||||
### 6.5 A4 数据字典与系统配置
|
### 6.5 A4 数据字典与系统配置
|
||||||
|
|
||||||
|
> **执行状态:✅ 已完成(2026-11-04 开发完成 + 全部门禁 + A4 浏览器 e2e 通过)**。以下 7 项 WBS 全部交付并实测;实现记录、验收证据与遗留见 [A4 实施报告](./灵犀_A4数据字典与系统配置_实施报告_V0.1.md)。
|
||||||
|
|
||||||
**目标**:字典与配置成为唯一来源,杜绝前后端硬编码。**对应**:P-06、P-13(部分)、PRD §5.4.5;`R-10` / `R-01` / `R-26`;架构 §5.3 / §5.7。
|
**目标**:字典与配置成为唯一来源,杜绝前后端硬编码。**对应**:P-06、P-13(部分)、PRD §5.4.5;`R-10` / `R-01` / `R-26`;架构 §5.3 / §5.7。
|
||||||
|
|
||||||
| 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD |
|
| 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD | 状态 |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| A4-BE-01 | 字典模型与三层合并 | BE | `dict_types` / `dict_items` 通用枚举表;类型 + 编码 + 名称 + 父编码 + 行业 + 排序 + 启用;编码不可改、名称可改;查询按"本行业 + 父行业 + 通用"三层合并 | A3-BE-01 | 一张表同时表达二级树与词表;编码修改被拒;三层合并结果有单测 | 1.0 |
|
| A4-BE-01 | 字典模型与三层合并 | BE | `dict_types` / `dict_items` 通用枚举表;类型 + 编码 + 名称 + 父编码 + 行业 + 排序 + 启用;编码不可改、名称可改;查询按"本行业 + 父行业 + 通用"三层合并 | A3-BE-01 | 一张表同时表达二级树与词表;编码修改被拒;三层合并结果有单测 | 1.0 | ✅ |
|
||||||
| A4-BE-02 | 系统配置与版本 | BE | `system_configs` 键值 + 版本 + 变更审计;命名规则、导出设置、通知开关、阈值(爆量 / 超时 / 上限)单一来源;前后端同源读取 | A0-BE-02 | 配置变更留前后值;阈值只有一处定义;前端上限读取配置接口 | 0.5 |
|
| A4-BE-02 | 系统配置与版本 | BE | `system_configs` 键值 + 版本 + 变更审计;命名规则、导出设置、通知开关、阈值(爆量 / 超时 / 上限)单一来源;前后端同源读取 | A0-BE-02 | 配置变更留前后值;阈值只有一处定义;前端上限读取配置接口 | 0.5 | ✅ |
|
||||||
| A4-BE-03 | 命名规则服务 | BE | `R-01` 占位符解析(行业 / 客户 / 方向 / 日期 / 序号),中文 / 字母 / 数字 / 下划线 / 短横线校验,同名自动加序号,≤ 100 字符 | A4-BE-02 | 占位符缺失报错;同名序号连续;规则可被导出 / 素材 / 脚本共用 | 0.5 |
|
| A4-BE-03 | 命名规则服务 | BE | `R-01` 占位符解析(行业 / 客户 / 方向 / 日期 / 序号),中文 / 字母 / 数字 / 下划线 / 短横线校验,同名自动加序号,≤ 100 字符 | A4-BE-02 | 占位符缺失报错;同名序号连续;规则可被导出 / 素材 / 脚本共用 | 0.5 | ✅ |
|
||||||
| A4-BE-04 | 字典快照 | BE | 字典项被引用时保存填写时快照;字典变更只影响新数据;历史统计按填写时解释(`R-10`) | A4-BE-01 | 改字典名称后历史数据不变;快照查询可复现 | 0.5 |
|
| A4-BE-04 | 字典快照 | BE | 字典项被引用时保存填写时快照;字典变更只影响新数据;历史统计按填写时解释(`R-10`) | A4-BE-01 | 改字典名称后历史数据不变;快照查询可复现 | 0.5 | ✅ |
|
||||||
| A4-FE-01 | 字典管理 | FE | 字典类型列表、条目树 / 词表双视图、行业作用域、排序、启停;编码只读 | A4-BE-04 | 编码不可编辑;树与词表可切换;影响范围提示 | 0.5 |
|
| A4-FE-01 | 字典管理 | FE | 字典类型列表、条目树 / 词表双视图、行业作用域、排序、启停;编码只读 | A4-BE-04 | 编码不可编辑;树与词表可切换;影响范围提示 | 0.5 | ✅ |
|
||||||
| A4-FE-02 | 系统配置页 | FE | 命名规则、导出设置、通知、阈值配置;版本与变更记录 | A4-BE-02 | 保存前展示影响面;阈值修改有二次确认 | 0.5 |
|
| A4-FE-02 | 系统配置页 | FE | 命名规则、导出设置、通知、阈值配置;版本与变更记录 | A4-BE-02 | 保存前展示影响面;阈值修改有二次确认 | 0.5 | ✅ |
|
||||||
| A4-QA-01 | 合并 / 快照 / 同源用例 | QA | 三层合并、编码不可改、快照、阈值前后端一致 | A4-BE-04 | 用例全绿;改字典不影响历史 | 0.5 |
|
| A4-QA-01 | 合并 / 快照 / 同源用例 | QA | 三层合并、编码不可改、快照、阈值前后端一致 | A4-BE-04 | 用例全绿;改字典不影响历史 | 0.5 | ✅ |
|
||||||
|
|
||||||
**本批门禁**:字典三层合并可用;编码不可改;阈值只存一处且前后端一致;配置变更可审计。
|
**本批门禁**:字典三层合并可用;编码不可改;阈值只存一处且前后端一致;配置变更可审计。
|
||||||
|
|
||||||
|
> **A4 完成情况(2026-11-04)**:7/7 项 WBS 全部 ✅。`dict_types` / `dict_items` 通用枚举表(树 / 词表一张表、编码不可改、三层合并、快照契约)、`system_configs` + `system_config_versions`(26 项配置、版本 / 乐观锁 / 前后值 / 审计日志)、命名规则服务(R-01)、阈值同源(roles / users / customers / customer-fields 读配置)全部落地;前端按用户选定 **方案 A 主从分栏**实现 `/system/dicts`、`/system/dicts/:type`、`/system/settings`,侧栏更名「数据字典」。门禁:`format:check / lint / typecheck / test` 全绿(shared 147 / api 137 / web 154,共 438),`test:int` 58 项(7 文件,含 A4 catalog 10 + config 9),A4 浏览器 e2e 7/7(约 1.1m),`build` 8/8,`openapi:export` + `openapi:diff` 无破坏性变更;实现记录、截图与遗留见 [A4 实施报告](./灵犀_A4数据字典与系统配置_实施报告_V0.1.md)。已知遗留:`refCount` 待 B0/B1、A5 审计落库、详情面包屑末级类型名、字典类型 >100 的分页。
|
||||||
|
|
||||||
### 6.6 A5 审计与日志
|
### 6.6 A5 审计与日志
|
||||||
|
|
||||||
**目标**:关键操作全留痕、不可篡改、可检索可导出。**对应**:P-07、PRD §5.4.6;`R-05`;架构 §5.4.5 / §9.7;工程规范 08。
|
**目标**:关键操作全留痕、不可篡改、可检索可导出。**对应**:P-07、PRD §5.4.6;`R-05`;架构 §5.4.5 / §9.7;工程规范 08。
|
||||||
@@ -952,7 +961,7 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 页面 | `/industries`、`/customers`、`/customers/new`(分步)、`/customers/:id`、`/customer-groups`、`/customer-fields/approvals` |
|
| 页面 | `/industries`、`/customers`、`/customers/new`(分步)、`/customers/:id`、`/customer-groups`、`/customer-fields/approvals` |
|
||||||
| 接口 | `GET/POST/PATCH /industries`、`GET/POST/PATCH /customers`、`/customer-groups`、`/customers/{id}/fields`、`/customer-fields/{id}/approve`、`POST /customers/{id}/apply-industry-pack` |
|
| 接口 | `GET/POST/PATCH /industries`、`GET/POST/PATCH /customers`、`/customer-groups`、`/customers/{id}/fields`、`/customer-fields/{id}/approve`、`POST /customers/{id}/apply-industry-pack` |
|
||||||
| 关键规则 | 行业名称全局唯一;客户名称同行业内唯一;**简称必填**(用于全部自动命名);客户必须归属一个行业、不可跨行业移动;暂停只读、结束只读归档(`R-07`);基础字段 ≤ 10 且永不进入生成,进入生成字段 ≤ 5 且必须审批 |
|
| 关键规则 | 行业两级树;一级名称全局唯一、二级同父唯一;客户名称同末级行业唯一;**简称必填**(用于全部自动命名);客户必须归属末级行业、不可跨行业移动;暂停只读、结束只读归档(`R-07`);基础字段 ≤ 10 且永不进入生成,进入生成字段 ≤ 5 且必须审批 |
|
||||||
| 交互要点 | 新建客户为分步表单(行业 → 档案 → 交付偏好 → 资产包),可回退;状态为暂停 / 结束时顶部只读横幅;客户组作为授权单位,批量授权入口清晰 |
|
| 交互要点 | 新建客户为分步表单(行业 → 档案 → 交付偏好 → 资产包),可回退;状态为暂停 / 结束时顶部只读横幅;客户组作为授权单位,批量授权入口清晰 |
|
||||||
| 验收 | ① 重名返回 40902;② 简称缺失 42201;③ 暂停客户不能提交新任务;④ 客户组授权后范围正确;⑤ 字段超限 40001、未审批字段不进入生成 |
|
| 验收 | ① 重名返回 40902;② 简称缺失 42201;③ 暂停客户不能提交新任务;④ 客户组授权后范围正确;⑤ 字段超限 40001、未审批字段不进入生成 |
|
||||||
|
|
||||||
@@ -1232,8 +1241,9 @@ infra/:compose、Nginx、监控、备份脚本占位
|
|||||||
- [ ] 超管首登强制改密;用户 / 角色 / 部门可用;
|
- [ ] 超管首登强制改密;用户 / 角色 / 部门可用;
|
||||||
- [ ] 自定义角色 + 权限点 + 六类数据范围 + 特殊权限可用;
|
- [ ] 自定义角色 + 权限点 + 六类数据范围 + 特殊权限可用;
|
||||||
- [ ] SoD 三对规则生效且有拒绝用例;
|
- [ ] SoD 三对规则生效且有拒绝用例;
|
||||||
- [ ] 行业两级、客户 / 客户组 / 客户端字段审批可用;
|
- [ ] 行业两级树(一级 → 二级,客户挂末级)、客户 / 客户组 / 客户端字段审批可用;
|
||||||
- [ ] 通用字典三层合并、编码不可改、历史快照可用;
|
- [x] 通用字典三层合并、编码不可改、历史快照可用;
|
||||||
|
- [x] 系统配置单一来源、阈值 / 上限同源、配置版本与变更记录可用;
|
||||||
- [ ] 审计七类关键动作留痕、只读、导出留痕;
|
- [ ] 审计七类关键动作留痕、只读、导出留痕;
|
||||||
- [ ] AI 四层配置 + Mock 全链路 + 密钥不回显;
|
- [ ] AI 四层配置 + Mock 全链路 + 密钥不回显;
|
||||||
- [ ] 任务中心可查 / 可取消 / 可重试,幂等生效;
|
- [ ] 任务中心可查 / 可取消 / 可重试,幂等生效;
|
||||||
@@ -1364,7 +1374,7 @@ infra/:compose、Nginx、监控、备份脚本占位
|
|||||||
| catalog | `asset:view` / `asset:edit` / `asset:delete` | 资产(被引用仅停用) |
|
| catalog | `asset:view` / `asset:edit` / `asset:delete` | 资产(被引用仅停用) |
|
||||||
| media | `media:view` / `media:generate` | 任务查看 / 提交(任务中心复用) |
|
| media | `media:view` / `media:generate` | 任务查看 / 提交(任务中心复用) |
|
||||||
| delivery | `export:create`★ | 导出 |
|
| delivery | `export:create`★ | 导出 |
|
||||||
| platform | `dict:view` / `dict:edit` | 字典 |
|
| platform | `dict:manage` | 字典(A2 定稿目录,单权限点覆盖查看 / 编辑) |
|
||||||
| platform | `ai:view` / `ai:configure`★ | AI 配置 |
|
| platform | `ai:view` / `ai:configure`★ | AI 配置 |
|
||||||
| platform | `audit:view`★ / `audit:export`★ | 审计查看 / 导出(均为特殊权限) |
|
| platform | `audit:view`★ / `audit:export`★ | 审计查看 / 导出(均为特殊权限) |
|
||||||
| platform | `system:configure` | 系统配置 |
|
| platform | `system:configure` | 系统配置 |
|
||||||
@@ -1379,11 +1389,11 @@ infra/:compose、Nginx、监控、备份脚本占位
|
|||||||
| `/system/users`、`/system/users/new`、`/system/users/:id` | 用户与部门 | `user:view` / `user:manage` | A1 |
|
| `/system/users`、`/system/users/new`、`/system/users/:id` | 用户与部门 | `user:view` / `user:manage` | A1 |
|
||||||
| `/system/departments` | 部门管理(两级组织树、停用/启用) | `user:view` / `user:manage` | A1 |
|
| `/system/departments` | 部门管理(两级组织树、停用/启用) | `user:view` / `user:manage` | A1 |
|
||||||
| `/system/roles`、`/system/roles/:id` | 角色管理 | `role:view` / `role:manage` | A2 |
|
| `/system/roles`、`/system/roles/:id` | 角色管理 | `role:view` / `role:manage` | A2 |
|
||||||
| `/system/industries` | 行业管理 | `industry_pack:view` / `customer:manage` | A3 |
|
| `/system/industries` | 行业管理 | `customer:read` / `customer:manage` | A3 |
|
||||||
| `/system/customers`、`/system/customers/new`、`/system/customers/:id` | 客户管理 | `customer:view` / `customer:manage` | A3 |
|
| `/system/customers`、`/system/customers/new`、`/system/customers/:id` | 客户管理 | `customer:view` / `customer:manage` | A3 |
|
||||||
| `/system/customer-groups` | 客户组 | `customer_group:manage` | A3 |
|
| `/system/customer-groups` | 客户组 | `customer_group:manage` | A3 |
|
||||||
| `/system/customer-fields/approvals` | 字段审批 | `customer_field:approve` | A3 |
|
| `/system/customer-fields/approvals` | 字段审批 | `customer_field:approve` | A3 |
|
||||||
| `/system/dicts`、`/system/dicts/:type` | 字典 | `dict:view` / `dict:edit` | A4 |
|
| `/system/dicts`、`/system/dicts/:type` | 字典 | `dict:manage` | A4 |
|
||||||
| `/system/settings` | 系统配置 | `system:configure` | A4 |
|
| `/system/settings` | 系统配置 | `system:configure` | A4 |
|
||||||
| `/system/audit-logs`、`/system/audit-logs/:id` | 审计 | `audit:view` | A5 |
|
| `/system/audit-logs`、`/system/audit-logs/:id` | 审计 | `audit:view` | A5 |
|
||||||
| `/material/tasks`、`/material/tasks/:id` | 任务中心 | `media:view` / `media:generate` | A6 |
|
| `/material/tasks`、`/material/tasks/:id` | 任务中心 | `media:view` / `media:generate` | A6 |
|
||||||
|
|||||||
@@ -456,7 +456,7 @@ PRD 5.4.4 要求"多供应商并存、按用途路由、额度预算、健康降
|
|||||||
| `script` | 5.1 | 要素卡、卖点库、脚本模板、批量生成、质量校核、审核入库、版本 | brief_cards / brief_card_index / selling_points / script_templates / scripts / script_versions | 脚本生成、校核、入库 |
|
| `script` | 5.1 | 要素卡、卖点库、脚本模板、批量生成、质量校核、审核入库、版本 | brief_cards / brief_card_index / selling_points / script_templates / scripts / script_versions | 脚本生成、校核、入库 |
|
||||||
| `media` | 5.2 | 图片线、视频线、任务与任务项、分镜、快筛、预览、失败与成本 | media_tasks / media_items / shots / render_profiles / preview_marks | 任务编排、产物管理 |
|
| `media` | 5.2 | 图片线、视频线、任务与任务项、分镜、快筛、预览、失败与成本 | media_tasks / media_items / shots / render_profiles / preview_marks | 任务编排、产物管理 |
|
||||||
| `delivery` | 5.5 | 导出、命名规则、交付清单、归档、下载签名 | exports / export_items / delivery_manifests | 打包、命名、审计 |
|
| `delivery` | 5.5 | 导出、命名规则、交付清单、归档、下载签名 | exports / export_items / delivery_manifests | 打包、命名、审计 |
|
||||||
| `platform` | 5.4.4 / 5.4.6 | AI 配置(模型/路由/提示词/额度)、AI 调用账本、审计、系统配置、成本看板 | ai_providers / ai_routes / ai_prompts / ai_usage / audit_logs / system_configs / cost_budgets | AI 适配层入口、审计写入、成本聚合 |
|
| `platform` | 5.4.4 / 5.4.6 | AI 配置(模型/路由/提示词/额度)、AI 调用账本、审计、系统配置、成本看板 | ai_providers / ai_routes / ai_prompts / ai_usage / audit_logs / system_configs / system_config_versions / cost_budgets | AI 适配层入口、审计写入、成本聚合 |
|
||||||
|
|
||||||
**共享内核**(`packages/shared`,不拥有表):领域事件定义、错误码、权限点常量、Zod schema、分页/游标、ID 生成、时间与命名规则工具。
|
**共享内核**(`packages/shared`,不拥有表):领域事件定义、错误码、权限点常量、Zod schema、分页/游标、ID 生成、时间与命名规则工具。
|
||||||
|
|
||||||
@@ -830,7 +830,7 @@ erDiagram
|
|||||||
| 组织权限 | `role_permissions` | 角色 → 权限点 | 复合主键 |
|
| 组织权限 | `role_permissions` | 角色 → 权限点 | 复合主键 |
|
||||||
| 组织权限 | `user_roles` | 用户 → 角色(可多) | 复合主键 |
|
| 组织权限 | `user_roles` | 用户 → 角色(可多) | 复合主键 |
|
||||||
| 组织权限 | `data_scopes` | 角色数据范围 | `scope_type` + 目标 ID |
|
| 组织权限 | `data_scopes` | 角色数据范围 | `scope_type` + 目标 ID |
|
||||||
| 客户与行业 | `industries` | 行业目录(业务属性) | 名称全局唯一 |
|
| 客户与行业 | `industries` | 行业目录(两级树:一级 → 二级) | 一级名称全局唯一;二级同一父级下唯一;最多两级 |
|
||||||
| 客户与行业 | `customers` | 客户 | `(industry_id, name)` 唯一;`short_name` 必填 |
|
| 客户与行业 | `customers` | 客户 | `(industry_id, name)` 唯一;`short_name` 必填 |
|
||||||
| 客户与行业 | `customer_groups` | 客户组 | 授权与范围最小单位 |
|
| 客户与行业 | `customer_groups` | 客户组 | 授权与范围最小单位 |
|
||||||
| 客户与行业 | `customer_group_members` | 客户组 ↔ 客户 | 复合主键 |
|
| 客户与行业 | `customer_group_members` | 客户组 ↔ 客户 | 复合主键 |
|
||||||
@@ -888,6 +888,7 @@ erDiagram
|
|||||||
| 平台 | `cost_budgets` | 预算与告警阈值 | 按角色/客户/月 |
|
| 平台 | `cost_budgets` | 预算与告警阈值 | 按角色/客户/月 |
|
||||||
| 平台 | `audit_logs` | 审计(append-only) | 按月分区,禁 UPDATE/DELETE |
|
| 平台 | `audit_logs` | 审计(append-only) | 按月分区,禁 UPDATE/DELETE |
|
||||||
| 平台 | `system_configs` | 系统配置(命名、导出、通知) | 带版本与变更审计 |
|
| 平台 | `system_configs` | 系统配置(命名、导出、通知) | 带版本与变更审计 |
|
||||||
|
| 平台 | `system_config_versions` | 系统配置变更历史(append-only:前后值 / 原因 / 操作人) | A4 新增;配置版本与审计可查 |
|
||||||
| 平台 | `outbox_events` | 事务性事件投递 | 已投递标记 |
|
| 平台 | `outbox_events` | 事务性事件投递 | 已投递标记 |
|
||||||
| 平台 | `idempotency_keys` | 请求幂等 | TTL 24 小时 |
|
| 平台 | `idempotency_keys` | 请求幂等 | TTL 24 小时 |
|
||||||
|
|
||||||
@@ -900,7 +901,7 @@ erDiagram
|
|||||||
| **字段** | **类型** | **说明** |
|
| **字段** | **类型** | **说明** |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | ulid PK | — |
|
| `id` | ulid PK | — |
|
||||||
| `industry_id` | ulid FK → industries | 不可为空;跨行业移动禁止(应用层 + 触发器可选) |
|
| `industry_id` | ulid FK → industries | 不可为空;必须指向**末级行业**(有子行业的一级行业不可选);跨行业移动禁止(应用层 + 触发器可选) |
|
||||||
| `name` | text | 同行业内唯一 |
|
| `name` | text | 同行业内唯一 |
|
||||||
| `short_name` | text | **必填**,用于自动命名,需满足通用字符规则 |
|
| `short_name` | text | **必填**,用于自动命名,需满足通用字符规则 |
|
||||||
| `status` | enum | 在服务 / 暂停 / 结束 |
|
| `status` | enum | 在服务 / 暂停 / 结束 |
|
||||||
@@ -1102,7 +1103,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
| **方法** | **路径** | **权限点** | **说明** |
|
| **方法** | **路径** | **权限点** | **说明** |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| GET/POST/PATCH | `/industries`、`/industries/{id}` | `industry_pack:view` / `customer:manage` | 行业目录;删除前校验 |
|
| GET/POST/PATCH | `/industries`、`/industries/{id}` | `customer:read` / `customer:manage` | 两级行业树;删除前校验无子行业 / 客户 / 资产;有启用子行业禁止停用 |
|
||||||
| GET/POST/PATCH | `/customers`、`/customers/{id}` | `customer:view` / `customer:manage` | 客户;`short_name` 必填 |
|
| GET/POST/PATCH | `/customers`、`/customers/{id}` | `customer:view` / `customer:manage` | 客户;`short_name` 必填 |
|
||||||
| GET/POST | `/customer-groups`、`/customer-groups/{id}/members` | `customer_group:manage` | 客户组与成员 |
|
| GET/POST | `/customer-groups`、`/customer-groups/{id}/members` | `customer_group:manage` | 客户组与成员 |
|
||||||
| POST | `/customers/{id}/apply-industry-pack` | `customer:manage` | 套用行业资产包(冷启动) |
|
| POST | `/customers/{id}/apply-industry-pack` | `customer:manage` | 套用行业资产包(冷启动) |
|
||||||
@@ -1187,7 +1188,7 @@ sequenceDiagram
|
|||||||
| GET/POST/PATCH | `/ai/providers`、`/ai/routes`、`/ai/prompts` | `ai:view` / `ai:configure` | 模型、路由、提示词 |
|
| GET/POST/PATCH | `/ai/providers`、`/ai/routes`、`/ai/prompts` | `ai:view` / `ai:configure` | 模型、路由、提示词 |
|
||||||
| GET/PUT | `/ai/budgets` | `ai:configure` | 额度与预算 |
|
| GET/PUT | `/ai/budgets` | `ai:configure` | 额度与预算 |
|
||||||
| GET | `/ai/usage` | `ai:view` | 账本查询(按客户 / 模型 / 批次) |
|
| GET | `/ai/usage` | `ai:view` | 账本查询(按客户 / 模型 / 批次) |
|
||||||
| GET/POST/PATCH | `/dicts`、`/dicts/{type}/items` | `dict:view` / `dict:edit` | 字典 |
|
| GET/POST/PATCH | `/dicts`、`/dicts/{type}/items` | `dict:manage` | 字典(A2 定稿的 30 个权限点中字典为单个 `dict:manage`,见 ADR-001) |
|
||||||
| GET | `/audit-logs` | `audit:view` | 审计检索(只读) |
|
| GET | `/audit-logs` | `audit:view` | 审计检索(只读) |
|
||||||
| POST | `/audit-logs/export` | `audit:export` | 审计导出(自身也审计) |
|
| POST | `/audit-logs/export` | `audit:export` | 审计导出(自身也审计) |
|
||||||
| GET/PUT | `/system/configs` | `system:configure` | 命名规则、导出设置、通知 |
|
| GET/PUT | `/system/configs` | `system:configure` | 命名规则、导出设置、通知 |
|
||||||
@@ -2003,6 +2004,7 @@ lingxi/
|
|||||||
| V0.4 | 2026-11-02 | 图片线改为确定性流水线(依据素材工坊 `sucaigongfang` 实测实现):`image:matting` / `image:render` / `delivery:zip` 三队列、PSD 图层命名约定与解析入库校验、ONNX 抠图与低质量降级、canvas 合成与字体自检、批次参数与命名快照、模板与批次表(`templates` / `template_versions` / `production_batches` / `production_tasks`);3.4 / 7.3 重写、新增 8.11 D-11、R-T-17~R-T-19、附录 F 补素材工坊行;需求基线对齐 PRD V1.6 |
|
| V0.4 | 2026-11-02 | 图片线改为确定性流水线(依据素材工坊 `sucaigongfang` 实测实现):`image:matting` / `image:render` / `delivery:zip` 三队列、PSD 图层命名约定与解析入库校验、ONNX 抠图与低质量降级、canvas 合成与字体自检、批次参数与命名快照、模板与批次表(`templates` / `template_versions` / `production_batches` / `production_tasks`);3.4 / 7.3 重写、新增 8.11 D-11、R-T-17~R-T-19、附录 F 补素材工坊行;需求基线对齐 PRD V1.6 |
|
||||||
| V0.3 | 2026-11-02 | 吸收 baoliangmofang:素材为主对象 + 规格为属性(`materials` / `material_versions` / `material_specs` / `material_files` / `material_links` / `upload_sessions`);分片直传 + hash 秒传 + 短时效签名 URL + `StorageProvider` 接口(2.5 / 7.9);送审与外部提审登记、分享链接安全口径(`share_links`);投放登记与日指标(`placements` / `material_metrics_daily`)、阈值同源与派生指标不落库(5.7);新增 `media:post` / `embed:index` 队列、素材端点 13 个、3.6 素材与送审状态机、7.9 入库转码链路、R-T-15 / R-T-16、附录 F 补 6 行;向量检索改为「M3 起 pgvector、访问收敛 VectorStore」;需求基线对齐 PRD V1.5 |
|
| V0.3 | 2026-11-02 | 吸收 baoliangmofang:素材为主对象 + 规格为属性(`materials` / `material_versions` / `material_specs` / `material_files` / `material_links` / `upload_sessions`);分片直传 + hash 秒传 + 短时效签名 URL + `StorageProvider` 接口(2.5 / 7.9);送审与外部提审登记、分享链接安全口径(`share_links`);投放登记与日指标(`placements` / `material_metrics_daily`)、阈值同源与派生指标不落库(5.7);新增 `media:post` / `embed:index` 队列、素材端点 13 个、3.6 素材与送审状态机、7.9 入库转码链路、R-T-15 / R-T-16、附录 F 补 6 行;向量检索改为「M3 起 pgvector、访问收敛 VectorStore」;需求基线对齐 PRD V1.5 |
|
||||||
| V0.1.4 | 2026-11-02 | React 19 的 Semi 包名改为 **`@douyinfe/semi-ui-19`**(禁与 `@douyinfe/semi-ui` 混装,ESLint 强制);明确入口仍需 `import '@douyinfe/semi-ui-19/react19-adapter'`(React 19 已移除 `react-dom` 的 `createRoot` 导出) |
|
| V0.1.4 | 2026-11-02 | React 19 的 Semi 包名改为 **`@douyinfe/semi-ui-19`**(禁与 `@douyinfe/semi-ui` 混装,ESLint 强制);明确入口仍需 `import '@douyinfe/semi-ui-19/react19-adapter'`(React 19 已移除 `react-dom` 的 `createRoot` 导出) |
|
||||||
|
| V0.4(A4 回写) | 2026-11-04 | A4 数据字典与系统配置:新增 `system_config_versions`(配置变更前后值 / 版本 / 审计);字典权限按 A2 定稿统一为 `dict:manage`(ADR-001);明确字典通配表 `dict_types` / `dict_items` 与三层合并、R-10 快照口径 |
|
||||||
|
|
||||||
## 待办
|
## 待办
|
||||||
|
|
||||||
|
|||||||
+50
-1
@@ -3,10 +3,12 @@
|
|||||||
## 现有用例
|
## 现有用例
|
||||||
|
|
||||||
| 文件 | 说明 | 默认是否执行 |
|
| 文件 | 说明 | 默认是否执行 |
|
||||||
| --------------------- | --------------------------------------------- | ----------------- |
|
| --------------------------- | ------------------------------------------------------------------------ | ----------------- |
|
||||||
| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 |
|
| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 |
|
||||||
| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` |
|
| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` |
|
||||||
| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` |
|
| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` |
|
||||||
|
| `a3-client.spec.ts` | A3-QA-01 客户与行业六页可访问 / 数据加载 | 否,需 `A3_E2E=1` |
|
||||||
|
| `a4-dicts-settings.spec.ts` | A4-QA-01 数据字典 / 三层合并 / 编码只读 / 系统配置 / 阈值确认 / 变更记录 | 否,需 `A4_E2E=1` |
|
||||||
|
|
||||||
## A1 账号生命周期用例
|
## A1 账号生命周期用例
|
||||||
|
|
||||||
@@ -60,3 +62,50 @@ A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts --reporter=list
|
|||||||
- 可选覆盖:`A2_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A2_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)、`A2_E2E_ACTOR_ACCOUNT` / `A2_E2E_ACTOR_PASSWORD`(缺省 `jiang.lan` / `QaE2eAdmin2026`,首跑用种子口令 `lingxi2026` 自动完成首登改密)。
|
- 可选覆盖:`A2_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A2_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)、`A2_E2E_ACTOR_ACCOUNT` / `A2_E2E_ACTOR_PASSWORD`(缺省 `jiang.lan` / `QaE2eAdmin2026`,首跑用种子口令 `lingxi2026` 自动完成首登改密)。
|
||||||
- 截图:`design/a2-ui-v1/shots/impl/e2e/`(`01-roles-list` … `11-roles-list-disabled`,共 11 张 1440×900)。
|
- 截图:`design/a2-ui-v1/shots/impl/e2e/`(`01-roles-list` … `11-roles-list-disabled`,共 11 张 1440×900)。
|
||||||
- 说明:用户按 A1 口径只停用不删除(用户不可删);角色在解绑后删除,可重复运行。
|
- 说明:用户按 A1 口径只停用不删除(用户不可删);角色在解绑后删除,可重复运行。
|
||||||
|
|
||||||
|
## A3 客户与行业用例(A3-QA-01)
|
||||||
|
|
||||||
|
覆盖:登录注入 → 行业管理 / 客户列表 / 新建客户 / 客户组 / 字段审批五页标题 → 客户列表有数据 → 点击行进入客户详情深链。
|
||||||
|
|
||||||
|
### 前置与运行
|
||||||
|
|
||||||
|
1. API 3000 + Web 5173(`pnpm dev`,真实 PG;已执行 `pnpm db:init`)。
|
||||||
|
2. 用例自建临时超管 `qa3.e2e`(Prisma 写入,结束后删除),不依赖本地账号密码。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list
|
||||||
|
```
|
||||||
|
|
||||||
|
- 可选覆盖:`A3_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A3_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)。
|
||||||
|
- 截图:`design/a3-ui-v1/impl/`(实施报告引用)。
|
||||||
|
|
||||||
|
## A4 数据字典与系统配置用例(A4-QA-01)
|
||||||
|
|
||||||
|
覆盖 7 条:
|
||||||
|
|
||||||
|
1. `/system/dicts` 主从页:h1、左侧「字典类型」、右栏 `script_direction` 类型头、三层 chips(通用 5 + 父行业 2 + 本行业 1 = 生效 7)、默认行业「电商 / 服饰鞋包」、种草短视频(已覆盖)/ 直播切片 / 穿搭教程;树 ↔ 词表切换(`?view=tree|list`);行业作用域切全平台(生效 5)再切回。
|
||||||
|
2. 条目:新增 SideSheet → 列表出现 → 编辑态编码 disabled 且值锁定 → Modal.confirm 停用 / 启用 → 行状态变化。
|
||||||
|
3. 类型:新建(`a4e2e_` 唯一 code + 名称 + 分组 + 形态树)→ 左侧类型列表可见 → 编辑类型编码 / 形态只读。
|
||||||
|
4. `/system/settings`:6 组导航、6 个占位符 chips、实时预览「电商-花间集-口播-20261104-01」、模板行内编辑与放弃恢复。
|
||||||
|
5. 阈值:客户基础字段上限改 12 → 保存全部更改 → 二次确认 → toast / Prisma 断言版本 +1 / 变更记录抽屉含前后值与原因。
|
||||||
|
6. 只读态:无 `dict:manage` / `system:configure` 账号访问两页 → `/403`。
|
||||||
|
7. 未登录访问 `/system/dicts` → 重定向登录页。
|
||||||
|
|
||||||
|
### 前置与运行
|
||||||
|
|
||||||
|
1. API 3000 + Web 5173(`pnpm dev`,真实 PG;已执行 `pnpm db:init`)。
|
||||||
|
2. 用例自建临时超管 `qa4.dicts.e2e` 与只读账号 `qa4.dicts.ro`(Prisma 写入,结束后删除);字典类型 / 条目按 `a4e2e_` 前缀清理;系统配置保存后恢复原值并删除本次 history。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts --reporter=list
|
||||||
|
```
|
||||||
|
|
||||||
|
- 可选覆盖:`A4_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A4_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)。
|
||||||
|
- 结果:7 passed(约 1.1m)。
|
||||||
|
- 截图:不生成常驻截图;失败时 Playwright 默认 `only-on-failure` 落 `test-results/`。
|
||||||
|
|
||||||
|
### 选择器校准(Semi)
|
||||||
|
|
||||||
|
- Semi Select 选项可访问名含图标 aria-label(tick),精确匹配不到,使用 `.semi-select-option` 文本点击。
|
||||||
|
- `Modal.confirm` 的确认 / 取消按钮 `aria-label` 固定为 `confirm` / `cancel`,不要按 `okText` 文本定位。
|
||||||
|
- `RadioGroup` 隐藏 input 被 addon 拦截,点可见文本或 `.semi-radio-addon-buttonRadio`。
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
/**
|
||||||
|
* A3-QA-01 · 客户与行业目录端到端用例(真实 API + 真实 PG + 真实浏览器)
|
||||||
|
*
|
||||||
|
* 覆盖:登录 → 行业管理树/详情 → 客户列表 → 客户详情 → 新建客户向导 → 客户组 → 字段审批。
|
||||||
|
* 门控:仅 A3_E2E=1 时执行;默认不跑,避免影响 CI / smoke。
|
||||||
|
*
|
||||||
|
* 前置:API 3000、Web 5173(`pnpm dev`);用例自建 `qa3.e2e` 超管账号并在结束后删除。
|
||||||
|
* 运行:A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list
|
||||||
|
*/
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const API_BASE = process.env.A3_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1';
|
||||||
|
const WEB_BASE = process.env.A3_E2E_BASE_URL ?? 'http://127.0.0.1:5173';
|
||||||
|
const ENABLED = process.env.A3_E2E === '1';
|
||||||
|
const PASSWORD = 'lingxi2026';
|
||||||
|
const PASSWORD_HASH =
|
||||||
|
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||||
|
const ACCOUNT = 'qa3.e2e';
|
||||||
|
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
function ulid(): string {
|
||||||
|
let time = Date.now();
|
||||||
|
const timeChars = new Array<string>(10);
|
||||||
|
for (let index = 9; index >= 0; index -= 1) {
|
||||||
|
timeChars[index] = ENCODING[time % 32] ?? '0';
|
||||||
|
time = Math.floor(time / 32);
|
||||||
|
}
|
||||||
|
const random = randomBytes(16);
|
||||||
|
return `${timeChars.join('')}${Array.from(random, (byte) => ENCODING[byte & 31] ?? '0').join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('A3 客户与行业目录', () => {
|
||||||
|
test.skip(!ENABLED, 'A3_E2E=1 时执行;需要 API 3000 + Web 5173');
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
const [role, department] = await Promise.all([
|
||||||
|
prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } }),
|
||||||
|
prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
|
||||||
|
]);
|
||||||
|
await prisma.user.deleteMany({ where: { account: ACCOUNT } });
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: ulid(),
|
||||||
|
name: 'A3 端到端',
|
||||||
|
account: ACCOUNT,
|
||||||
|
email: `${ACCOUNT}@lingxi.cn`,
|
||||||
|
phone: '13500008888',
|
||||||
|
passwordHash: PASSWORD_HASH,
|
||||||
|
departmentId: department.id,
|
||||||
|
mustChangePassword: false,
|
||||||
|
roles: { create: [{ roleId: role.id }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await prisma.user.deleteMany({ where: { account: ACCOUNT } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('行业 / 客户 / 客户组 / 字段审批六页可访问且数据加载', async ({ page, request }) => {
|
||||||
|
const login = await request.post(`${API_BASE}/auth/login`, {
|
||||||
|
data: { account: ACCOUNT, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(login.ok()).toBe(true);
|
||||||
|
const session = (await login.json()) as { accessToken: string; refreshToken: string };
|
||||||
|
const meResponse = await request.get(`${API_BASE}/me`, {
|
||||||
|
headers: { authorization: `Bearer ${session.accessToken}` },
|
||||||
|
});
|
||||||
|
expect(meResponse.ok()).toBe(true);
|
||||||
|
const me = (await meResponse.json()) as {
|
||||||
|
user: { id: string; name: string; account: string };
|
||||||
|
roles: { name: string }[];
|
||||||
|
permissions: string[];
|
||||||
|
};
|
||||||
|
await page.addInitScript(
|
||||||
|
(value) => {
|
||||||
|
window.localStorage.setItem('lingxi.auth.session', JSON.stringify(value));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
refreshToken: session.refreshToken,
|
||||||
|
user: {
|
||||||
|
id: me.user.id,
|
||||||
|
name: me.user.name,
|
||||||
|
account: me.user.account,
|
||||||
|
roleName: me.roles[0]?.name,
|
||||||
|
},
|
||||||
|
permissions: me.permissions,
|
||||||
|
mustChangePassword: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const pages = [
|
||||||
|
{ path: '/system/industries', title: '行业管理' },
|
||||||
|
{ path: '/system/customers', title: '客户管理' },
|
||||||
|
{ path: '/system/customers/new', title: '新建客户' },
|
||||||
|
{ path: '/system/customer-groups', title: '客户组' },
|
||||||
|
{ path: '/system/customer-fields/approvals', title: '客户字段审批' },
|
||||||
|
];
|
||||||
|
for (const item of pages) {
|
||||||
|
await page.goto(`${WEB_BASE}${item.path}`, { waitUntil: 'networkidle' });
|
||||||
|
await expect(page.locator('h1').first()).toHaveText(item.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`${WEB_BASE}/system/customers`, { waitUntil: 'networkidle' });
|
||||||
|
const customerRows = page.locator('.lx-client-table tbody tr');
|
||||||
|
const count = await customerRows.count();
|
||||||
|
expect(count).toBeGreaterThan(0);
|
||||||
|
await customerRows.first().click();
|
||||||
|
await expect(page).toHaveURL(/\/system\/customers\/[A-Z0-9]+$/);
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
/**
|
||||||
|
* A4-QA-01 · 数据字典与系统配置端到端用例(真实 API + 真实 PG + 真实浏览器)
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* ① /system/dicts:h1「数据字典」、左侧「字典类型」、右栏类型头、三层 chips、树 / 词表切换、行业作用域;
|
||||||
|
* ② 默认类型 script_direction + 行业视角「电商 / 服饰鞋包」→ 生效 7 项,并可见「种草短视频 / 直播切片 /
|
||||||
|
* 穿搭教程」与「已覆盖」标签;
|
||||||
|
* ③ 新增条目 → 列表出现 → 打开编辑时编码只读 → 停用 / 启用(Modal.confirm)→ 行状态变化;
|
||||||
|
* ④ 新建字典类型(编码前缀 a4e2e_)→ 类型列表可见 → 打开编辑验证编码只读;
|
||||||
|
* ⑤ /system/settings:h1「系统配置」、左侧 6 组、命名规则占位符 chips 与实时预览、模板行内编辑;
|
||||||
|
* ⑥ 修改「客户基础字段上限」为 12 → 保存全部更改 → 二次确认 → toast / 版本 +1 / 变更记录抽屉。
|
||||||
|
* ⑦ 无 dict:manage / system:configure 的账号访问 /system/dicts 与 /system/settings → 403;
|
||||||
|
* ⑧ 未登录访问 /system/dicts → 重定向登录页。
|
||||||
|
*
|
||||||
|
* 门控:仅 A4_E2E=1 时执行;默认 skip,不影响 smoke / CI。
|
||||||
|
* 前置:API 3000、Web 5173(`pnpm dev`,真实 PG + Redis,已 `pnpm db:init`)。
|
||||||
|
* 数据自建自清:临时账号 qa4.dicts.e2e(超管)+ qa4.dicts.ro(素材运营,无字典 / 配置权限),
|
||||||
|
* 用例结束删除;字典条目 / 类型按 `a4e2e_` 前缀清理;系统配置保存后通过 Prisma 恢复原值并删除本次历史行。
|
||||||
|
*
|
||||||
|
* 运行:A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts --reporter=list
|
||||||
|
*/
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
import { type APIRequestContext, expect, type Page, request, test } from '@playwright/test';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const API_BASE = process.env.A4_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1';
|
||||||
|
const WEB_BASE = process.env.A4_E2E_BASE_URL ?? 'http://127.0.0.1:5173';
|
||||||
|
const ENABLED = process.env.A4_E2E === '1';
|
||||||
|
|
||||||
|
const PASSWORD = 'lingxi2026';
|
||||||
|
/** 与 a3 / a1 共用的 argon2id 哈希(明文 lingxi2026) */
|
||||||
|
const PASSWORD_HASH =
|
||||||
|
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||||
|
const ADMIN_ACCOUNT = 'qa4.dicts.e2e';
|
||||||
|
const READONLY_ACCOUNT = 'qa4.dicts.ro';
|
||||||
|
|
||||||
|
/** 类型 / 条目编码前缀:用于自清理与识别异常中断遗留数据 */
|
||||||
|
const TYPE_CODE_PREFIX = 'a4e2e_';
|
||||||
|
const ITEM_CODE_PREFIX = 'a4e2e_item_';
|
||||||
|
|
||||||
|
const SEED_BASE_FIELD_LIMIT = 10;
|
||||||
|
const BASE_FIELD_KEY = 'limit.customer.base_field';
|
||||||
|
const SAVE_REASON = '在系统配置页调整';
|
||||||
|
|
||||||
|
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
let apiContext: APIRequestContext | null = null;
|
||||||
|
let adminSession: QaSession | null = null;
|
||||||
|
let readonlySession: QaSession | null = null;
|
||||||
|
let bootstrapError: string | null = null;
|
||||||
|
|
||||||
|
let baseFieldOriginalValue = SEED_BASE_FIELD_LIMIT;
|
||||||
|
let baseFieldBeforeVersion = 1;
|
||||||
|
let baseFieldAfterVersion: number | null = null;
|
||||||
|
|
||||||
|
/** 本次运行创建的字典数据(失败时 afterAll 兜底清理) */
|
||||||
|
let createdItemId: string | null = null;
|
||||||
|
let createdTypeCode: string | null = null;
|
||||||
|
|
||||||
|
interface QaSession {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: { id: string; name: string; account: string; roleName?: string };
|
||||||
|
permissions: string[];
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.skip(!ENABLED, 'A4_E2E=1 才运行数据字典 / 系统配置 e2e(依赖 API :3000 + web :5173 + PG)');
|
||||||
|
|
||||||
|
test.use({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 } });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 基础工具
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function randomSuffix(): string {
|
||||||
|
return randomBytes(3).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ulid(): string {
|
||||||
|
let time = Date.now();
|
||||||
|
const timeChars = new Array<string>(10);
|
||||||
|
for (let index = 9; index >= 0; index -= 1) {
|
||||||
|
timeChars[index] = ENCODING[time % 32] ?? '0';
|
||||||
|
time = Math.floor(time / 32);
|
||||||
|
}
|
||||||
|
const random = randomBytes(16);
|
||||||
|
return `${timeChars.join('')}${Array.from(random, (byte) => ENCODING[byte & 31] ?? '0').join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${label} 不是 JSON 对象`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireString(source: Record<string, unknown>, key: string, label: string): string {
|
||||||
|
const value = source[key];
|
||||||
|
if (typeof value !== 'string' || value.length === 0) {
|
||||||
|
throw new Error(`${label}.${key} 缺失或不是字符串`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArray(value: unknown): string[] {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.filter((item): item is string => typeof item === 'string')
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireApi(): APIRequestContext {
|
||||||
|
if (apiContext === null) {
|
||||||
|
throw new Error('APIRequestContext 未初始化');
|
||||||
|
}
|
||||||
|
return apiContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdminSession(): QaSession {
|
||||||
|
if (adminSession === null) {
|
||||||
|
throw new Error('超管会话未就绪');
|
||||||
|
}
|
||||||
|
return adminSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireReadonlySession(): QaSession {
|
||||||
|
if (readonlySession === null) {
|
||||||
|
throw new Error('只读账号会话未就绪');
|
||||||
|
}
|
||||||
|
return readonlySession;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把登录态写入 localStorage(auth-store 的持久化 key) */
|
||||||
|
async function injectSession(page: Page, session: QaSession): Promise<void> {
|
||||||
|
const persisted = {
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
refreshToken: session.refreshToken,
|
||||||
|
user: session.user,
|
||||||
|
permissions: session.permissions,
|
||||||
|
mustChangePassword: session.mustChangePassword,
|
||||||
|
};
|
||||||
|
await page.addInitScript((value: unknown) => {
|
||||||
|
window.localStorage.setItem('lingxi.auth.session', JSON.stringify(value));
|
||||||
|
}, persisted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginViaApi(account: string): Promise<QaSession> {
|
||||||
|
const context = requireApi();
|
||||||
|
const login = await context.post(`${API_BASE}/auth/login`, {
|
||||||
|
data: { account, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(login.status(), `POST /auth/login ${account}`).toBe(200);
|
||||||
|
const body = requireRecord((await login.json()) as unknown, '登录响应');
|
||||||
|
const accessToken = requireString(body, 'accessToken', '登录响应');
|
||||||
|
const refreshToken = requireString(body, 'refreshToken', '登录响应');
|
||||||
|
const meResponse = await context.get(`${API_BASE}/me`, {
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
expect(meResponse.status(), `GET /me ${account}`).toBe(200);
|
||||||
|
const me = requireRecord((await meResponse.json()) as unknown, '/me 响应');
|
||||||
|
const user = requireRecord(me.user, '/me.user');
|
||||||
|
const roles = Array.isArray(me.roles) ? me.roles : [];
|
||||||
|
const firstRole = isRecord(roles[0]) ? roles[0] : null;
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: {
|
||||||
|
id: requireString(user, 'id', '/me.user'),
|
||||||
|
name: requireString(user, 'name', '/me.user'),
|
||||||
|
account: requireString(user, 'account', '/me.user'),
|
||||||
|
...(firstRole === null ? {} : { roleName: requireString(firstRole, 'name', '/me.roles[0]') }),
|
||||||
|
},
|
||||||
|
permissions: stringArray(me.permissions),
|
||||||
|
mustChangePassword: me.mustChangePassword === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(account: string, name: string, roleCode: string): Promise<void> {
|
||||||
|
const [role, department] = await Promise.all([
|
||||||
|
prisma.role.findUniqueOrThrow({ where: { code: roleCode } }),
|
||||||
|
prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
|
||||||
|
]);
|
||||||
|
await prisma.user.deleteMany({ where: { account } });
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: ulid(),
|
||||||
|
name,
|
||||||
|
account,
|
||||||
|
email: `${account}@lingxi.cn`,
|
||||||
|
phone: `139${randomBytes(4).readUInt32BE(0).toString().padStart(8, '0').slice(-8)}`,
|
||||||
|
passwordHash: PASSWORD_HASH,
|
||||||
|
departmentId: department.id,
|
||||||
|
mustChangePassword: false,
|
||||||
|
roles: { create: [{ roleId: role.id }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理异常中断运行遗留的字典数据(前缀匹配,避免影响 seed 与其他用例) */
|
||||||
|
async function cleanupDictLeftovers(): Promise<void> {
|
||||||
|
const staleTypes = await prisma.dictType.findMany({
|
||||||
|
where: { code: { startsWith: TYPE_CODE_PREFIX } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (staleTypes.length > 0) {
|
||||||
|
await prisma.dictItem.deleteMany({
|
||||||
|
where: { typeId: { in: staleTypes.map((row) => row.id) } },
|
||||||
|
});
|
||||||
|
await prisma.dictType.deleteMany({ where: { id: { in: staleTypes.map((row) => row.id) } } });
|
||||||
|
}
|
||||||
|
await prisma.dictItem.deleteMany({ where: { code: { startsWith: ITEM_CODE_PREFIX } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取并记录「客户基础字段上限」的原始值 / 版本;异常遗留值先恢复 seed 默认值 */
|
||||||
|
async function prepareBaseFieldConfig(): Promise<void> {
|
||||||
|
const row = await prisma.systemConfig.findUnique({ where: { configKey: BASE_FIELD_KEY } });
|
||||||
|
const original = typeof row?.configValue === 'number' ? row.configValue : SEED_BASE_FIELD_LIMIT;
|
||||||
|
baseFieldOriginalValue = original;
|
||||||
|
baseFieldBeforeVersion = row?.version ?? 1;
|
||||||
|
if (original !== SEED_BASE_FIELD_LIMIT) {
|
||||||
|
// 上次运行可能异常中断;恢复 seed 默认值,保证本次「改成 12」一定产生变更。
|
||||||
|
await prisma.systemConfig.update({
|
||||||
|
where: { configKey: BASE_FIELD_KEY },
|
||||||
|
data: { configValue: SEED_BASE_FIELD_LIMIT },
|
||||||
|
});
|
||||||
|
baseFieldOriginalValue = SEED_BASE_FIELD_LIMIT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DictTypeRow {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readDictTypeByCode(code: string): Promise<DictTypeRow | null> {
|
||||||
|
return prisma.dictType.findUnique({ where: { code }, select: { id: true, code: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 浏览器 / 页面辅助
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function openDicts(page: Page): Promise<void> {
|
||||||
|
await injectSession(page, requireAdminSession());
|
||||||
|
await page.goto('/system/dicts');
|
||||||
|
await expect(page.getByRole('heading', { name: '数据字典' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('app-shell')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gotoSystem(page: Page, path: string): Promise<void> {
|
||||||
|
await injectSession(page, requireAdminSession());
|
||||||
|
await page.goto(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dictItemRow(page: Page, text: string) {
|
||||||
|
return page.locator('.lx-dict-table tbody tr', { hasText: text });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择 Semi Select 下拉项。
|
||||||
|
* 注意:Semi Select option 的可访问名会带上勾选图标的 aria-label(tick),
|
||||||
|
* `getByRole('option', { name, exact: true })` 匹配不到,这里统一按选项文本定位。
|
||||||
|
*/
|
||||||
|
async function pickSelectOption(page: Page, optionName: string): Promise<void> {
|
||||||
|
const option = page.locator('.semi-select-option').filter({ hasText: optionName }).first();
|
||||||
|
await expect(option).toBeVisible({ timeout: 10_000 });
|
||||||
|
await option.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitToast(page: Page, text: string | RegExp): Promise<void> {
|
||||||
|
await expect(page.locator('[role="alert"]', { hasText: text }).first()).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 全局编排
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
if (!ENABLED) return;
|
||||||
|
apiContext = await request.newContext();
|
||||||
|
try {
|
||||||
|
const web = await apiContext.get(`${WEB_BASE}/`);
|
||||||
|
expect(web.status(), `web 未就绪:请在 ${WEB_BASE} 启动前端`).toBe(200);
|
||||||
|
await cleanupDictLeftovers();
|
||||||
|
await prepareBaseFieldConfig();
|
||||||
|
await createUser(ADMIN_ACCOUNT, 'A4 端到端超管', 'super_admin');
|
||||||
|
await createUser(READONLY_ACCOUNT, 'A4 端到端只读', 'material_operator');
|
||||||
|
adminSession = await loginViaApi(ADMIN_ACCOUNT);
|
||||||
|
readonlySession = await loginViaApi(READONLY_ACCOUNT);
|
||||||
|
expect(adminSession.permissions, '超管应含 dict:manage').toContain('dict:manage');
|
||||||
|
expect(adminSession.permissions, '超管应含 system:configure').toContain('system:configure');
|
||||||
|
expect(readonlySession.permissions, '素材运营不应含 dict:manage').not.toContain('dict:manage');
|
||||||
|
expect(readonlySession.permissions, '素材运营不应含 system:configure').not.toContain(
|
||||||
|
'system:configure',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
bootstrapError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (!ENABLED) return;
|
||||||
|
try {
|
||||||
|
// 字典:先删条目再删类型(按本次记录 + 前缀双保险)
|
||||||
|
if (createdItemId !== null) {
|
||||||
|
await prisma.dictItem.deleteMany({ where: { id: createdItemId } });
|
||||||
|
}
|
||||||
|
if (createdTypeCode !== null) {
|
||||||
|
const type = await readDictTypeByCode(createdTypeCode);
|
||||||
|
if (type !== null) {
|
||||||
|
await prisma.dictItem.deleteMany({ where: { typeId: type.id } });
|
||||||
|
await prisma.dictType.deleteMany({ where: { id: type.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await cleanupDictLeftovers();
|
||||||
|
|
||||||
|
// 系统配置:恢复原值 / 原版本,并删除本次产生的历史行
|
||||||
|
if (baseFieldAfterVersion !== null) {
|
||||||
|
await prisma.systemConfigVersion.deleteMany({
|
||||||
|
where: { configKey: BASE_FIELD_KEY, version: baseFieldAfterVersion },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const configRow = await prisma.systemConfig.findUnique({
|
||||||
|
where: { configKey: BASE_FIELD_KEY },
|
||||||
|
});
|
||||||
|
if (configRow !== null) {
|
||||||
|
await prisma.systemConfig.update({
|
||||||
|
where: { configKey: BASE_FIELD_KEY },
|
||||||
|
data: { configValue: baseFieldOriginalValue, version: baseFieldBeforeVersion },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.deleteMany({ where: { account: { in: [ADMIN_ACCOUNT, READONLY_ACCOUNT] } } });
|
||||||
|
} catch {
|
||||||
|
// 清理失败不覆盖真实断言结果;残留由下一次 beforeAll 按前缀清理
|
||||||
|
}
|
||||||
|
await apiContext?.dispose();
|
||||||
|
apiContext = null;
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
test.skip(bootstrapError !== null, bootstrapError ?? '测试环境未就绪');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ① / ② 字典主从页:默认类型、三层 chips、树表切换、行业作用域
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('数据字典:默认类型 / 三层 chips / 树词表切换 / 行业作用域', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await openDicts(page);
|
||||||
|
|
||||||
|
// 左侧类型栏与右栏类型头
|
||||||
|
await expect(page.getByRole('complementary', { name: '字典类型' })).toBeVisible();
|
||||||
|
await expect(page.locator('.lx-dict-list__title')).toHaveText('字典类型');
|
||||||
|
const detailHead = page.locator('.lx-dict-head');
|
||||||
|
await expect(detailHead.locator('.lx-dict-head__name b').first()).toHaveText('脚本方向');
|
||||||
|
await expect(detailHead.locator('.lx-dict-head__meta code').first()).toHaveText(
|
||||||
|
'script_direction',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 默认行业视角:电商 / 服饰鞋包
|
||||||
|
await expect(page.locator('.lx-dict-scope__select')).toContainText('电商 / 服饰鞋包');
|
||||||
|
|
||||||
|
// 三层 chips:通用 5 + 父行业 2 + 本行业 1 = 生效 7
|
||||||
|
const chips = page.locator('.lx-dict-scope__layers .lx-dict-layer-chip');
|
||||||
|
await expect(chips.filter({ hasText: '通用' })).toContainText('5');
|
||||||
|
await expect(chips.filter({ hasText: '父行业' })).toContainText('2');
|
||||||
|
await expect(chips.filter({ hasText: '本行业' })).toContainText('1');
|
||||||
|
await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('生效');
|
||||||
|
await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('7');
|
||||||
|
await expect(page.locator('.lx-dict-viewrow__hint')).toContainText('生效 7 项');
|
||||||
|
|
||||||
|
// 词表视图:种短视频 / 直播切片 / 穿搭教程 + 「已覆盖」标签
|
||||||
|
await expect(page.locator('.lx-dict-table')).toBeVisible();
|
||||||
|
for (const name of ['种草短视频', '直播切片', '穿搭教程']) {
|
||||||
|
await expect(dictItemRow(page, name)).toBeVisible();
|
||||||
|
}
|
||||||
|
await expect(dictItemRow(page, '种草短视频').getByText('已覆盖')).toBeVisible();
|
||||||
|
await expect(dictItemRow(page, '直播切片').getByText('已覆盖')).toHaveCount(0);
|
||||||
|
|
||||||
|
// 树 / 词表切换:点击「树视图」→ URL ?view=tree 且渲染树
|
||||||
|
// Semi RadioGroup 的隐藏 input 被 addon 覆盖,直接点击可见的 addon 文本。
|
||||||
|
const viewSwitcher = page.locator('.lx-dict-viewrow');
|
||||||
|
await viewSwitcher
|
||||||
|
.getByText(/树视图/)
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page).toHaveURL(/view=tree/);
|
||||||
|
await expect(page.locator('.lx-dict-tree')).toBeVisible();
|
||||||
|
await expect(page.locator('.lx-dict-table')).toHaveCount(0);
|
||||||
|
await viewSwitcher
|
||||||
|
.getByText(/词表视图/)
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page).toHaveURL(/view=list/);
|
||||||
|
await expect(page.locator('.lx-dict-table')).toBeVisible();
|
||||||
|
|
||||||
|
// 行业作用域:切到全平台 → 只保留通用层(生效 5)
|
||||||
|
await page.locator('.lx-dict-scope__select').click();
|
||||||
|
await pickSelectOption(page, '全平台');
|
||||||
|
await expect(page).toHaveURL(/industry=all/);
|
||||||
|
await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('5');
|
||||||
|
await expect(page.locator('.lx-dict-layer-chip', { hasText: '父行业' })).toHaveCount(0);
|
||||||
|
await expect(dictItemRow(page, '直播切片')).toHaveCount(0);
|
||||||
|
await expect(dictItemRow(page, '口播')).toBeVisible();
|
||||||
|
|
||||||
|
// 切回电商 / 服饰鞋包 → 三层恢复为 7
|
||||||
|
await page.locator('.lx-dict-scope__select').click();
|
||||||
|
await pickSelectOption(page, '电商 / 服饰鞋包');
|
||||||
|
await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('7');
|
||||||
|
await expect(dictItemRow(page, '直播切片')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ③ 条目:新增 → 编辑编码只读 → 停用 / 启用
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('数据字典:新增条目 → 编辑编码只读 → 停用 / 启用', async ({ page }) => {
|
||||||
|
test.setTimeout(150_000);
|
||||||
|
await openDicts(page);
|
||||||
|
await expect(page.locator('.lx-dict-table tbody tr').first()).toBeVisible();
|
||||||
|
|
||||||
|
const suffix = randomSuffix();
|
||||||
|
const itemCode = `${ITEM_CODE_PREFIX}${suffix}`;
|
||||||
|
const itemName = `A4E2E测试条目${suffix}`;
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '新增条目' }).first().click();
|
||||||
|
const sheet = page.locator('.semi-sidesheet');
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
await expect(sheet.locator('.semi-sidesheet-title')).toHaveText('新增条目');
|
||||||
|
await sheet.getByPlaceholder('如:口播').fill(itemName);
|
||||||
|
await sheet.getByPlaceholder('字母、数字、下划线或短横线').fill(itemCode);
|
||||||
|
await sheet.getByRole('button', { name: '保存', exact: true }).click();
|
||||||
|
await waitToast(page, '条目已创建');
|
||||||
|
|
||||||
|
const row = dictItemRow(page, itemCode);
|
||||||
|
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(row).toContainText(itemName);
|
||||||
|
await expect(row.locator('.lx-dict-status--active')).toBeVisible();
|
||||||
|
|
||||||
|
// 记录 id,afterAll 兜底清理
|
||||||
|
const createdItem = await prisma.dictItem.findFirst({
|
||||||
|
where: { code: itemCode },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
createdItemId = createdItem?.id ?? null;
|
||||||
|
expect(createdItemId, '新增条目应落库').not.toBeNull();
|
||||||
|
|
||||||
|
// 打开编辑:编码输入禁用且值不可改
|
||||||
|
await row.getByRole('button', { name: '编辑', exact: true }).click();
|
||||||
|
const editSheet = page.locator('.semi-sidesheet');
|
||||||
|
await expect(editSheet.locator('.semi-sidesheet-title')).toHaveText('编辑条目');
|
||||||
|
const codeInput = editSheet.getByPlaceholder('字母、数字、下划线或短横线');
|
||||||
|
await expect(codeInput).toBeDisabled();
|
||||||
|
await expect(codeInput).toHaveValue(itemCode);
|
||||||
|
await expect(editSheet.getByText('编码创建后不可修改')).toBeVisible();
|
||||||
|
await editSheet.getByRole('button', { name: '取消', exact: true }).click();
|
||||||
|
await expect(editSheet).toHaveCount(0);
|
||||||
|
|
||||||
|
// 停用条目:Modal.confirm 点「停用」(Semi 确认按钮的 aria-label 固定为 confirm)
|
||||||
|
await row.getByRole('button', { name: '停用', exact: true }).click();
|
||||||
|
const disableDialog = page.getByRole('dialog', { name: new RegExp(`停用条目「${itemName}」`) });
|
||||||
|
await expect(disableDialog).toBeVisible();
|
||||||
|
await disableDialog.getByRole('button', { name: 'confirm' }).click();
|
||||||
|
await waitToast(page, `已停用「${itemName}」`);
|
||||||
|
await expect(row.locator('.lx-dict-status--disabled')).toBeVisible();
|
||||||
|
await expect(row).toHaveClass(/is-disabled/);
|
||||||
|
|
||||||
|
// 启用条目:Modal.confirm 点「启用」
|
||||||
|
await row.getByRole('button', { name: '启用', exact: true }).click();
|
||||||
|
const enableDialog = page.getByRole('dialog', { name: new RegExp(`启用条目「${itemName}」`) });
|
||||||
|
await expect(enableDialog).toBeVisible();
|
||||||
|
await enableDialog.getByRole('button', { name: 'confirm' }).click();
|
||||||
|
await waitToast(page, `已启用「${itemName}」`);
|
||||||
|
await expect(row.locator('.lx-dict-status--active')).toBeVisible();
|
||||||
|
await expect(row).not.toHaveClass(/is-disabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ④ 字典类型:新建 → 列表可见 → 编辑编码只读
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('数据字典:新建类型 → 列表可见 → 编辑类型编码只读', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await openDicts(page);
|
||||||
|
|
||||||
|
const suffix = randomSuffix();
|
||||||
|
const typeCode = `${TYPE_CODE_PREFIX}type_${suffix}`;
|
||||||
|
const typeName = `A4E2E测试类型${suffix}`;
|
||||||
|
createdTypeCode = typeCode;
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '新建字典类型' }).click();
|
||||||
|
const modal = page.getByRole('dialog', { name: '新建字典类型' });
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
await modal.getByPlaceholder('如:投放位置').fill(typeName);
|
||||||
|
await modal.getByPlaceholder('小写字母、数字与下划线').fill(typeCode);
|
||||||
|
|
||||||
|
// 所属分组:质量与合规
|
||||||
|
const groupField = modal.locator('.lx-dict-field', { hasText: '所属分组' });
|
||||||
|
await groupField.locator('.semi-select').click();
|
||||||
|
await pickSelectOption(page, '质量与合规');
|
||||||
|
// 形态:树
|
||||||
|
const kindField = modal.locator('.lx-dict-field', { hasText: '形态' });
|
||||||
|
await kindField.locator('.semi-select').click();
|
||||||
|
await pickSelectOption(page, '树');
|
||||||
|
await expect(kindField.locator('.semi-select')).toContainText('树');
|
||||||
|
|
||||||
|
await modal.getByRole('button', { name: '保存', exact: true }).click();
|
||||||
|
await waitToast(page, '字典类型已创建');
|
||||||
|
|
||||||
|
// 保存后自动进入新类型详情;左侧类型列表可见
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/system/dicts/${typeCode}`));
|
||||||
|
await expect(page.locator('.lx-dict-head__name b').first()).toHaveText(typeName);
|
||||||
|
const typeRow = page.locator('.lx-dict-type', { hasText: typeCode });
|
||||||
|
await expect(typeRow).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(typeRow).toContainText(typeName);
|
||||||
|
|
||||||
|
// 编辑类型:编码只读、形态只读
|
||||||
|
await page.getByRole('button', { name: '编辑类型' }).click();
|
||||||
|
const editModal = page.getByRole('dialog', { name: '编辑字典类型' });
|
||||||
|
await expect(editModal).toBeVisible();
|
||||||
|
const codeField = editModal.locator('.lx-dict-field', { hasText: '类型编码' });
|
||||||
|
await expect(codeField.locator('input')).toBeDisabled();
|
||||||
|
await expect(codeField.locator('input')).toHaveValue(typeCode);
|
||||||
|
await expect(editModal.getByText('创建后不可修改').first()).toBeVisible();
|
||||||
|
const editKindField = editModal.locator('.lx-dict-field', { hasText: '形态' });
|
||||||
|
await expect(editKindField.locator('.semi-select-disabled')).toBeVisible();
|
||||||
|
await editModal.getByRole('button', { name: '取消', exact: true }).click();
|
||||||
|
await expect(editModal).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ⑤ 系统配置:6 组、命名占位符与实时预览、模板行内编辑
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('系统配置:6 组导航 / 命名占位符 / 实时预览 / 模板编辑', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await gotoSystem(page, '/system/settings');
|
||||||
|
await expect(page.getByRole('heading', { name: '系统配置' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('app-shell')).toBeVisible();
|
||||||
|
|
||||||
|
// 左侧 6 组
|
||||||
|
await expect(page.getByRole('complementary', { name: '配置分组' })).toBeVisible();
|
||||||
|
const navItems = page.locator('.lx-settings-nav__item');
|
||||||
|
await expect(navItems).toHaveCount(6);
|
||||||
|
for (const groupName of [
|
||||||
|
'基础信息',
|
||||||
|
'命名规则',
|
||||||
|
'导出与归档',
|
||||||
|
'通知开关',
|
||||||
|
'阈值与上限',
|
||||||
|
'业务阈值',
|
||||||
|
]) {
|
||||||
|
await expect(navItems.filter({ hasText: groupName })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命名规则:占位符 chips + 默认实时预览
|
||||||
|
await expect(page.locator('.lx-settings-naming-aside')).toBeVisible();
|
||||||
|
await expect(page.locator('.lx-settings-aside-head')).toContainText('命名占位符');
|
||||||
|
await expect(page.locator('.lx-settings-token')).toHaveCount(6);
|
||||||
|
await expect(page.locator('.lx-settings-preview__value')).toHaveText(
|
||||||
|
'电商-花间集-口播-20261104-01',
|
||||||
|
);
|
||||||
|
const originalPreview = await page.locator('.lx-settings-preview__value').innerText();
|
||||||
|
|
||||||
|
// 点击「编辑」改模板 → 预览实时变化 → 放弃更改恢复
|
||||||
|
const patternRow = page
|
||||||
|
.locator('.lx-settings-config.is-pattern')
|
||||||
|
.filter({ hasText: '脚本命名规则' });
|
||||||
|
await patternRow.getByRole('button', { name: '编辑', exact: true }).click();
|
||||||
|
const patternInput = page.getByLabel('命名规则');
|
||||||
|
await expect(patternInput).toBeVisible();
|
||||||
|
await patternInput.fill('{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}');
|
||||||
|
await expect(page.locator('.lx-settings-preview__value')).toHaveText(
|
||||||
|
'电商-花间集-口播-20261104-01-1080x1920',
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.locator('.lx-settings-form__foot')
|
||||||
|
.getByRole('button', { name: '放弃更改', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.locator('.lx-settings-preview__value')).toHaveText(originalPreview);
|
||||||
|
await expect(page.locator('.lx-settings-headindicator')).toContainText('所有修改已保存');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ⑥ 阈值修改:保存 → 二次确认 → toast / 版本 +1 / 变更记录
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('系统配置:阈值修改 → 二次确认 → 版本 +1 → 变更记录', async ({ page }) => {
|
||||||
|
test.setTimeout(150_000);
|
||||||
|
await gotoSystem(page, '/system/settings');
|
||||||
|
await expect(page.getByRole('heading', { name: '系统配置' })).toBeVisible();
|
||||||
|
|
||||||
|
// 进入「阈值与上限」
|
||||||
|
await page.locator('.lx-settings-nav__item', { hasText: '阈值与上限' }).click();
|
||||||
|
await expect(page).toHaveURL(/group=limits/);
|
||||||
|
await expect(page.locator('.lx-settings-form__head h2')).toHaveText('阈值与上限');
|
||||||
|
|
||||||
|
const baseFieldRow = page.locator('.lx-settings-config').filter({ hasText: '客户基础字段上限' });
|
||||||
|
await expect(baseFieldRow).toBeVisible();
|
||||||
|
await baseFieldRow.getByLabel('客户基础字段上限').fill('12');
|
||||||
|
await expect(page.locator('.lx-settings-headindicator')).toContainText('未保存 1 项');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator('.lx-settings-form__foot')
|
||||||
|
.getByRole('button', { name: '保存全部更改', exact: true })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// 阈值类配置二次确认
|
||||||
|
const confirmDialog = page.getByRole('dialog', { name: /确认修改阈值与上限/ });
|
||||||
|
await expect(confirmDialog).toBeVisible();
|
||||||
|
await expect(confirmDialog.getByText('客户基础字段上限')).toBeVisible();
|
||||||
|
await expect(confirmDialog).toContainText('12');
|
||||||
|
await confirmDialog.getByRole('button', { name: 'confirm' }).click();
|
||||||
|
|
||||||
|
await waitToast(page, '配置已保存,版本已更新');
|
||||||
|
await expect(page.locator('.lx-settings-headindicator')).toContainText('所有修改已保存');
|
||||||
|
|
||||||
|
// 版本 +1(值来自 Prisma 当前行)
|
||||||
|
const saved = await prisma.systemConfig.findUnique({ where: { configKey: BASE_FIELD_KEY } });
|
||||||
|
expect(saved?.configValue, '配置值应为 12').toBe(12);
|
||||||
|
baseFieldAfterVersion = saved?.version ?? null;
|
||||||
|
expect(baseFieldAfterVersion, '配置版本应 +1').toBe(baseFieldBeforeVersion + 1);
|
||||||
|
await expect(
|
||||||
|
page.locator('.lx-settings-config').filter({ hasText: '客户基础字段上限' }),
|
||||||
|
).toContainText(`v${String(baseFieldAfterVersion)}`);
|
||||||
|
|
||||||
|
// 变更记录抽屉:出现本次记录(版本 / 前后值 / 原因)
|
||||||
|
await page.getByRole('button', { name: '变更记录' }).click();
|
||||||
|
const historySheet = page.locator('.semi-sidesheet');
|
||||||
|
await expect(historySheet).toBeVisible();
|
||||||
|
await expect(historySheet.locator('.semi-sidesheet-title')).toHaveText('配置变更记录');
|
||||||
|
const historyItem = page
|
||||||
|
.locator('.lx-settings-history-item')
|
||||||
|
.filter({ hasText: '客户基础字段上限' })
|
||||||
|
.filter({ hasText: `v${String(baseFieldAfterVersion)}` });
|
||||||
|
await expect(historyItem).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(historyItem).toContainText('12');
|
||||||
|
await expect(historyItem).toContainText(SAVE_REASON);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ⑦ 权限:无 dict:manage / system:configure 的账号 → 403
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('权限:无字典 / 配置权限的账号访问 A4 页面显示 403', async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await injectSession(page, requireReadonlySession());
|
||||||
|
|
||||||
|
await page.goto('/system/dicts');
|
||||||
|
await expect(page).toHaveURL(/\/403/);
|
||||||
|
await expect(page.getByRole('heading', { name: '没有访问权限' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/system/settings');
|
||||||
|
await expect(page).toHaveURL(/\/403/);
|
||||||
|
await expect(page.getByRole('heading', { name: '没有访问权限' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ⑧ 未登录:访问 /system/dicts 重定向登录页
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('未登录访问 /system/dicts 重定向到登录页', async ({ page }) => {
|
||||||
|
await page.goto('/system/dicts');
|
||||||
|
await expect(page).toHaveURL(/\/login\?redirect=/);
|
||||||
|
await expect(page.getByRole('heading', { name: '登录灵犀' })).toBeVisible();
|
||||||
|
});
|
||||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user