feat(client): A3 客户与行业目录(两级行业、客户组、字段审批)
背景:A3 需把行业 / 客户 / 客户组 / 客户级字段立为资产根节点;用户确认两级行业树与混搭 UI(行业 B / 客户列表 A / 详情 A / 新建 B / 客户组 B / 字段 A)。 改动: - 新增 ADR-002,行业改为两级树,客户挂末级行业;同步 PRD / 技术架构 / 开发计划。 - 新增 client 模块:industries / customers / customer-groups / customer-fields 接口、真实 PrismaCustomerDirectory、客户状态机与客户转交、客户组数据范围解析、跨客户 404。 - 新增迁移与幂等种子(电商/金融两级行业、2 客户、1 客户组、4 条字段)。 - 前端新增行业/客户/客户组/字段审批页面与路由、导航更名(客户管理 / 行业管理 / 客户组),复用现有 token。 - 补充 shared 契约单测、client 集成用例、A3 浏览器 e2e。 影响面:client 模块与 client 表、shared 契约、web 路由/导航、ScopeContext 数据范围、OpenAPI 与种子;A1 客户转交占位由 client 实现替换。 回滚:回滚本提交;数据库保留新增表或按报告执行 down SQL;CUSTOMER_DIRECTORY 可切回空集实现。 验证:format:check / lint / typecheck / test 全绿(shared 131 / api 90 / web 125,共 346);test:int 5 文件 39 条;A3 e2e 1/1;build 8/8;真实浏览器六页验证通过。
This commit is contained in:
1 parent
8626996bb1
commit
140dc386c1
54 files changed
+9018
-147
No files matched your search
@@ -7,6 +7,7 @@ import { APP_CONFIG, AppConfigModule } from './config/config.module';
|
||||
import type { AppConfig } from './config/env';
|
||||
import { PrismaModule } from './infra/prisma/prisma.module';
|
||||
import { RedisModule } from './infra/redis/redis.module';
|
||||
import { ClientModule } from './modules/client/client.module';
|
||||
import { IdentityModule } from './modules/identity/identity.module';
|
||||
import { PlatformModule } from './modules/platform/platform.module';
|
||||
|
||||
@@ -49,6 +50,7 @@ import { PlatformModule } from './modules/platform/platform.module';
|
||||
RedisModule,
|
||||
SecurityModule,
|
||||
IdentityModule,
|
||||
ClientModule,
|
||||
PlatformModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import { PermissionCatalogService } from './catalog/permission-catalog.service';
|
||||
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
|
||||
import { PermissionService } from './permission.service';
|
||||
import { RequestContextService } from './request-context';
|
||||
import { CUSTOMER_DIRECTORY, EmptyCustomerDirectory } from './scope/customer-directory';
|
||||
import { ScopeResolver } from './scope/scope-resolver.service';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
@@ -15,8 +14,8 @@ import { TokenService } from './token.service';
|
||||
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、数据范围解析、JWT 与守卫链。
|
||||
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
|
||||
*
|
||||
* `CUSTOMER_DIRECTORY` 是客户目录端口:A2 注册空集实现,A3 落地 client 模块后替换 provider
|
||||
* 即可让六类数据范围解析出真实客户集合,ScopeResolver / 守卫链无需改动。
|
||||
* `CUSTOMER_DIRECTORY` 由全局 ClientModule 提供真实实现(A3);
|
||||
* ScopeResolver / 守卫链只依赖端口,不感知客户模块。
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -32,7 +31,6 @@ import { TokenService } from './token.service';
|
||||
JwtAuthGuard,
|
||||
SpecialPermissionGuard,
|
||||
ScopeGuard,
|
||||
{ provide: CUSTOMER_DIRECTORY, useClass: EmptyCustomerDirectory },
|
||||
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useExisting: PermissionGuard },
|
||||
{ provide: APP_GUARD, useExisting: SpecialPermissionGuard },
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import { API_PREFIX, ErrorCode, Permission, ulid } from '@lingxi/shared';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createApp, type CreatedApiApp } from '../../bootstrap';
|
||||
import { loadDotenv } from '../../config/load-dotenv';
|
||||
|
||||
const PASSWORD_HASH =
|
||||
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||
const PASSWORD = 'lingxi2026';
|
||||
const PREFIX = 'itqa3.';
|
||||
const API = API_PREFIX;
|
||||
|
||||
loadDotenv();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface TokenBody {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
interface JsonResult<T> {
|
||||
status: number;
|
||||
body: T;
|
||||
}
|
||||
|
||||
interface FixtureUser {
|
||||
id: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
let phoneCounter = 0;
|
||||
let app: CreatedApiApp['app'];
|
||||
let superToken = '';
|
||||
let scopedToken = '';
|
||||
let approverToken = '';
|
||||
let scopedUserId = '';
|
||||
let rootIndustryId = '';
|
||||
let leafIndustryId = '';
|
||||
let customerId = '';
|
||||
let groupId = '';
|
||||
const createdUserIds: string[] = [];
|
||||
const createdRoleIds: string[] = [];
|
||||
|
||||
function nextPhone(): string {
|
||||
phoneCounter += 1;
|
||||
return `136${String(phoneCounter).padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
async function createRole(
|
||||
suffix: string,
|
||||
permissions: string[],
|
||||
dataScope: { type: string; targetId?: string },
|
||||
): Promise<string> {
|
||||
const id = ulid();
|
||||
await prisma.role.create({
|
||||
data: {
|
||||
id,
|
||||
code: `${PREFIX}role_${suffix}`,
|
||||
name: `${PREFIX}角色-${suffix}`,
|
||||
description: 'A3-QA-01 集成测试角色',
|
||||
status: 'ACTIVE',
|
||||
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
|
||||
dataScopes: {
|
||||
create: [{ id: ulid(), scopeType: dataScope.type, targetId: dataScope.targetId ?? null }],
|
||||
},
|
||||
},
|
||||
});
|
||||
createdRoleIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createUser(suffix: string, roleId: string): Promise<FixtureUser> {
|
||||
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||||
const account = `${PREFIX}${suffix}`;
|
||||
const id = ulid();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
name: `A3 集成测试${suffix}`,
|
||||
account,
|
||||
email: `${account}@lingxi.cn`,
|
||||
phone: nextPhone(),
|
||||
passwordHash: PASSWORD_HASH,
|
||||
departmentId: department.id,
|
||||
mustChangePassword: false,
|
||||
roles: { create: [{ roleId }] },
|
||||
},
|
||||
});
|
||||
createdUserIds.push(id);
|
||||
return { id, account };
|
||||
}
|
||||
|
||||
async function login(account: string): Promise<string> {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `${API}/auth/login`,
|
||||
payload: { account, password: PASSWORD },
|
||||
});
|
||||
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
||||
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
|
||||
}
|
||||
return response.json<TokenBody>().accessToken;
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||
url: string,
|
||||
token: string,
|
||||
payload?: unknown,
|
||||
): Promise<JsonResult<T>> {
|
||||
const response = await app.inject({
|
||||
method,
|
||||
url: `${API}${url}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||
});
|
||||
const body = response.body.length === 0 ? (null as T) : response.json<T>();
|
||||
return { status: response.statusCode, body };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await prisma.customerField.deleteMany({
|
||||
where: { customer: { name: { startsWith: 'ITQA3-' } } },
|
||||
});
|
||||
await prisma.customerGroupMember.deleteMany({
|
||||
where: { group: { name: { startsWith: 'ITQA3-' } } },
|
||||
});
|
||||
await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||
await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||
await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||
await prisma.user.deleteMany({ where: { account: { startsWith: PREFIX } } });
|
||||
await prisma.role.deleteMany({ where: { code: { startsWith: `${PREFIX}role_` } } });
|
||||
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
|
||||
await createUser('admin', superAdminRole.id);
|
||||
const scopedRole = await createRole('scoped', [Permission.CUSTOMER_READ], {
|
||||
type: 'created_by_me',
|
||||
});
|
||||
const scopedUser = await createUser('scoped', scopedRole);
|
||||
scopedUserId = scopedUser.id;
|
||||
const approverRole = await createRole(
|
||||
'approver',
|
||||
[Permission.CUSTOMER_READ, Permission.CUSTOMER_FIELD_APPROVE],
|
||||
{ type: 'all' },
|
||||
);
|
||||
await createUser('approver', approverRole);
|
||||
|
||||
const created = await createApp();
|
||||
app = created.app;
|
||||
await app.init();
|
||||
const adminRow = await prisma.user.findUnique({ where: { account: `${PREFIX}admin` } });
|
||||
if (adminRow === null) throw new Error('fixture user missing');
|
||||
superToken = await login(`${PREFIX}admin`);
|
||||
scopedToken = await login(`${PREFIX}scoped`);
|
||||
approverToken = await login(`${PREFIX}approver`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.customerField.deleteMany({
|
||||
where: { customer: { name: { startsWith: 'ITQA3-' } } },
|
||||
});
|
||||
await prisma.customerGroupMember.deleteMany({
|
||||
where: { group: { name: { startsWith: 'ITQA3-' } } },
|
||||
});
|
||||
await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||
await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||
await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
|
||||
if (createdRoleIds.length > 0) {
|
||||
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
|
||||
}
|
||||
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
|
||||
if (app) await app.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe('A3 客户与行业目录 · 唯一性 / 状态 / 范围 / 字段审批', () => {
|
||||
it('两级行业:新建一级与二级,重名 40902,有子行业禁止停用/删除', async () => {
|
||||
const root = await call<{ id: string; level: number; status: string }>(
|
||||
'POST',
|
||||
'/industries',
|
||||
superToken,
|
||||
{
|
||||
name: 'ITQA3-电商',
|
||||
sortOrder: 10,
|
||||
},
|
||||
);
|
||||
expect(root.status).toBe(201);
|
||||
expect(root.body.level).toBe(1);
|
||||
rootIndustryId = root.body.id;
|
||||
|
||||
const child = await call<{ id: string; parentId: string; level: number }>(
|
||||
'POST',
|
||||
'/industries',
|
||||
superToken,
|
||||
{
|
||||
name: 'ITQA3-服饰',
|
||||
parentId: rootIndustryId,
|
||||
sortOrder: 10,
|
||||
},
|
||||
);
|
||||
expect(child.status).toBe(201);
|
||||
expect(child.body.level).toBe(2);
|
||||
leafIndustryId = child.body.id;
|
||||
|
||||
const duplicate = await call<{ code: number }>('POST', '/industries', superToken, {
|
||||
name: 'ITQA3-电商',
|
||||
});
|
||||
expect(duplicate.status).toBe(409);
|
||||
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
|
||||
|
||||
const blockedStop = await call<{ code: number }>(
|
||||
'PATCH',
|
||||
`/industries/${rootIndustryId}/status`,
|
||||
superToken,
|
||||
{ status: 'disabled' },
|
||||
);
|
||||
expect(blockedStop.status).toBe(409);
|
||||
expect(blockedStop.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
const blockedDelete = await call<{ code: number }>(
|
||||
'DELETE',
|
||||
`/industries/${rootIndustryId}`,
|
||||
superToken,
|
||||
);
|
||||
expect(blockedDelete.status).toBe(422);
|
||||
expect(blockedDelete.body.code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
|
||||
});
|
||||
|
||||
it('客户:简称必填 42201、同末级行业重名 40902、跨客户 404', async () => {
|
||||
const missingShort = await call<{ code: number }>('POST', '/customers', superToken, {
|
||||
industryId: leafIndustryId,
|
||||
name: 'ITQA3-花间集',
|
||||
ownerUserId: scopedUserId,
|
||||
});
|
||||
expect(missingShort.status).toBe(400);
|
||||
expect(missingShort.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
|
||||
const customer = await call<{ id: string; genFieldCount: number }>(
|
||||
'POST',
|
||||
'/customers',
|
||||
superToken,
|
||||
{
|
||||
industryId: leafIndustryId,
|
||||
name: 'ITQA3-花间集',
|
||||
shortName: 'HJ3',
|
||||
ownerUserId: scopedUserId,
|
||||
baseFields: [{ key: '门店', value: '沪-001' }],
|
||||
},
|
||||
);
|
||||
expect(customer.status).toBe(201);
|
||||
customerId = customer.body.id;
|
||||
expect(customer.body.genFieldCount).toBe(0);
|
||||
|
||||
const duplicate = await call<{ code: number }>('POST', '/customers', superToken, {
|
||||
industryId: leafIndustryId,
|
||||
name: 'ITQA3-花间集',
|
||||
shortName: 'HJ3B',
|
||||
ownerUserId: scopedUserId,
|
||||
});
|
||||
expect(duplicate.status).toBe(409);
|
||||
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
|
||||
|
||||
const crossCustomer = await call<{ code: number }>(
|
||||
'GET',
|
||||
`/customers/${customerId}`,
|
||||
scopedToken,
|
||||
);
|
||||
expect(crossCustomer.status).toBe(404);
|
||||
expect(crossCustomer.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('客户组:创建、幂等添加成员、数据范围解析到客户', async () => {
|
||||
const group = await call<{ id: string; memberCount: number }>(
|
||||
'POST',
|
||||
'/customer-groups',
|
||||
superToken,
|
||||
{
|
||||
name: 'ITQA3-客户组',
|
||||
description: 'A3 集成测试客户组',
|
||||
customerIds: [customerId],
|
||||
},
|
||||
);
|
||||
expect(group.status).toBe(201);
|
||||
groupId = group.body.id;
|
||||
expect(group.body.memberCount).toBe(1);
|
||||
|
||||
const again = await call<{ memberCount: number }>(
|
||||
'POST',
|
||||
`/customer-groups/${groupId}/members`,
|
||||
superToken,
|
||||
{
|
||||
addCustomerIds: [customerId],
|
||||
removeCustomerIds: [],
|
||||
},
|
||||
);
|
||||
expect(again.status).toBe(200);
|
||||
expect(again.body.memberCount).toBe(1);
|
||||
|
||||
const groupRole = await createRole('group', [Permission.CUSTOMER_READ], {
|
||||
type: 'customer_groups',
|
||||
targetId: groupId,
|
||||
});
|
||||
await createUser('group', groupRole);
|
||||
const groupToken = await login(`${PREFIX}group`);
|
||||
const visible = await call<{ items: { id: string }[] }>(
|
||||
'GET',
|
||||
`/customers?limit=50`,
|
||||
groupToken,
|
||||
);
|
||||
expect(visible.status).toBe(200);
|
||||
expect(visible.body.items.some((item) => item.id === customerId)).toBe(true);
|
||||
});
|
||||
|
||||
it('客户字段:进入生成申请待审批、审批通过生效、驳回原因必填、超限 40001', async () => {
|
||||
const apply = await call<{ id: string; status: string; entersGeneration: boolean }>(
|
||||
'POST',
|
||||
`/customers/${customerId}/fields`,
|
||||
superToken,
|
||||
{
|
||||
name: 'ITQA3-活动编码',
|
||||
code: 'itqa3_campaign',
|
||||
type: 'text',
|
||||
purpose: '活动素材命名与检索',
|
||||
entersGeneration: true,
|
||||
},
|
||||
);
|
||||
expect(apply.status).toBe(201);
|
||||
expect(apply.body.status).toBe('pending');
|
||||
expect(apply.body.entersGeneration).toBe(true);
|
||||
|
||||
const pending = await call<{ items: { id: string; status: string }[] }>(
|
||||
'GET',
|
||||
'/customer-fields?status=pending&limit=50',
|
||||
approverToken,
|
||||
);
|
||||
expect(pending.status).toBe(200);
|
||||
expect(pending.body.items.some((item) => item.id === apply.body.id)).toBe(true);
|
||||
|
||||
const rejectWithoutReason = await call<{ code: number }>(
|
||||
'POST',
|
||||
`/customer-fields/${apply.body.id}/approve`,
|
||||
approverToken,
|
||||
{ decision: 'reject' },
|
||||
);
|
||||
expect(rejectWithoutReason.status).toBe(400);
|
||||
expect(rejectWithoutReason.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
|
||||
const approve = await call<{ status: string }>(
|
||||
'POST',
|
||||
`/customer-fields/${apply.body.id}/approve`,
|
||||
approverToken,
|
||||
{ decision: 'approve' },
|
||||
);
|
||||
expect(approve.status).toBe(200);
|
||||
expect(approve.body.status).toBe('active');
|
||||
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const extra = await call<{ code?: number }>(
|
||||
'POST',
|
||||
`/customers/${customerId}/fields`,
|
||||
superToken,
|
||||
{
|
||||
name: `ITQA3-字段${index}`,
|
||||
code: `itqa3_extra_${index}`,
|
||||
type: 'text',
|
||||
purpose: '补足进入生成字段上限',
|
||||
entersGeneration: true,
|
||||
},
|
||||
);
|
||||
expect(extra.status).toBe(201);
|
||||
}
|
||||
const overflow = await call<{ code: number }>(
|
||||
'POST',
|
||||
`/customers/${customerId}/fields`,
|
||||
superToken,
|
||||
{
|
||||
name: 'ITQA3-超限字段',
|
||||
code: 'itqa3_overflow',
|
||||
type: 'text',
|
||||
purpose: '第六个进入生成字段应被拒绝',
|
||||
entersGeneration: true,
|
||||
},
|
||||
);
|
||||
expect(overflow.status).toBe(400);
|
||||
expect(overflow.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||
});
|
||||
|
||||
it('客户转交:无 customer:transfer 返回 403;超级管理员转交成功', async () => {
|
||||
const forbidden = await call<{ code: number }>(
|
||||
'POST',
|
||||
`/customers/${customerId}/transfer`,
|
||||
scopedToken,
|
||||
{
|
||||
ownerUserId: scopedUserId,
|
||||
},
|
||||
);
|
||||
expect(forbidden.status).toBe(403);
|
||||
expect(forbidden.body.code).toBe(ErrorCode.PERMISSION_DENIED);
|
||||
|
||||
const transferred = await call<{ ownerUserId: string }>(
|
||||
'POST',
|
||||
`/customers/${customerId}/transfer`,
|
||||
superToken,
|
||||
{ ownerUserId: createdUserIds[0] },
|
||||
);
|
||||
expect(transferred.status).toBe(200);
|
||||
expect(transferred.body.ownerUserId).toBe(createdUserIds[0]);
|
||||
});
|
||||
it('客户状态机:暂停 / 结束只读', async () => {
|
||||
const paused = await call<{ status: string }>(
|
||||
'PATCH',
|
||||
`/customers/${customerId}/status`,
|
||||
superToken,
|
||||
{
|
||||
status: 'paused',
|
||||
},
|
||||
);
|
||||
expect(paused.status).toBe(200);
|
||||
expect(paused.body.status).toBe('paused');
|
||||
const editPaused = await call<{ code: number }>(
|
||||
'PATCH',
|
||||
`/customers/${customerId}`,
|
||||
superToken,
|
||||
{
|
||||
shortName: 'HJ3C',
|
||||
},
|
||||
);
|
||||
expect(editPaused.status).toBe(409);
|
||||
expect(editPaused.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
const ended = await call<{ status: string }>(
|
||||
'PATCH',
|
||||
`/customers/${customerId}/status`,
|
||||
superToken,
|
||||
{
|
||||
status: 'ended',
|
||||
},
|
||||
);
|
||||
expect(ended.status).toBe(200);
|
||||
expect(ended.body.status).toBe('ended');
|
||||
const editEnded = await call<{ code: number }>(
|
||||
'PATCH',
|
||||
`/customers/${customerId}`,
|
||||
superToken,
|
||||
{
|
||||
shortName: 'HJ3D',
|
||||
},
|
||||
);
|
||||
expect(editEnded.status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
baseFieldSchema,
|
||||
type CustomerDetail,
|
||||
type CustomerField,
|
||||
type CustomerGroupDetail,
|
||||
type CustomerGroupSummary,
|
||||
customerPreferencesSchema,
|
||||
type CustomerStatus,
|
||||
type CustomerSummary,
|
||||
type IndustryNode,
|
||||
type IndustryStatus,
|
||||
} from '@lingxi/shared';
|
||||
import type { CustomerFieldStatus, IndustryStatus as PrismaIndustryStatus } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export function iso(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
export function isoOrNull(value: Date | null): string | null {
|
||||
return value === null ? null : value.toISOString();
|
||||
}
|
||||
|
||||
export function mapIndustryStatus(value: PrismaIndustryStatus): IndustryStatus {
|
||||
return value === 'ACTIVE' ? 'active' : 'disabled';
|
||||
}
|
||||
|
||||
export function mapCustomerStatus(value: string): CustomerStatus {
|
||||
if (value === 'PAUSED') return 'paused';
|
||||
if (value === 'ENDED') return 'ended';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
export function mapFieldStatus(value: CustomerFieldStatus): CustomerField['status'] {
|
||||
if (value === 'ACTIVE') return 'active';
|
||||
if (value === 'REJECTED') return 'rejected';
|
||||
if (value === 'DISABLED') return 'disabled';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export interface IndustryRow {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
level: number;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
status: PrismaIndustryStatus;
|
||||
remark: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export function toIndustryNode(
|
||||
row: IndustryRow,
|
||||
customerCount: number,
|
||||
assetCount = 0,
|
||||
): IndustryNode {
|
||||
return {
|
||||
id: row.id,
|
||||
parentId: row.parentId,
|
||||
level: row.level === 2 ? 2 : 1,
|
||||
name: row.name,
|
||||
sortOrder: row.sortOrder,
|
||||
status: mapIndustryStatus(row.status),
|
||||
remark: row.remark,
|
||||
customerCount,
|
||||
assetCount,
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomerSummaryRow {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
industryId: string;
|
||||
status: string;
|
||||
ownerUserId: string;
|
||||
updatedAt: Date;
|
||||
industry: { name: string; parent: { name: string } | null };
|
||||
owner: { name: string };
|
||||
customFields: unknown;
|
||||
groupMembers: { group: { id: string; name: string } }[];
|
||||
fields: { entersGeneration: boolean; status: CustomerFieldStatus }[];
|
||||
}
|
||||
|
||||
function industryPath(row: Pick<CustomerSummaryRow, 'industry'>): string {
|
||||
return row.industry.parent === null
|
||||
? row.industry.name
|
||||
: `${row.industry.parent.name} / ${row.industry.name}`;
|
||||
}
|
||||
|
||||
function genFieldCount(row: Pick<CustomerSummaryRow, 'fields'>): number {
|
||||
return row.fields.filter((field) => field.entersGeneration && field.status !== 'REJECTED').length;
|
||||
}
|
||||
|
||||
const baseFieldsSchema = z.array(baseFieldSchema);
|
||||
|
||||
export function toCustomerSummary(row: CustomerSummaryRow): CustomerSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
shortName: row.shortName,
|
||||
industryId: row.industryId,
|
||||
industryName: row.industry.name,
|
||||
industryPath: industryPath(row),
|
||||
status: mapCustomerStatus(row.status),
|
||||
ownerUserId: row.ownerUserId,
|
||||
ownerName: row.owner.name,
|
||||
groups: row.groupMembers.map((member) => ({ id: member.group.id, name: member.group.name })),
|
||||
baseFieldCount: parseBaseFields(row.customFields).length,
|
||||
genFieldCount: genFieldCount(row),
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomerDetailRow extends CustomerSummaryRow {
|
||||
contactName: string | null;
|
||||
contactPhone: string | null;
|
||||
preferences: unknown;
|
||||
customFields: unknown;
|
||||
remark: string | null;
|
||||
createdAt: Date;
|
||||
fields: CustomerFieldRow[];
|
||||
}
|
||||
|
||||
export interface CustomerFieldRow {
|
||||
id: string;
|
||||
customerId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: string;
|
||||
options: unknown;
|
||||
purpose: string;
|
||||
entersGeneration: boolean;
|
||||
status: CustomerFieldStatus;
|
||||
applicant: { name: string };
|
||||
approver: { name: string } | null;
|
||||
appliedAt: Date;
|
||||
approvedAt: Date | null;
|
||||
rejectReason: string | null;
|
||||
}
|
||||
|
||||
const fieldTypeSchema = z.enum(['text', 'number', 'date', 'select']);
|
||||
const optionsSchema = z.array(z.string());
|
||||
|
||||
export function toCustomerField(row: CustomerFieldRow): CustomerField {
|
||||
const options = optionsSchema.safeParse(row.options);
|
||||
return {
|
||||
id: row.id,
|
||||
customerId: row.customerId,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
type: fieldTypeSchema.safeParse(row.type).success ? fieldTypeSchema.parse(row.type) : 'text',
|
||||
options: options.success ? options.data : [],
|
||||
purpose: row.purpose,
|
||||
entersGeneration: row.entersGeneration,
|
||||
status: mapFieldStatus(row.status),
|
||||
applicantName: row.applicant.name,
|
||||
approverName: row.approver?.name ?? null,
|
||||
appliedAt: iso(row.appliedAt),
|
||||
approvedAt: isoOrNull(row.approvedAt),
|
||||
rejectReason: row.rejectReason,
|
||||
};
|
||||
}
|
||||
|
||||
function parseBaseFields(value: unknown): CustomerDetail['baseFields'] {
|
||||
const parsed = baseFieldsSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : [];
|
||||
}
|
||||
|
||||
function parsePreferences(value: unknown): CustomerDetail['preferences'] {
|
||||
const parsed = customerPreferencesSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : { naming: '', directory: '', specs: [], taboo: [] };
|
||||
}
|
||||
|
||||
export function toCustomerDetail(row: CustomerDetailRow): CustomerDetail {
|
||||
const baseFields = parseBaseFields(row.customFields);
|
||||
return {
|
||||
...toCustomerSummary({
|
||||
...row,
|
||||
fields: row.fields,
|
||||
}),
|
||||
baseFieldCount: baseFields.length,
|
||||
contactName: row.contactName,
|
||||
contactPhone: row.contactPhone,
|
||||
preferences: parsePreferences(row.preferences),
|
||||
baseFields,
|
||||
fields: row.fields.map((field) => toCustomerField(field)),
|
||||
remark: row.remark,
|
||||
createdAt: iso(row.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomerGroupRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
updatedAt: Date;
|
||||
_count: { members: number };
|
||||
}
|
||||
|
||||
export function toCustomerGroupSummary(row: CustomerGroupRow): CustomerGroupSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
memberCount: row._count.members,
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomerGroupMemberRow {
|
||||
customer: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
status: string;
|
||||
industry: { name: string; parent: { name: string } | null };
|
||||
owner: { name: string };
|
||||
};
|
||||
}
|
||||
|
||||
export function toCustomerGroupDetail(
|
||||
row: CustomerGroupRow,
|
||||
members: CustomerGroupMemberRow[],
|
||||
): CustomerGroupDetail {
|
||||
return {
|
||||
...toCustomerGroupSummary(row),
|
||||
members: members.map((member) => ({
|
||||
id: member.customer.id,
|
||||
name: member.customer.name,
|
||||
shortName: member.customer.shortName,
|
||||
industryPath:
|
||||
member.customer.industry.parent === null
|
||||
? member.customer.industry.name
|
||||
: `${member.customer.industry.parent.name} / ${member.customer.industry.name}`,
|
||||
status: mapCustomerStatus(member.customer.status),
|
||||
ownerName: member.customer.owner.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { CUSTOMER_DIRECTORY } from '../../common/security/scope/customer-directory';
|
||||
import {
|
||||
CustomerFieldsController,
|
||||
CustomerFieldsOfCustomerController,
|
||||
} from './customer-fields.controller';
|
||||
import { CustomerFieldsService } from './customer-fields.service';
|
||||
import { CustomerGroupsController } from './customer-groups.controller';
|
||||
import { CustomerGroupsService } from './customer-groups.service';
|
||||
import { CustomersController, CustomerTransferController } from './customers.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
import { IndustriesController } from './industries.controller';
|
||||
import { IndustriesService } from './industries.service';
|
||||
import { PrismaCustomerDirectory } from './prisma-customer-directory.service';
|
||||
|
||||
/**
|
||||
* client 模块(A3):两级行业、客户档案与状态机、客户组、客户级字段。
|
||||
*
|
||||
* 全局导出 `CUSTOMER_DIRECTORY` 真实实现,供 SecurityModule 的 ScopeResolver 解析六类
|
||||
* 数据范围;client 本身不反向依赖 security 模块,避免循环依赖。
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [
|
||||
IndustriesController,
|
||||
CustomersController,
|
||||
CustomerTransferController,
|
||||
CustomerGroupsController,
|
||||
CustomerFieldsController,
|
||||
CustomerFieldsOfCustomerController,
|
||||
],
|
||||
providers: [
|
||||
IndustriesService,
|
||||
CustomersService,
|
||||
CustomerGroupsService,
|
||||
CustomerFieldsService,
|
||||
PrismaCustomerDirectory,
|
||||
{ provide: CUSTOMER_DIRECTORY, useExisting: PrismaCustomerDirectory },
|
||||
],
|
||||
exports: [CUSTOMER_DIRECTORY, IndustriesService, CustomersService],
|
||||
})
|
||||
export class ClientModule {}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
type ApplyCustomerFieldInput,
|
||||
applyCustomerFieldInputSchema,
|
||||
type ApproveCustomerFieldInput,
|
||||
approveCustomerFieldInputSchema,
|
||||
type CustomerField,
|
||||
type CustomerFieldListItem,
|
||||
type CustomerFieldListQuery,
|
||||
customerFieldListQuerySchema,
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
} from '@lingxi/shared';
|
||||
import { Body, Controller, Get, HttpCode, Inject, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import type { AuthContext } from '../../common/security/auth.types';
|
||||
import {
|
||||
Audit,
|
||||
CurrentScope,
|
||||
CurrentUser,
|
||||
RequirePermissions,
|
||||
} from '../../common/security/decorators';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import { CustomerFieldsService } from './customer-fields.service';
|
||||
|
||||
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||
if (auth === null) throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
return auth;
|
||||
}
|
||||
|
||||
@ApiTags('client-customer-fields')
|
||||
@ApiBearerAuth()
|
||||
@Controller('customer-fields')
|
||||
export class CustomerFieldsController {
|
||||
constructor(@Inject(CustomerFieldsService) private readonly fields: CustomerFieldsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.CUSTOMER_FIELD_APPROVE, Permission.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: '字段审批队列(待审批 / 已生效 / 已驳回)' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(customerFieldListQuerySchema)) query: CustomerFieldListQuery,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<ListResponse<CustomerFieldListItem>> {
|
||||
return this.fields.list(query, scope);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.CUSTOMER_FIELD_APPROVE)
|
||||
@Audit('client.customer_field.approve')
|
||||
@ApiOperation({ summary: '审批客户级字段(通过 / 驳回;驳回原因必填)' })
|
||||
approve(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(approveCustomerFieldInputSchema)) input: ApproveCustomerFieldInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerField> {
|
||||
return this.fields.approve(id, input, requireAuth(auth).userId, scope);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('client-customer-fields')
|
||||
@ApiBearerAuth()
|
||||
@Controller('customers')
|
||||
export class CustomerFieldsOfCustomerController {
|
||||
constructor(@Inject(CustomerFieldsService) private readonly fields: CustomerFieldsService) {}
|
||||
|
||||
@Get(':id/fields')
|
||||
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: '客户级字段列表(基础字段在 customers.custom_fields)' })
|
||||
list(
|
||||
@Param('id') id: string,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerField[]> {
|
||||
return this.fields.listByCustomer(id, scope);
|
||||
}
|
||||
|
||||
@Post(':id/fields')
|
||||
@RequirePermissions(Permission.CUSTOMER_FIELD_APPLY)
|
||||
@Audit('client.customer_field.apply')
|
||||
@ApiOperation({ summary: '申请客户级字段(进入生成字段需审批;每客户 ≤ 5)' })
|
||||
apply(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(applyCustomerFieldInputSchema)) input: ApplyCustomerFieldInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerField> {
|
||||
return this.fields.apply(id, input, requireAuth(auth).userId, scope);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/* eslint-disable max-lines-per-function -- 申请 / 审批共用字段状态与上限校验,集中便于审计 */
|
||||
import {
|
||||
type ApplyCustomerFieldInput,
|
||||
type ApproveCustomerFieldInput,
|
||||
CUSTOMER_GEN_FIELD_LIMIT,
|
||||
type CustomerField,
|
||||
type CustomerFieldListItem,
|
||||
type CustomerFieldListQuery,
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
ulid,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import { type CustomerFieldRow, toCustomerField } from './client.mapper';
|
||||
|
||||
const FIELD_INCLUDE = {
|
||||
applicant: { select: { name: true } },
|
||||
approver: { select: { name: true } },
|
||||
} satisfies Prisma.CustomerFieldInclude;
|
||||
|
||||
const FIELD_LIST_INCLUDE = {
|
||||
...FIELD_INCLUDE,
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortName: true,
|
||||
industry: { include: { parent: true } },
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.CustomerFieldInclude;
|
||||
|
||||
function industryPath(customer: {
|
||||
industry: { name: string; parent: { name: string } | null };
|
||||
}): string {
|
||||
return customer.industry.parent === null
|
||||
? customer.industry.name
|
||||
: `${customer.industry.parent.name} / ${customer.industry.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户级字段(A3-BE-04):基础字段走 customers.custom_fields(≤10,免审批);
|
||||
* 本表只承载进入生成 / 需要审批的字段(≤5)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomerFieldsService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(
|
||||
query: CustomerFieldListQuery,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<ListResponse<CustomerFieldListItem>> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
const rows = await this.prisma.customerField.findMany({
|
||||
where: {
|
||||
customer: {
|
||||
deletedAt: null,
|
||||
...(scope.customerIds === null ? {} : { id: { in: [...scope.customerIds] } }),
|
||||
},
|
||||
...(query.customer_id === undefined ? {} : { customerId: query.customer_id }),
|
||||
...(query.status === 'all'
|
||||
? {}
|
||||
: {
|
||||
status:
|
||||
query.status === 'pending'
|
||||
? 'PENDING'
|
||||
: query.status === 'active'
|
||||
? 'ACTIVE'
|
||||
: query.status === 'rejected'
|
||||
? 'REJECTED'
|
||||
: 'DISABLED',
|
||||
}),
|
||||
},
|
||||
include: FIELD_LIST_INCLUDE,
|
||||
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
|
||||
take: query.limit + 1,
|
||||
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
|
||||
});
|
||||
const page = rows.slice(0, query.limit);
|
||||
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
|
||||
return {
|
||||
items: page.map((row) => ({
|
||||
...toCustomerField(row as CustomerFieldRow),
|
||||
customerName: row.customer.name,
|
||||
customerShortName: row.customer.shortName,
|
||||
industryPath: industryPath(row.customer),
|
||||
})),
|
||||
next_cursor: next,
|
||||
};
|
||||
}
|
||||
|
||||
async listByCustomer(
|
||||
customerId: string,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<CustomerField[]> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
if (!isCustomerVisible(scope, customerId)) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
const rows = await this.prisma.customerField.findMany({
|
||||
where: { customerId, customer: { deletedAt: null } },
|
||||
include: FIELD_INCLUDE,
|
||||
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
|
||||
});
|
||||
return rows.map((row) => toCustomerField(row as CustomerFieldRow));
|
||||
}
|
||||
|
||||
async apply(
|
||||
customerId: string,
|
||||
input: ApplyCustomerFieldInput,
|
||||
actorId: string,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<CustomerField> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
if (!isCustomerVisible(scope, customerId)) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
const customer = await this.prisma.customer.findFirst({
|
||||
where: { id: customerId, deletedAt: null },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (customer === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
if (customer.status !== 'ACTIVE') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: customer.status === 'PAUSED' ? '客户已暂停,字段只读' : '客户已结束,字段只读归档',
|
||||
});
|
||||
}
|
||||
const duplicate = await this.prisma.customerField.findUnique({
|
||||
where: { customerId_code: { customerId, code: input.code } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate !== null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||
message: '该客户下字段编码已存在',
|
||||
});
|
||||
}
|
||||
if (input.entersGeneration) {
|
||||
const used = await this.genUsedCount(customerId);
|
||||
if (used >= CUSTOMER_GEN_FIELD_LIMIT) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,请先停用或驳回其他字段`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const id = ulid();
|
||||
await this.prisma.customerField.create({
|
||||
data: {
|
||||
id,
|
||||
customerId,
|
||||
name: input.name,
|
||||
code: input.code,
|
||||
type: input.type,
|
||||
options: input.options,
|
||||
purpose: input.purpose,
|
||||
entersGeneration: input.entersGeneration,
|
||||
status: input.entersGeneration ? 'PENDING' : 'ACTIVE',
|
||||
applicantUserId: actorId,
|
||||
...(input.entersGeneration ? {} : { approvedAt: new Date(), approverUserId: actorId }),
|
||||
},
|
||||
});
|
||||
const row = await this.prisma.customerField.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: FIELD_INCLUDE,
|
||||
});
|
||||
return toCustomerField(row);
|
||||
}
|
||||
|
||||
async approve(
|
||||
id: string,
|
||||
input: ApproveCustomerFieldInput,
|
||||
actorId: string,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<CustomerField> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
const existing = await this.prisma.customerField.findUnique({
|
||||
where: { id },
|
||||
include: { customer: { select: { id: true, deletedAt: true } } },
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- 保留显式 null 分支,跨客户统一 404
|
||||
if (existing === null || existing.customer.deletedAt !== null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' });
|
||||
}
|
||||
if (!isCustomerVisible(scope, existing.customerId)) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' });
|
||||
}
|
||||
if (existing.status !== 'PENDING') {
|
||||
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '该申请已处理' });
|
||||
}
|
||||
if (input.decision === 'approve' && existing.entersGeneration) {
|
||||
const used = await this.genUsedCount(existing.customerId, id);
|
||||
if (used >= CUSTOMER_GEN_FIELD_LIMIT) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,审批后仍超限`,
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.prisma.customerField.update({
|
||||
where: { id },
|
||||
data:
|
||||
input.decision === 'approve'
|
||||
? {
|
||||
status: 'ACTIVE',
|
||||
approverUserId: actorId,
|
||||
approvedAt: new Date(),
|
||||
rejectReason: null,
|
||||
}
|
||||
: {
|
||||
status: 'REJECTED',
|
||||
approverUserId: actorId,
|
||||
approvedAt: new Date(),
|
||||
rejectReason: input.reason ?? '',
|
||||
},
|
||||
});
|
||||
const row = await this.prisma.customerField.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: FIELD_INCLUDE,
|
||||
});
|
||||
return toCustomerField(row);
|
||||
}
|
||||
|
||||
private async genUsedCount(customerId: string, excludeId?: string): Promise<number> {
|
||||
return this.prisma.customerField.count({
|
||||
where: {
|
||||
customerId,
|
||||
entersGeneration: true,
|
||||
status: { not: 'REJECTED' },
|
||||
...(excludeId === undefined ? {} : { id: { not: excludeId } }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
type CreateCustomerGroupInput,
|
||||
createCustomerGroupInputSchema,
|
||||
type CustomerGroupDetail,
|
||||
type CustomerGroupListQuery,
|
||||
customerGroupListQuerySchema,
|
||||
type CustomerGroupSummary,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
type UpdateCustomerGroupInput,
|
||||
updateCustomerGroupInputSchema,
|
||||
type UpdateCustomerGroupMembersInput,
|
||||
updateCustomerGroupMembersInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { Audit, RequirePermissions } from '../../common/security/decorators';
|
||||
import { CustomerGroupsService } from './customer-groups.service';
|
||||
|
||||
@ApiTags('client-customer-groups')
|
||||
@ApiBearerAuth()
|
||||
@Controller('customer-groups')
|
||||
export class CustomerGroupsController {
|
||||
constructor(@Inject(CustomerGroupsService) private readonly groups: CustomerGroupsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||
@ApiOperation({ summary: '客户组列表(授权与数据范围最小单位)' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(customerGroupListQuerySchema)) query: CustomerGroupListQuery,
|
||||
): Promise<ListResponse<CustomerGroupSummary>> {
|
||||
return this.groups.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||
@ApiOperation({ summary: '客户组详情与成员' })
|
||||
detail(@Param('id') id: string): Promise<CustomerGroupDetail> {
|
||||
return this.groups.detail(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||
@Audit('client.customer_group.create')
|
||||
@ApiOperation({ summary: '新建客户组' })
|
||||
create(
|
||||
@Body(new ZodValidationPipe(createCustomerGroupInputSchema)) input: CreateCustomerGroupInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
return this.groups.create(input);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||
@Audit('client.customer_group.update')
|
||||
@ApiOperation({ summary: '更新客户组名称 / 说明' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateCustomerGroupInputSchema)) input: UpdateCustomerGroupInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
return this.groups.update(id, input);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||
@Audit('client.customer_group.delete')
|
||||
@ApiOperation({ summary: '删除客户组(被角色数据范围引用时拒绝)' })
|
||||
async remove(@Param('id') id: string): Promise<void> {
|
||||
await this.groups.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/members')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
|
||||
@Audit('client.customer_group.members')
|
||||
@ApiOperation({ summary: '批量添加 / 移除成员(幂等)' })
|
||||
updateMembers(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateCustomerGroupMembersInputSchema))
|
||||
input: UpdateCustomerGroupMembersInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
return this.groups.updateMembers(id, input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
type CreateCustomerGroupInput,
|
||||
type CustomerGroupDetail,
|
||||
type CustomerGroupListQuery,
|
||||
type CustomerGroupSummary,
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
ulid,
|
||||
type UpdateCustomerGroupInput,
|
||||
type UpdateCustomerGroupMembersInput,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import {
|
||||
type CustomerGroupRow,
|
||||
toCustomerGroupDetail,
|
||||
toCustomerGroupSummary,
|
||||
} from './client.mapper';
|
||||
|
||||
const GROUP_INCLUDE = {
|
||||
_count: { select: { members: true } },
|
||||
} satisfies Prisma.CustomerGroupInclude;
|
||||
|
||||
const GROUP_MEMBER_INCLUDE = {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shortName: true,
|
||||
status: true,
|
||||
industry: { include: { parent: true } },
|
||||
owner: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.CustomerGroupMemberInclude;
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
||||
}
|
||||
|
||||
/** 客户组(A3-BE-03):授权与数据范围最小单位;成员增删幂等。 */
|
||||
@Injectable()
|
||||
export class CustomerGroupsService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: CustomerGroupListQuery): Promise<ListResponse<CustomerGroupSummary>> {
|
||||
const rows = await this.prisma.customerGroup.findMany({
|
||||
where:
|
||||
query.keyword === undefined
|
||||
? {}
|
||||
: { name: { contains: query.keyword, mode: 'insensitive' } },
|
||||
include: GROUP_INCLUDE,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: query.limit + 1,
|
||||
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
|
||||
});
|
||||
const page = rows.slice(0, query.limit);
|
||||
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
|
||||
return {
|
||||
items: page.map((row) => toCustomerGroupSummary(row as CustomerGroupRow)),
|
||||
next_cursor: next,
|
||||
};
|
||||
}
|
||||
|
||||
async detail(id: string): Promise<CustomerGroupDetail> {
|
||||
const row = await this.findByIdOrThrow(id);
|
||||
const members = await this.prisma.customerGroupMember.findMany({
|
||||
where: { groupId: id },
|
||||
include: GROUP_MEMBER_INCLUDE,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return toCustomerGroupDetail(row, members);
|
||||
}
|
||||
|
||||
async create(input: CreateCustomerGroupInput): Promise<CustomerGroupDetail> {
|
||||
await this.assertNameUnique(input.name, null);
|
||||
const id = ulid();
|
||||
try {
|
||||
await this.prisma.customerGroup.create({
|
||||
data: {
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description ?? '',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '客户组名称已存在' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (input.customerIds !== undefined && input.customerIds.length > 0) {
|
||||
await this.addMembers(id, input.customerIds);
|
||||
}
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateCustomerGroupInput): Promise<CustomerGroupDetail> {
|
||||
await this.findByIdOrThrow(id);
|
||||
if (input.name !== undefined) await this.assertNameUnique(input.name, id);
|
||||
await this.prisma.customerGroup.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.name === undefined ? {} : { name: input.name }),
|
||||
...(input.description === undefined ? {} : { description: input.description }),
|
||||
},
|
||||
});
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findByIdOrThrow(id);
|
||||
const referenced = await this.prisma.dataScope.count({ where: { targetId: id } });
|
||||
if (referenced > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '该客户组已被角色数据范围引用,无法删除;请先调整角色范围',
|
||||
});
|
||||
}
|
||||
await this.prisma.customerGroup.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async updateMembers(
|
||||
id: string,
|
||||
input: UpdateCustomerGroupMembersInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
await this.findByIdOrThrow(id);
|
||||
if (input.addCustomerIds.length > 0) await this.assertCustomersExist(input.addCustomerIds);
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.customerGroupMember.deleteMany({
|
||||
where: { groupId: id, customerId: { in: [...new Set(input.removeCustomerIds)] } },
|
||||
}),
|
||||
this.prisma.customerGroupMember.createMany({
|
||||
data: [...new Set(input.addCustomerIds)].map((customerId) => ({
|
||||
groupId: id,
|
||||
customerId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
this.prisma.customerGroup.update({ where: { id }, data: { updatedAt: new Date() } }),
|
||||
]);
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
private async addMembers(id: string, customerIds: readonly string[]): Promise<void> {
|
||||
await this.assertCustomersExist(customerIds);
|
||||
await this.prisma.customerGroupMember.createMany({
|
||||
data: [...new Set(customerIds)].map((customerId) => ({ groupId: id, customerId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async assertCustomersExist(customerIds: readonly string[]): Promise<void> {
|
||||
const found = await this.prisma.customer.findMany({
|
||||
where: { id: { in: [...new Set(customerIds)] }, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== new Set(customerIds).size) {
|
||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '存在不存在的客户' });
|
||||
}
|
||||
}
|
||||
|
||||
private async findByIdOrThrow(id: string): Promise<CustomerGroupRow> {
|
||||
const row = await this.prisma.customerGroup.findUnique({
|
||||
where: { id },
|
||||
include: GROUP_INCLUDE,
|
||||
});
|
||||
if (row === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户组不存在' });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private async assertNameUnique(name: string, excludeId: string | null): Promise<void> {
|
||||
const duplicate = await this.prisma.customerGroup.findFirst({
|
||||
where: { name, ...(excludeId === null ? {} : { id: { not: excludeId } }) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate !== null) {
|
||||
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '客户组名称已存在' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
type CreateCustomerInput,
|
||||
createCustomerInputSchema,
|
||||
type CustomerDetail,
|
||||
type CustomerListQuery,
|
||||
customerListQuerySchema,
|
||||
type CustomerSummary,
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
type SetCustomerGroupsInput,
|
||||
setCustomerGroupsInputSchema,
|
||||
type SetCustomerStatusInput,
|
||||
setCustomerStatusInputSchema,
|
||||
type TransferCustomerInput,
|
||||
transferCustomerInputSchema,
|
||||
type TransferCustomersInput,
|
||||
transferCustomersInputSchema,
|
||||
type UpdateCustomerInput,
|
||||
updateCustomerInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import type { AuthContext } from '../../common/security/auth.types';
|
||||
import {
|
||||
Audit,
|
||||
CurrentScope,
|
||||
CurrentUser,
|
||||
RequirePermissions,
|
||||
} from '../../common/security/decorators';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
@ApiTags('client-customers')
|
||||
@ApiBearerAuth()
|
||||
@Controller('customers')
|
||||
export class CustomersController {
|
||||
constructor(@Inject(CustomersService) private readonly customers: CustomersService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: '客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选)' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(customerListQuerySchema)) query: CustomerListQuery,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<ListResponse<CustomerSummary>> {
|
||||
return this.customers.list(query, scope);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: '客户详情(跨客户访问统一 404)' })
|
||||
detail(
|
||||
@Param('id') id: string,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
return this.customers.detail(id, scope);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.customer.create')
|
||||
@ApiOperation({ summary: '新建客户(简称必填;行业必须为末级行业)' })
|
||||
create(
|
||||
@Body(new ZodValidationPipe(createCustomerInputSchema)) input: CreateCustomerInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
return this.customers.create(input, requireAuth(auth).userId, scope);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.customer.update')
|
||||
@ApiOperation({ summary: '更新客户档案(所属行业不可改;暂停 / 结束只读)' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateCustomerInputSchema)) input: UpdateCustomerInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
return this.customers.update(id, input, scope, requireAuth(auth).permissions);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.customer.status')
|
||||
@ApiOperation({ summary: '客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆)' })
|
||||
setStatus(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(setCustomerStatusInputSchema)) input: SetCustomerStatusInput,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
return this.customers.setStatus(id, input, scope);
|
||||
}
|
||||
|
||||
@Post(':id/transfer')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.CUSTOMER_TRANSFER)
|
||||
@Audit('client.customer.transfer')
|
||||
@ApiOperation({ summary: '变更客户负责人(写审计)' })
|
||||
transfer(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(transferCustomerInputSchema)) input: TransferCustomerInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
return this.customers.transfer(id, input, scope, requireAuth(auth).permissions);
|
||||
}
|
||||
|
||||
@Put(':id/groups')
|
||||
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE, Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.customer.groups')
|
||||
@ApiOperation({ summary: '设置客户所属客户组(覆盖式)' })
|
||||
setGroups(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(setCustomerGroupsInputSchema)) input: SetCustomerGroupsInput,
|
||||
@CurrentScope() scope: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
return this.customers.setGroups(id, input, scope);
|
||||
}
|
||||
|
||||
@Post(':id/apply-industry-pack')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.customer.apply_industry_pack')
|
||||
@ApiOperation({ summary: '套用行业资产包(B0 批次提供;当前返回业务提示)' })
|
||||
applyIndustryPack(): never {
|
||||
return this.customers.applyIndustryPack();
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户停用前的客户转交:保留 A1 路径 /users/{id}/transfer-customers,由 client 模块落地 */
|
||||
@ApiTags('client-customers')
|
||||
@ApiBearerAuth()
|
||||
@Controller('users')
|
||||
export class CustomerTransferController {
|
||||
constructor(@Inject(CustomersService) private readonly customers: CustomersService) {}
|
||||
|
||||
@Post(':id/transfer-customers')
|
||||
@HttpCode(200)
|
||||
@RequirePermissions(Permission.CUSTOMER_TRANSFER, Permission.USER_MANAGE)
|
||||
@Audit('identity.user.transfer_customers')
|
||||
@ApiOperation({ summary: '把用户名下客户转交给另一负责人' })
|
||||
async transfer(
|
||||
@Param('id') fromUserId: string,
|
||||
@Body(new ZodValidationPipe(transferCustomersInputSchema)) input: TransferCustomersInput,
|
||||
@CurrentUser() auth: AuthContext | null,
|
||||
): Promise<{ transferred: number }> {
|
||||
return this.customers.transferByUser(
|
||||
fromUserId,
|
||||
input.toUserId,
|
||||
input.customerIds,
|
||||
requireAuth(auth).permissions,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 客户状态机 / 权限与字段上限集中校验,拆分会丢失事务边界 */
|
||||
import {
|
||||
type CreateCustomerInput,
|
||||
CUSTOMER_BASE_FIELD_LIMIT,
|
||||
type CustomerDetail,
|
||||
type CustomerListQuery,
|
||||
type CustomerSummary,
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
type SetCustomerGroupsInput,
|
||||
type SetCustomerStatusInput,
|
||||
type TransferCustomerInput,
|
||||
ulid,
|
||||
type UpdateCustomerInput,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { ScopeContext } from '../../common/security/scope/scope-context';
|
||||
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import { type CustomerSummaryRow, toCustomerDetail, toCustomerSummary } from './client.mapper';
|
||||
import { IndustriesService } from './industries.service';
|
||||
|
||||
const CUSTOMER_INCLUDE = {
|
||||
industry: { include: { parent: true } },
|
||||
owner: { select: { name: true } },
|
||||
groupMembers: { include: { group: true } },
|
||||
fields: { select: { entersGeneration: true, status: true } },
|
||||
} satisfies Prisma.CustomerInclude;
|
||||
|
||||
const CUSTOMER_DETAIL_INCLUDE = {
|
||||
...CUSTOMER_INCLUDE,
|
||||
fields: {
|
||||
include: { applicant: { select: { name: true } }, approver: { select: { name: true } } },
|
||||
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
|
||||
},
|
||||
} satisfies Prisma.CustomerInclude;
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
||||
}
|
||||
|
||||
function scopeWhere(scope: ScopeContext): Prisma.CustomerWhereInput {
|
||||
return scope.customerIds === null ? {} : { id: { in: [...scope.customerIds] } };
|
||||
}
|
||||
|
||||
function hasPermission(permissions: readonly string[], code: string): boolean {
|
||||
return permissions.includes(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户档案(A3-BE-02 / A3-BE-05):状态机、负责人 / 对接人、交付偏好、
|
||||
* 客户组归属、客户转交;所有读写都带 ScopeContext 客户维度。
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(
|
||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||
@Inject(IndustriesService) private readonly industries: IndustriesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: CustomerListQuery,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<ListResponse<CustomerSummary>> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
const industryIds =
|
||||
query.industry_id === undefined
|
||||
? undefined
|
||||
: await this.industrySubtreeIds(query.industry_id);
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
...scopeWhere(scope),
|
||||
...(query.keyword === undefined
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ name: { contains: query.keyword, mode: 'insensitive' } },
|
||||
{ shortName: { contains: query.keyword, mode: 'insensitive' } },
|
||||
],
|
||||
}),
|
||||
...(industryIds === undefined ? {} : { industryId: { in: industryIds } }),
|
||||
...(query.status === 'all'
|
||||
? {}
|
||||
: {
|
||||
status:
|
||||
query.status === 'active'
|
||||
? 'ACTIVE'
|
||||
: query.status === 'paused'
|
||||
? 'PAUSED'
|
||||
: 'ENDED',
|
||||
}),
|
||||
...(query.owner_user_id === undefined ? {} : { ownerUserId: query.owner_user_id }),
|
||||
...(query.group_id === undefined
|
||||
? {}
|
||||
: { groupMembers: { some: { groupId: query.group_id } } }),
|
||||
},
|
||||
include: CUSTOMER_INCLUDE,
|
||||
orderBy: { id: 'desc' },
|
||||
take: query.limit + 1,
|
||||
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
|
||||
});
|
||||
const page = rows.slice(0, query.limit);
|
||||
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
|
||||
return {
|
||||
items: page.map((row) => toCustomerSummary(row as CustomerSummaryRow)),
|
||||
next_cursor: next,
|
||||
};
|
||||
}
|
||||
|
||||
async detail(id: string, scopeInput: ScopeContext | null): Promise<CustomerDetail> {
|
||||
return this.findVisibleOrThrow(id, requireScopeContext(scopeInput));
|
||||
}
|
||||
|
||||
async create(
|
||||
input: CreateCustomerInput,
|
||||
actorId: string,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
await this.industries.assertLeafIndustry(input.industryId);
|
||||
await this.ensureOwner(input.ownerUserId);
|
||||
const baseFields = input.baseFields ?? [];
|
||||
this.assertBaseFieldLimit(baseFields.length);
|
||||
const id = ulid();
|
||||
try {
|
||||
await this.prisma.customer.create({
|
||||
data: {
|
||||
id,
|
||||
industryId: input.industryId,
|
||||
name: input.name,
|
||||
shortName: input.shortName,
|
||||
ownerUserId: input.ownerUserId,
|
||||
contactName: input.contactName ?? null,
|
||||
contactPhone: input.contactPhone ?? null,
|
||||
preferences: input.preferences ?? {},
|
||||
customFields: baseFields,
|
||||
remark: input.remark ?? null,
|
||||
createdBy: actorId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||
message: '该末级行业下已存在同名客户',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.detail(id, scope);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdateCustomerInput,
|
||||
scopeInput: ScopeContext | null,
|
||||
permissions: readonly string[],
|
||||
): Promise<CustomerDetail> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
const existing = await this.findRow(id);
|
||||
this.assertVisible(scope, id);
|
||||
if (existing.status !== 'ACTIVE') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: existing.status === 'PAUSED' ? '客户已暂停,档案只读' : '客户已结束,数据只读归档',
|
||||
});
|
||||
}
|
||||
if (
|
||||
input.ownerUserId !== undefined &&
|
||||
input.ownerUserId !== existing.ownerUserId &&
|
||||
!hasPermission(permissions, Permission.CUSTOMER_TRANSFER)
|
||||
) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PERMISSION_DENIED,
|
||||
message: '变更负责人需要 customer:transfer 权限',
|
||||
});
|
||||
}
|
||||
if (input.ownerUserId !== undefined && input.ownerUserId !== existing.ownerUserId) {
|
||||
await this.ensureOwner(input.ownerUserId);
|
||||
}
|
||||
if (input.baseFields !== undefined) this.assertBaseFieldLimit(input.baseFields.length);
|
||||
if (input.name !== undefined && input.name !== existing.name) {
|
||||
const duplicate = await this.prisma.customer.findFirst({
|
||||
where: {
|
||||
industryId: existing.industryId,
|
||||
name: input.name,
|
||||
id: { not: id },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate !== null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||
message: '该末级行业下已存在同名客户',
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.prisma.customer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.name === undefined ? {} : { name: input.name }),
|
||||
...(input.shortName === undefined ? {} : { shortName: input.shortName }),
|
||||
...(input.ownerUserId === undefined ? {} : { ownerUserId: input.ownerUserId }),
|
||||
...(input.contactName === undefined ? {} : { contactName: input.contactName }),
|
||||
...(input.contactPhone === undefined ? {} : { contactPhone: input.contactPhone }),
|
||||
...(input.preferences === undefined ? {} : { preferences: input.preferences }),
|
||||
...(input.baseFields === undefined ? {} : { customFields: input.baseFields }),
|
||||
...(input.remark === undefined ? {} : { remark: input.remark }),
|
||||
},
|
||||
});
|
||||
return this.detail(id, scope);
|
||||
}
|
||||
|
||||
async setStatus(
|
||||
id: string,
|
||||
input: SetCustomerStatusInput,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
const existing = await this.findRow(id);
|
||||
this.assertVisible(scope, id);
|
||||
if (existing.status === 'ENDED') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '客户已结束,不能再变更状态',
|
||||
});
|
||||
}
|
||||
if (existing.status === input.status) {
|
||||
return this.detail(id, scope);
|
||||
}
|
||||
const target =
|
||||
input.status === 'active' ? 'ACTIVE' : input.status === 'paused' ? 'PAUSED' : 'ENDED';
|
||||
await this.prisma.customer.update({ where: { id }, data: { status: target } });
|
||||
return this.detail(id, scope);
|
||||
}
|
||||
|
||||
async transfer(
|
||||
id: string,
|
||||
input: TransferCustomerInput,
|
||||
scopeInput: ScopeContext | null,
|
||||
permissions: readonly string[],
|
||||
): Promise<CustomerDetail> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
if (!hasPermission(permissions, Permission.CUSTOMER_TRANSFER)) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
|
||||
message: '缺少客户转交权限',
|
||||
});
|
||||
}
|
||||
const existing = await this.findRow(id);
|
||||
this.assertVisible(scope, id);
|
||||
if (existing.status === 'ENDED') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '客户已结束,不能转交',
|
||||
});
|
||||
}
|
||||
await this.ensureOwner(input.ownerUserId);
|
||||
await this.prisma.customer.update({ where: { id }, data: { ownerUserId: input.ownerUserId } });
|
||||
return this.detail(id, scope);
|
||||
}
|
||||
|
||||
/** 用户停用前批量转交:只转交 from 用户名下、且在指定列表中的客户 */
|
||||
async transferByUser(
|
||||
fromUserId: string,
|
||||
toUserId: string,
|
||||
customerIds: readonly string[] | undefined,
|
||||
permissions: readonly string[],
|
||||
): Promise<{ transferred: number }> {
|
||||
if (
|
||||
!hasPermission(permissions, Permission.CUSTOMER_TRANSFER) &&
|
||||
!hasPermission(permissions, Permission.USER_MANAGE)
|
||||
) {
|
||||
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '缺少客户转交权限' });
|
||||
}
|
||||
await this.ensureOwner(toUserId);
|
||||
const result = await this.prisma.customer.updateMany({
|
||||
where: {
|
||||
ownerUserId: fromUserId,
|
||||
deletedAt: null,
|
||||
status: { not: 'ENDED' },
|
||||
...(customerIds === undefined ? {} : { id: { in: [...customerIds] } }),
|
||||
},
|
||||
data: { ownerUserId: toUserId },
|
||||
});
|
||||
return { transferred: result.count };
|
||||
}
|
||||
|
||||
async setGroups(
|
||||
id: string,
|
||||
input: SetCustomerGroupsInput,
|
||||
scopeInput: ScopeContext | null,
|
||||
): Promise<CustomerDetail> {
|
||||
const scope = requireScopeContext(scopeInput);
|
||||
await this.findRow(id);
|
||||
this.assertVisible(scope, id);
|
||||
if (input.groupIds.length > 0) {
|
||||
const found = await this.prisma.customerGroup.findMany({
|
||||
where: { id: { in: input.groupIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== new Set(input.groupIds).size) {
|
||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '客户组不存在' });
|
||||
}
|
||||
}
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.customerGroupMember.deleteMany({ where: { customerId: id } }),
|
||||
this.prisma.customerGroupMember.createMany({
|
||||
data: [...new Set(input.groupIds)].map((groupId) => ({ groupId, customerId: id })),
|
||||
}),
|
||||
]);
|
||||
return this.detail(id, scope);
|
||||
}
|
||||
|
||||
applyIndustryPack(): never {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '行业资产包将在 B0 批次提供;当前可先完成客户创建',
|
||||
});
|
||||
}
|
||||
|
||||
private async findRow(id: string): Promise<{
|
||||
id: string;
|
||||
status: string;
|
||||
ownerUserId: string;
|
||||
industryId: string;
|
||||
name: string;
|
||||
}> {
|
||||
const row = await this.prisma.customer.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: { id: true, status: true, ownerUserId: true, industryId: true, name: true },
|
||||
});
|
||||
if (row === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private async findVisibleOrThrow(id: string, scope: ScopeContext): Promise<CustomerDetail> {
|
||||
if (!isCustomerVisible(scope, id)) {
|
||||
// A3-BE-05:跨客户访问统一 404,不暴露存在性
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
const row = await this.prisma.customer.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: CUSTOMER_DETAIL_INCLUDE,
|
||||
});
|
||||
if (row === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
return toCustomerDetail(row);
|
||||
}
|
||||
|
||||
private assertVisible(scope: ScopeContext, id: string): void {
|
||||
if (!isCustomerVisible(scope, id)) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureOwner(userId: string): Promise<void> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: userId, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (user === null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: '客户负责人不存在或已停用',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private assertBaseFieldLimit(count: number): void {
|
||||
if (count > CUSTOMER_BASE_FIELD_LIMIT) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: `基础字段最多 ${CUSTOMER_BASE_FIELD_LIMIT} 个,当前 ${count} 个`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async industrySubtreeIds(industryId: string): Promise<string[]> {
|
||||
const industry = await this.prisma.industry.findUnique({
|
||||
where: { id: industryId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (industry === null) {
|
||||
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '行业不存在' });
|
||||
}
|
||||
const children = await this.prisma.industry.findMany({
|
||||
where: { parentId: industryId },
|
||||
select: { id: true },
|
||||
});
|
||||
return [industryId, ...children.map((child) => child.id)];
|
||||
}
|
||||
|
||||
/** 供其他模块复用的归属校验(A3-BE-05):不泄露跨客户资源存在性 */
|
||||
async assertCustomerVisible(customerId: string, scope: ScopeContext): Promise<void> {
|
||||
if (!isCustomerVisible(scope, customerId)) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
const count = await this.prisma.customer.count({ where: { id: customerId, deletedAt: null } });
|
||||
if (count === 0) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
type CreateIndustryInput,
|
||||
createIndustryInputSchema,
|
||||
type IndustryDetail,
|
||||
type IndustryListQuery,
|
||||
industryListQuerySchema,
|
||||
type IndustryTreeItem,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
type SetIndustryStatusInput,
|
||||
setIndustryStatusInputSchema,
|
||||
type UpdateIndustryInput,
|
||||
updateIndustryInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { Audit, RequirePermissions } from '../../common/security/decorators';
|
||||
import { IndustriesService } from './industries.service';
|
||||
|
||||
@ApiTags('client-industries')
|
||||
@ApiBearerAuth()
|
||||
@Controller('industries')
|
||||
export class IndustriesController {
|
||||
constructor(@Inject(IndustriesService) private readonly industries: IndustriesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: '两级行业树(一级 → 二级)' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(industryListQuerySchema)) query: IndustryListQuery,
|
||||
): Promise<ListResponse<IndustryTreeItem>> {
|
||||
return this.industries.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(Permission.CUSTOMER_READ)
|
||||
@ApiOperation({ summary: '行业详情与引用(子行业 / 客户 / 资产)' })
|
||||
detail(@Param('id') id: string): Promise<IndustryDetail> {
|
||||
return this.industries.detail(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.industry.create')
|
||||
@ApiOperation({ summary: '新建一级 / 二级行业' })
|
||||
create(
|
||||
@Body(new ZodValidationPipe(createIndustryInputSchema)) input: CreateIndustryInput,
|
||||
): Promise<IndustryDetail> {
|
||||
return this.industries.create(input);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.industry.update')
|
||||
@ApiOperation({ summary: '更新行业名称 / 排序 / 备注(层级与上级不可变)' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateIndustryInputSchema)) input: UpdateIndustryInput,
|
||||
): Promise<IndustryDetail> {
|
||||
return this.industries.update(id, input);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.industry.status')
|
||||
@ApiOperation({ summary: '停用 / 启用行业(有启用子行业时禁止停用)' })
|
||||
setStatus(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(setIndustryStatusInputSchema)) input: SetIndustryStatusInput,
|
||||
): Promise<IndustryDetail> {
|
||||
return this.industries.setStatus(id, input);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePermissions(Permission.CUSTOMER_MANAGE)
|
||||
@Audit('client.industry.delete')
|
||||
@ApiOperation({ summary: '删除行业(仅无子行业 / 客户 / 资产时)' })
|
||||
async remove(@Param('id') id: string): Promise<void> {
|
||||
await this.industries.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
type CreateIndustryInput,
|
||||
ErrorCode,
|
||||
type IndustryDetail,
|
||||
type IndustryListQuery,
|
||||
type IndustryReferences,
|
||||
type IndustryTreeItem,
|
||||
type ListResponse,
|
||||
type SetIndustryStatusInput,
|
||||
ulid,
|
||||
type UpdateIndustryInput,
|
||||
} from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import { type IndustryRow, toIndustryNode } from './client.mapper';
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
||||
}
|
||||
|
||||
/**
|
||||
* 行业目录(A3-BE-01 / ADR-002):两级树、名称唯一、停用 / 删除引用保护。
|
||||
* 资产数在 B1 资产表落地前固定为 0;接口已保留字段,后续接入不改契约。
|
||||
*/
|
||||
@Injectable()
|
||||
export class IndustriesService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: IndustryListQuery): Promise<ListResponse<IndustryTreeItem>> {
|
||||
const rows = await this.prisma.industry.findMany({
|
||||
where: {
|
||||
...(query.keyword === undefined
|
||||
? {}
|
||||
: { name: { contains: query.keyword, mode: 'insensitive' } }),
|
||||
...(query.status === 'all'
|
||||
? {}
|
||||
: { status: query.status === 'active' ? 'ACTIVE' : 'DISABLED' }),
|
||||
...(query.parent_id === undefined ? {} : { parentId: query.parent_id }),
|
||||
},
|
||||
orderBy: [{ level: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
const counts = await this.customerCounts();
|
||||
const roots = rows.filter((row) => row.level === 1);
|
||||
const childrenByParent = new Map<string, IndustryRow[]>();
|
||||
for (const row of rows) {
|
||||
if (row.level !== 2 || row.parentId === null) continue;
|
||||
const list = childrenByParent.get(row.parentId) ?? [];
|
||||
list.push(row);
|
||||
childrenByParent.set(row.parentId, list);
|
||||
}
|
||||
const items = roots.map((root) => {
|
||||
const children = (childrenByParent.get(root.id) ?? []).map((child) =>
|
||||
toIndustryNode(child, counts.get(child.id) ?? 0),
|
||||
);
|
||||
const own = counts.get(root.id) ?? 0;
|
||||
const total = children.reduce((sum, child) => sum + child.customerCount, own);
|
||||
return {
|
||||
...toIndustryNode(root, total),
|
||||
children,
|
||||
};
|
||||
});
|
||||
return { items, next_cursor: null };
|
||||
}
|
||||
|
||||
async detail(id: string): Promise<IndustryDetail> {
|
||||
const row = await this.findOrThrow(id);
|
||||
const parent =
|
||||
row.parentId === null
|
||||
? null
|
||||
: await this.prisma.industry.findUnique({ where: { id: row.parentId } });
|
||||
const references = await this.references(id);
|
||||
return {
|
||||
...toIndustryNode(row, references.customerCount),
|
||||
parentName: parent?.name ?? null,
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
async references(id: string): Promise<IndustryReferences> {
|
||||
const children = await this.prisma.industry.findMany({
|
||||
where: { parentId: id },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
const customerWhere: Prisma.CustomerWhereInput = {
|
||||
deletedAt: null,
|
||||
OR: [{ industryId: id }, { industry: { parentId: id } }],
|
||||
};
|
||||
const [customerCount, customers] = await Promise.all([
|
||||
this.prisma.customer.count({ where: customerWhere }),
|
||||
this.prisma.customer.findMany({
|
||||
where: customerWhere,
|
||||
select: { id: true, name: true, status: true },
|
||||
take: 20,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
childCount: children.length,
|
||||
activeChildCount: children.filter((child) => child.status === 'ACTIVE').length,
|
||||
customerCount,
|
||||
customers: customers.map((customer) => ({
|
||||
id: customer.id,
|
||||
name: customer.name,
|
||||
status:
|
||||
customer.status === 'PAUSED'
|
||||
? 'paused'
|
||||
: customer.status === 'ENDED'
|
||||
? 'ended'
|
||||
: 'active',
|
||||
})),
|
||||
assetCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async create(input: CreateIndustryInput): Promise<IndustryDetail> {
|
||||
const level = input.parentId === undefined || input.parentId === null ? 1 : 2;
|
||||
if (level === 2 && input.parentId !== undefined && input.parentId !== null) {
|
||||
const parent = await this.findOrThrow(input.parentId);
|
||||
if (parent.level !== 1) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.PARAM_INVALID,
|
||||
message: '行业最多两级,二级行业下不能再建子行业',
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.assertNameUnique(input.name, level === 1 ? null : (input.parentId ?? null), null);
|
||||
const id = ulid();
|
||||
try {
|
||||
await this.prisma.industry.create({
|
||||
data: {
|
||||
id,
|
||||
parentId: level === 1 ? null : (input.parentId ?? null),
|
||||
level,
|
||||
name: input.name,
|
||||
sortOrder: input.sortOrder,
|
||||
remark: input.remark,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||
message: level === 1 ? '行业名称已存在' : '该上级行业下已存在同名行业',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateIndustryInput): Promise<IndustryDetail> {
|
||||
const existing = await this.findOrThrow(id);
|
||||
if (input.name !== undefined && input.name !== existing.name) {
|
||||
await this.assertNameUnique(input.name, existing.parentId, id);
|
||||
}
|
||||
await this.prisma.industry.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.name === undefined ? {} : { name: input.name }),
|
||||
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
|
||||
...(input.remark === undefined ? {} : { remark: input.remark }),
|
||||
},
|
||||
});
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async setStatus(id: string, input: SetIndustryStatusInput): Promise<IndustryDetail> {
|
||||
const existing = await this.findOrThrow(id);
|
||||
if (existing.status === 'DISABLED' && input.status === 'active') {
|
||||
const parent =
|
||||
existing.parentId === null
|
||||
? null
|
||||
: await this.prisma.industry.findUnique({ where: { id: existing.parentId } });
|
||||
if (parent !== null && parent.status === 'DISABLED') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: '上级行业已停用,请先启用上级行业',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (input.status === 'disabled') {
|
||||
const activeChildren = await this.prisma.industry.count({
|
||||
where: { parentId: id, status: 'ACTIVE' },
|
||||
});
|
||||
if (activeChildren > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||||
message: `该行业下仍有 ${activeChildren} 个启用中的二级行业,请先停用或调整子级`,
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.prisma.industry.update({
|
||||
where: { id },
|
||||
data: { status: input.status === 'active' ? 'ACTIVE' : 'DISABLED' },
|
||||
});
|
||||
return this.detail(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const references = await this.references(id);
|
||||
if (references.childCount > 0 || references.customerCount > 0 || references.assetCount > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: `无法删除:该行业下有 ${references.childCount} 个二级行业、${references.customerCount} 个客户`,
|
||||
details: references,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await this.prisma.industry.delete({ where: { id } });
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2003') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '该行业仍被引用,无法删除,只能停用',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 客户归属校验:客户只能挂末级行业(A3-BE-02 / ADR-002 D2) */
|
||||
async assertLeafIndustry(id: string): Promise<void> {
|
||||
const industry = await this.findOrThrow(id);
|
||||
if (industry.status === 'DISABLED') {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '行业已停用,新建客户不可选择',
|
||||
});
|
||||
}
|
||||
if (industry.level === 1) {
|
||||
const childCount = await this.prisma.industry.count({ where: { parentId: id } });
|
||||
if (childCount > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
message: '该一级行业下存在二级行业,客户只能归属到末级行业',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async findOrThrow(id: string): Promise<IndustryRow> {
|
||||
const row = await this.prisma.industry.findUnique({ where: { id } });
|
||||
if (row === null) {
|
||||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '行业不存在' });
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private async assertNameUnique(
|
||||
name: string,
|
||||
parentId: string | null,
|
||||
excludeId: string | null,
|
||||
): Promise<void> {
|
||||
const duplicate = await this.prisma.industry.findFirst({
|
||||
where: {
|
||||
name,
|
||||
parentId,
|
||||
...(excludeId === null ? {} : { id: { not: excludeId } }),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate !== null) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||
message: parentId === null ? '行业名称已存在' : '该上级行业下已存在同名行业',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async customerCounts(): Promise<Map<string, number>> {
|
||||
const grouped = await this.prisma.customer.groupBy({
|
||||
by: ['industryId'],
|
||||
where: { deletedAt: null },
|
||||
_count: { _all: true },
|
||||
});
|
||||
return new Map(grouped.map((item) => [item.industryId, item._count._all]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { CustomerDirectory } from '../../common/security/scope/customer-directory';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
|
||||
/**
|
||||
* 真实客户目录(A3 替换 A2 的 EmptyCustomerDirectory)。
|
||||
* 只读,供 ScopeResolver 解析六类数据范围;不参与业务写操作。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrismaCustomerDirectory implements CustomerDirectory {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async findOwnedCustomerIds(userId: string): Promise<string[]> {
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: { ownerUserId: userId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async findCustomerGroupCustomerIds(customerGroupIds: readonly string[]): Promise<string[]> {
|
||||
if (customerGroupIds.length === 0) return [];
|
||||
const rows = await this.prisma.customerGroupMember.findMany({
|
||||
where: { groupId: { in: [...customerGroupIds] }, customer: { deletedAt: null } },
|
||||
select: { customerId: true },
|
||||
});
|
||||
return [...new Set(rows.map((row) => row.customerId))].sort();
|
||||
}
|
||||
|
||||
async findDepartmentCustomerIds(departmentIds: readonly string[]): Promise<string[]> {
|
||||
if (departmentIds.length === 0) return [];
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
owner: { departmentId: { in: [...departmentIds] } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async findCreatedCustomerIds(userId: string): Promise<string[]> {
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: { createdBy: userId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
}
|
||||
@@ -616,7 +616,7 @@ describe('identity 集成(真实 PG / Redis)', () => {
|
||||
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);
|
||||
expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]);
|
||||
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;
|
||||
expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']);
|
||||
for (const scope of scopes) {
|
||||
expect(scope.customerIds).toEqual([]);
|
||||
expect(Array.isArray(scope.customerIds)).toBe(true);
|
||||
}
|
||||
// A3 起 CustomerDirectory 返回真实客户;本用例用户没有负责 / 创建的客户
|
||||
expect(scopes.find((scope) => scope.type === 'owned_customers')?.customerIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -259,8 +259,6 @@ describe('UsersService', () => {
|
||||
const disabled = userFixture({ status: 'DISABLED' });
|
||||
const service2 = serviceOf({ user: { findUnique: vi.fn().mockResolvedValue(disabled) } });
|
||||
await expectRejected(service2.disable('u1', {}, 'u2', []), ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
expect(() => service.transferCustomers()).toThrow(AppException);
|
||||
});
|
||||
|
||||
it('停用成功会同步吊销 refresh token;启用恢复状态', async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ErrorCode,
|
||||
type ListResponse,
|
||||
Permission,
|
||||
transferCustomersInputSchema,
|
||||
type UpdateUserInput,
|
||||
updateUserInputSchema,
|
||||
type UserDetail,
|
||||
@@ -110,15 +109,4 @@ export class UsersController {
|
||||
): Promise<UserDetail> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -180,14 +180,6 @@ export class UsersService {
|
||||
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 人):只记录告警,不阻断保存;
|
||||
* 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -28,4 +28,28 @@ export const ENDPOINTS = {
|
||||
roles: '/roles',
|
||||
role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`,
|
||||
permissions: '/permissions',
|
||||
// A3 client
|
||||
industries: '/industries',
|
||||
industry: (industryId: string): string => `/industries/${encodeURIComponent(industryId)}`,
|
||||
industryStatus: (industryId: string): string =>
|
||||
`/industries/${encodeURIComponent(industryId)}/status`,
|
||||
customers: '/customers',
|
||||
customer: (customerId: string): string => `/customers/${encodeURIComponent(customerId)}`,
|
||||
customerStatus: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/status`,
|
||||
customerTransfer: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/transfer`,
|
||||
customerGroups: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/groups`,
|
||||
customerApplyPack: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/apply-industry-pack`,
|
||||
customerGroupsList: '/customer-groups',
|
||||
customerGroup: (groupId: string): string => `/customer-groups/${encodeURIComponent(groupId)}`,
|
||||
customerGroupMembers: (groupId: string): string =>
|
||||
`/customer-groups/${encodeURIComponent(groupId)}/members`,
|
||||
customerFields: '/customer-fields',
|
||||
customerFieldsOf: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/fields`,
|
||||
customerFieldApprove: (fieldId: string): string =>
|
||||
`/customer-fields/${encodeURIComponent(fieldId)}/approve`,
|
||||
} as const;
|
||||
+706
-37
@@ -177,26 +177,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}/transfer-customers': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 客户转交(占位)
|
||||
* @description A3 客户模块落地前返回 42201
|
||||
*/
|
||||
post: operations['UsersController_transferCustomers'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/departments/tree': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -366,6 +346,287 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/industries': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 两级行业树(一级 → 二级) */
|
||||
get: operations['IndustriesController_list'];
|
||||
put?: never;
|
||||
/** 新建一级 / 二级行业 */
|
||||
post: operations['IndustriesController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/industries/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 行业详情与引用(子行业 / 客户 / 资产) */
|
||||
get: operations['IndustriesController_detail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** 删除行业(仅无子行业 / 客户 / 资产时) */
|
||||
delete: operations['IndustriesController_remove'];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 更新行业名称 / 排序 / 备注(层级与上级不可变) */
|
||||
patch: operations['IndustriesController_update'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/industries/{id}/status': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 停用 / 启用行业(有启用子行业时禁止停用) */
|
||||
patch: operations['IndustriesController_setStatus'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选) */
|
||||
get: operations['CustomersController_list'];
|
||||
put?: never;
|
||||
/** 新建客户(简称必填;行业必须为末级行业) */
|
||||
post: operations['CustomersController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户详情(跨客户访问统一 404) */
|
||||
get: operations['CustomersController_detail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 更新客户档案(所属行业不可改;暂停 / 结束只读) */
|
||||
patch: operations['CustomersController_update'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/status': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆) */
|
||||
patch: operations['CustomersController_setStatus'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/transfer': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 变更客户负责人(写审计) */
|
||||
post: operations['CustomersController_transfer'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/groups': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** 设置客户所属客户组(覆盖式) */
|
||||
put: operations['CustomersController_setGroups'];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/apply-industry-pack': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 套用行业资产包(B0 批次提供;当前返回业务提示) */
|
||||
post: operations['CustomersController_applyIndustryPack'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}/transfer-customers': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 把用户名下客户转交给另一负责人 */
|
||||
post: operations['CustomerTransferController_transfer'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-groups': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户组列表(授权与数据范围最小单位) */
|
||||
get: operations['CustomerGroupsController_list'];
|
||||
put?: never;
|
||||
/** 新建客户组 */
|
||||
post: operations['CustomerGroupsController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-groups/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户组详情与成员 */
|
||||
get: operations['CustomerGroupsController_detail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** 删除客户组(被角色数据范围引用时拒绝) */
|
||||
delete: operations['CustomerGroupsController_remove'];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 更新客户组名称 / 说明 */
|
||||
patch: operations['CustomerGroupsController_update'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-groups/{id}/members': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 批量添加 / 移除成员(幂等) */
|
||||
post: operations['CustomerGroupsController_updateMembers'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-fields': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 字段审批队列(待审批 / 已生效 / 已驳回) */
|
||||
get: operations['CustomerFieldsController_list'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-fields/{id}/approve': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 审批客户级字段(通过 / 驳回;驳回原因必填) */
|
||||
post: operations['CustomerFieldsController_approve'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/fields': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户级字段列表(基础字段在 customers.custom_fields) */
|
||||
get: operations['CustomerFieldsOfCustomerController_list'];
|
||||
put?: never;
|
||||
/** 申请客户级字段(进入生成字段需审批;每客户 ≤ 5) */
|
||||
post: operations['CustomerFieldsOfCustomerController_apply'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/health': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -684,23 +945,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_transferCustomers: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
DepartmentsController_tree: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -890,6 +1134,431 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_detail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_remove: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_setStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_detail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_setStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_transfer: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_setGroups: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_applyIndustryPack: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerTransferController_transfer: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_detail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_remove: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_updateMembers: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerFieldsController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerFieldsController_approve: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerFieldsOfCustomerController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerFieldsOfCustomerController_apply: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
HealthController_check: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -13,4 +13,17 @@ export const queryKeys = {
|
||||
roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const,
|
||||
role: (roleId: string) => ['roles', 'detail', roleId] as const,
|
||||
permissions: ['permissions'] as const,
|
||||
// A3 client
|
||||
industries: ['industries'] as const,
|
||||
industryList: (query: object) => ['industries', 'list', query] as const,
|
||||
industry: (industryId: string) => ['industries', 'detail', industryId] as const,
|
||||
customers: ['customers'] as const,
|
||||
customerList: (query: object) => ['customers', 'list', query] as const,
|
||||
customer: (customerId: string) => ['customers', 'detail', customerId] as const,
|
||||
customerFieldsOf: (customerId: string) => ['customers', 'fields', customerId] as const,
|
||||
customerGroups: ['customer-groups'] as const,
|
||||
customerGroupList: (query: object) => ['customer-groups', 'list', query] as const,
|
||||
customerGroup: (groupId: string) => ['customer-groups', 'detail', groupId] as const,
|
||||
customerFields: (query: object) => ['customer-fields', 'list', query] as const,
|
||||
customerFieldsRoot: ['customer-fields'] as const,
|
||||
};
|
||||
@@ -0,0 +1,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();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '@/styles/users.css';
|
||||
import '@/styles/roles.css';
|
||||
import '@/styles/role-editor.css';
|
||||
import '@/styles/departments.css';
|
||||
import '@/styles/client.css';
|
||||
|
||||
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||
import { localeZhCN } from '@lingxi/ui';
|
||||
|
||||
@@ -53,6 +53,20 @@ describe('导航配置', () => {
|
||||
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('按路径定位当前模块与菜单', () => {
|
||||
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
|
||||
expect(active?.module.key).toBe('material');
|
||||
|
||||
@@ -273,19 +273,27 @@ export const NAV_MODULES: readonly NavModule[] = [
|
||||
},
|
||||
{
|
||||
key: 'customers',
|
||||
label: '客户',
|
||||
label: '客户管理',
|
||||
path: '/system/customers',
|
||||
icon: IconGlobal,
|
||||
permission: Permission.CUSTOMER_MANAGE,
|
||||
description: '客户、行业归属与数据范围',
|
||||
permission: Permission.CUSTOMER_READ,
|
||||
description: '客户档案、状态与交付偏好',
|
||||
},
|
||||
{
|
||||
key: 'customer-groups',
|
||||
label: '客户组',
|
||||
path: '/system/customer-groups',
|
||||
icon: IconApps,
|
||||
permission: Permission.CUSTOMER_GROUP_MANAGE,
|
||||
description: '客户组与批量授权单位',
|
||||
},
|
||||
{
|
||||
key: 'industries',
|
||||
label: '行业树',
|
||||
label: '行业管理',
|
||||
path: '/system/industries',
|
||||
icon: IconStack,
|
||||
permission: Permission.INDUSTRY_MANAGE,
|
||||
description: '行业分类树与行业包绑定',
|
||||
permission: Permission.CUSTOMER_READ,
|
||||
description: '两级行业树、状态与客户归属',
|
||||
},
|
||||
{
|
||||
key: 'dicts',
|
||||
@@ -338,9 +346,9 @@ export const NAV_MODULES: readonly NavModule[] = [
|
||||
{
|
||||
key: 'customer-fields',
|
||||
label: '客户字段',
|
||||
path: '/system/customer-fields',
|
||||
path: '/system/customer-fields/approvals',
|
||||
icon: IconTask,
|
||||
permission: Permission.CUSTOMER_MANAGE,
|
||||
permission: Permission.CUSTOMER_FIELD_APPROVE,
|
||||
description: '客户级扩展字段与审批',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/* 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';
|
||||
|
||||
function statusTag(status: CustomerFieldListItem['status']): ReactNode {
|
||||
const cls =
|
||||
status === 'active'
|
||||
? 'active'
|
||||
: status === 'pending'
|
||||
? 'paused'
|
||||
: status === 'rejected'
|
||||
? 'ended'
|
||||
: 'disabled';
|
||||
const label =
|
||||
status === 'active'
|
||||
? '已生效'
|
||||
: status === 'pending'
|
||||
? '待审批'
|
||||
: status === 'rejected'
|
||||
? '已驳回'
|
||||
: '已停用';
|
||||
return <span className={`lx-client-tag lx-client-tag--${cls}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function ApplyModal({ onClose }: { onClose: () => void }): ReactNode {
|
||||
const customers = useCustomersQuery({ limit: 100, status: 'active' });
|
||||
const apply = useApplyCustomerFieldMutation();
|
||||
const [customerId, setCustomerId] = useState<string | undefined>();
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [purpose, setPurpose] = useState('');
|
||||
const [type, setType] = useState<'text' | 'number' | 'date' | 'select'>('text');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async (): Promise<void> => {
|
||||
if (
|
||||
customerId === undefined ||
|
||||
name.trim() === '' ||
|
||||
code.trim() === '' ||
|
||||
purpose.trim() === ''
|
||||
) {
|
||||
setError('请完整填写客户、字段名称、编码与用途');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apply.mutateAsync({
|
||||
customerId,
|
||||
input: { name, code, type, purpose, entersGeneration: true, options: [] },
|
||||
});
|
||||
notifySuccess('字段申请已提交,等待审批');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '提交失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="申请客户字段"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="提交申请"
|
||||
>
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field is-full">
|
||||
<label>申请客户</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={customerId}
|
||||
placeholder="选择客户"
|
||||
optionList={(customers.data?.items ?? []).map((customer) => ({
|
||||
value: customer.id,
|
||||
label: customer.name,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setCustomerId(String(value));
|
||||
}}
|
||||
filter
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段名称</label>
|
||||
<Input value={name} onChange={setName} placeholder="如:活动编码" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段编码</label>
|
||||
<Input value={code} onChange={setCode} placeholder="如:campaign_code" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段类型</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={type}
|
||||
optionList={[
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'date', label: '日期' },
|
||||
{ value: 'select', label: '单选' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setType(value as typeof type);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>用途说明</label>
|
||||
<Input value={purpose} onChange={setPurpose} placeholder="说明字段用途与进入生成的原因" />
|
||||
</div>
|
||||
</div>
|
||||
{error !== null ? (
|
||||
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function RejectModal({
|
||||
field,
|
||||
onClose,
|
||||
}: {
|
||||
field: CustomerFieldListItem;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const approve = useApproveCustomerFieldMutation();
|
||||
const [reason, setReason] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async (): Promise<void> => {
|
||||
if (reason.trim() === '') {
|
||||
setError('驳回原因必填');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await approve.mutateAsync({ id: field.id, input: { decision: 'reject', reason } });
|
||||
notifySuccess('已驳回,申请人将收到通知');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '驳回失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="驳回字段申请"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ type: 'danger' }}
|
||||
>
|
||||
<div className="lx-client-hint">
|
||||
{field.customerName} · {field.name}({field.code})
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>驳回原因</label>
|
||||
<Input
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
placeholder="例如:与基础字段重复,仅需检索无需进入生成"
|
||||
/>
|
||||
</div>
|
||||
{error !== null ? (
|
||||
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** 客户字段审批(选定方案 A:全宽表格 + 审批弹窗) */
|
||||
export function CustomerFieldsPage(): ReactNode {
|
||||
const [status, setStatus] = useState<'all' | 'pending' | 'active' | 'rejected'>('all');
|
||||
const fields = useCustomerFieldsQuery({ limit: 100, status });
|
||||
const approve = useApproveCustomerFieldMutation();
|
||||
const [applyOpen, setApplyOpen] = useState(false);
|
||||
const [rejectField, setRejectField] = useState<CustomerFieldListItem | null>(null);
|
||||
|
||||
const items = fields.data?.items ?? [];
|
||||
|
||||
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">
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
value={status}
|
||||
optionList={[
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'pending', label: '待审批' },
|
||||
{ value: 'active', label: '已生效' },
|
||||
{ value: 'rejected', label: '已驳回' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setStatus(value as typeof status);
|
||||
}}
|
||||
/>
|
||||
<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}</td>
|
||||
<td>{field.entersGeneration ? '是' : '否'}</td>
|
||||
<td>{field.applicantName}</td>
|
||||
<td className="lx-client-muted">
|
||||
{field.appliedAt.slice(0, 19).replace('T', ' ')}
|
||||
</td>
|
||||
<td>{statusTag(field.status)}</td>
|
||||
<td>
|
||||
{field.status === 'pending' ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setRejectField(field);
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
approveField(field);
|
||||
}}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="lx-client-muted">已处理</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="lx-client-tablefoot">
|
||||
审批与变更写入审计 · 未审批字段不进入生成 · 每客户进入生成字段 ≤ 5 个
|
||||
</div>
|
||||
</section>
|
||||
{applyOpen ? (
|
||||
<ApplyModal
|
||||
onClose={() => {
|
||||
setApplyOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{rejectField !== null ? (
|
||||
<RejectModal
|
||||
field={rejectField}
|
||||
onClose={() => {
|
||||
setRejectField(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/* 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 [modal, setModal] = useState<'create' | 'edit' | 'members' | null>(null);
|
||||
|
||||
const items = useMemo(() => groups.data?.items ?? [], [groups.data]);
|
||||
if (groups.isPending) return <PageSkeleton rows={6} />;
|
||||
|
||||
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} 个 · 覆盖 9 个客户</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
|
||||
onClick={() => {
|
||||
setModal('members');
|
||||
}}
|
||||
>
|
||||
管理成员
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModal('edit');
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="danger" onClick={deleteGroup}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-client-right__body">
|
||||
<div className="lx-client-hint">
|
||||
调整成员会立即改变引用该客户组的角色数据范围;变更写入审计。
|
||||
</div>
|
||||
<div className="lx-client-memberlist">
|
||||
{detail.data.members.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无成员</div>
|
||||
) : null}
|
||||
{detail.data.members.map((member) => (
|
||||
<div key={member.id} className="lx-client-memberrow">
|
||||
<b>{member.name}</b>
|
||||
<span>
|
||||
{member.industryPath} · {member.ownerName}
|
||||
</span>
|
||||
</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,676 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
|
||||
import { type CustomerDetail, type CustomerField, Permission } from '@lingxi/shared';
|
||||
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||
import { IconBack } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
import {
|
||||
useApplyCustomerFieldMutation,
|
||||
useCustomerDetailQuery,
|
||||
useCustomerFieldsOfQuery,
|
||||
useCustomerGroupsQuery,
|
||||
useSetCustomerGroupsMutation,
|
||||
useSetCustomerStatusMutation,
|
||||
useTransferCustomerMutation,
|
||||
useUpdateCustomerMutation,
|
||||
} from '@/hooks/queries/use-client';
|
||||
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
|
||||
import { ROUTES } from '@/routes';
|
||||
|
||||
import { CustomerStatusTag } from './CustomerStatusTag';
|
||||
|
||||
function Section({
|
||||
title,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<section className="lx-client-section">
|
||||
<div className="lx-client-section__head">
|
||||
<b>{title}</b>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldStatusTag({ field }: { field: CustomerField }): ReactNode {
|
||||
const cls =
|
||||
field.status === 'active'
|
||||
? 'active'
|
||||
: field.status === 'pending'
|
||||
? 'paused'
|
||||
: field.status === 'rejected'
|
||||
? 'ended'
|
||||
: 'disabled';
|
||||
const label =
|
||||
field.status === 'active'
|
||||
? '已生效'
|
||||
: field.status === 'pending'
|
||||
? '待审批'
|
||||
: field.status === 'rejected'
|
||||
? '已驳回'
|
||||
: '已停用';
|
||||
return <span className={`lx-client-tag lx-client-tag--${cls}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function TransferModal({
|
||||
customer,
|
||||
onClose,
|
||||
}: {
|
||||
customer: CustomerDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const users = useUserCandidatesQuery(true);
|
||||
const transfer = useTransferCustomerMutation();
|
||||
const [ownerUserId, setOwnerUserId] = useState(customer.ownerUserId);
|
||||
const submit = async (): Promise<void> => {
|
||||
try {
|
||||
await transfer.mutateAsync({ id: customer.id, input: { ownerUserId } });
|
||||
notifySuccess('客户负责人已转交,已写入审计');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '转交失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="变更客户负责人"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="确认转交"
|
||||
>
|
||||
<div className="lx-client-field">
|
||||
<label>新的客户负责人</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={ownerUserId}
|
||||
optionList={(users.data?.items ?? []).map((user) => ({
|
||||
value: user.id,
|
||||
label: `${user.name}(${user.departmentName})`,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setOwnerUserId(String(value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
转交后该客户会从原负责人的「负责客户」范围移出,并写入审计。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function BaseFieldModal({
|
||||
customer,
|
||||
onClose,
|
||||
}: {
|
||||
customer: CustomerDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const update = useUpdateCustomerMutation();
|
||||
const [key, setKey] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const submit = async (): Promise<void> => {
|
||||
if (key.trim() === '') return;
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
id: customer.id,
|
||||
input: { baseFields: [...customer.baseFields, { key: key.trim(), value: value.trim() }] },
|
||||
});
|
||||
notifySuccess('基础字段已保存');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '保存失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal visible title="添加基础字段" onCancel={onClose} onOk={() => void submit()} okText="保存">
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field">
|
||||
<label>字段键</label>
|
||||
<Input value={key} onChange={setKey} placeholder="如:门店编号" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段值</label>
|
||||
<Input value={value} onChange={setValue} placeholder="如:沪-012" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
基础字段仅用于检索与报表,永不进入生成;每客户最多 10 个。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ApplyFieldModal({
|
||||
customer,
|
||||
onClose,
|
||||
}: {
|
||||
customer: CustomerDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const apply = useApplyCustomerFieldMutation();
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [type, setType] = useState<'text' | 'number' | 'date' | 'select'>('text');
|
||||
const [purpose, setPurpose] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async (): Promise<void> => {
|
||||
if (name.trim() === '' || code.trim() === '' || purpose.trim() === '') {
|
||||
setError('请填写字段名称、编码与用途说明');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apply.mutateAsync({
|
||||
customerId: customer.id,
|
||||
input: { name, code, type, purpose, entersGeneration: true, options: [] },
|
||||
});
|
||||
notifySuccess('字段申请已提交,等待审批');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '提交失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="申请客户字段"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="提交申请"
|
||||
>
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field">
|
||||
<label>字段名称</label>
|
||||
<Input value={name} onChange={setName} placeholder="如:活动编码" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段编码</label>
|
||||
<Input value={code} onChange={setCode} placeholder="如:campaign_code" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段类型</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={type}
|
||||
optionList={[
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'date', label: '日期' },
|
||||
{ value: 'select', label: '单选' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setType(value as typeof type);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>用途说明</label>
|
||||
<Input value={purpose} onChange={setPurpose} placeholder="说明字段用途与进入生成的原因" />
|
||||
</div>
|
||||
</div>
|
||||
{error !== null ? (
|
||||
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="lx-client-hint">进入生成字段需审批后生效;每客户最多 5 个。</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupsModal({
|
||||
customer,
|
||||
onClose,
|
||||
}: {
|
||||
customer: CustomerDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const groups = useCustomerGroupsQuery();
|
||||
const update = useSetCustomerGroupsMutation();
|
||||
const [groupIds, setGroupIds] = useState<string[]>(customer.groups.map((group) => group.id));
|
||||
const submit = async (): Promise<void> => {
|
||||
try {
|
||||
await update.mutateAsync({ id: customer.id, input: { groupIds } });
|
||||
notifySuccess('客户组归属已更新');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '保存失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="管理客户所属客户组"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="保存"
|
||||
>
|
||||
<div className="lx-client-field">
|
||||
<label>客户组</label>
|
||||
<Select
|
||||
multiple
|
||||
style={{ width: '100%' }}
|
||||
value={groupIds}
|
||||
optionList={(groups.data?.items ?? []).map((group) => ({
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setGroupIds(value as string[]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
客户组是数据范围授权的最小单位;调整会立即影响引用该客户组的角色可见范围。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** 客户详情(选定方案 A:独立三栏档案页) */
|
||||
export function CustomerDetailPage(): ReactNode {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const detail = useCustomerDetailQuery(id ?? null);
|
||||
const fields = useCustomerFieldsOfQuery(id ?? null);
|
||||
const setStatus = useSetCustomerStatusMutation();
|
||||
const update = useUpdateCustomerMutation();
|
||||
const [modal, setModal] = useState<'transfer' | 'base' | 'field' | 'groups' | null>(null);
|
||||
|
||||
if (detail.isPending) return <PageSkeleton rows={8} />;
|
||||
if (detail.error) {
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<div className="lx-client-empty">
|
||||
<b>客户加载失败</b>
|
||||
<span>{detail.error.message}</span>
|
||||
<Button onClick={() => void navigate(ROUTES.system.customers)}>返回客户列表</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const customer = detail.data;
|
||||
const canWrite = customer.status === 'active';
|
||||
const changeStatus = (target: 'paused' | 'ended' | 'active'): void => {
|
||||
Modal.confirm({
|
||||
title:
|
||||
target === 'paused' ? '暂停客户服务' : target === 'ended' ? '结束客户服务' : '恢复客户服务',
|
||||
content:
|
||||
target === 'paused'
|
||||
? '暂停后档案只读,不能发起新的生成任务;可随时恢复。'
|
||||
: target === 'ended'
|
||||
? '结束后全部数据只读归档,且不能恢复;如继续合作需新建客户。'
|
||||
: '恢复后档案可编辑,并可以重新发起生成任务。',
|
||||
okButtonProps: target === 'ended' ? { type: 'danger' } : undefined,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await setStatus.mutateAsync({ id: customer.id, input: { status: target } });
|
||||
notifySuccess(
|
||||
target === 'paused'
|
||||
? '客户已暂停'
|
||||
: target === 'ended'
|
||||
? '客户已结束并归档'
|
||||
: '客户已恢复为在服务',
|
||||
);
|
||||
void detail.refetch();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '操作失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const removeBaseField = async (index: number): Promise<void> => {
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
id: customer.id,
|
||||
input: {
|
||||
baseFields: customer.baseFields.filter((_item, itemIndex) => itemIndex !== index),
|
||||
},
|
||||
});
|
||||
notifySuccess('基础字段已删除');
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '删除失败');
|
||||
}
|
||||
};
|
||||
const fieldRows = fields.data ?? customer.fields;
|
||||
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<header className="lx-client-header">
|
||||
<div>
|
||||
<a
|
||||
className="lx-client-back"
|
||||
href={ROUTES.system.customers}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate(ROUTES.system.customers);
|
||||
}}
|
||||
>
|
||||
<IconBack size={14} /> 返回客户列表
|
||||
</a>
|
||||
<h1>
|
||||
{customer.name} <CustomerStatusTag status={customer.status} />
|
||||
</h1>
|
||||
<p>
|
||||
{customer.shortName} · {customer.industryPath} · 负责人 {customer.ownerName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Can permission={Permission.CUSTOMER_TRANSFER}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModal('transfer');
|
||||
}}
|
||||
disabled={customer.status === 'ended'}
|
||||
>
|
||||
变更负责人
|
||||
</Button>
|
||||
</Can>
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
{customer.status === 'active' ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
changeStatus('paused');
|
||||
}}
|
||||
>
|
||||
暂停服务
|
||||
</Button>
|
||||
) : null}
|
||||
{customer.status === 'paused' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
changeStatus('active');
|
||||
}}
|
||||
>
|
||||
恢复服务
|
||||
</Button>
|
||||
) : null}
|
||||
{customer.status !== 'ended' ? (
|
||||
<Button
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
changeStatus('ended');
|
||||
}}
|
||||
>
|
||||
结束服务
|
||||
</Button>
|
||||
) : null}
|
||||
</Can>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{customer.status !== 'active' ? (
|
||||
<div
|
||||
className={`lx-client-banner lx-client-banner--${customer.status === 'paused' ? 'paused' : 'ended'}`}
|
||||
>
|
||||
{customer.status === 'paused'
|
||||
? '客户已暂停:档案只读,不能发起新的生成任务。恢复为「在服务」后可继续。'
|
||||
: '客户已结束服务:全部数据只读归档,不能编辑或发起任务。'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="lx-client-grid">
|
||||
<aside className="lx-client-anchors">
|
||||
<a href="#basic">基本信息</a>
|
||||
<a href="#prefs">交付偏好</a>
|
||||
<a href="#base-fields">基础字段</a>
|
||||
<a href="#gen-fields">进入生成</a>
|
||||
<a href="#groups">客户组</a>
|
||||
<a href="#asset">资产包</a>
|
||||
</aside>
|
||||
<div className="lx-client-panel">
|
||||
<Section title="基本信息">
|
||||
<div className="lx-client-kv" id="basic">
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户名称</span>
|
||||
<b>{customer.name}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户简称</span>
|
||||
<b>{customer.shortName}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>所属行业</span>
|
||||
<b>{customer.industryPath}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>服务状态</span>
|
||||
<b>
|
||||
<CustomerStatusTag status={customer.status} />
|
||||
</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户负责人</span>
|
||||
<b>{customer.ownerName}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户侧对接人</span>
|
||||
<b>
|
||||
{customer.contactName ?? '—'} {customer.contactPhone ?? ''}
|
||||
</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>创建时间</span>
|
||||
<b>{customer.createdAt.slice(0, 10)}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>最近更新</span>
|
||||
<b>{customer.updatedAt.slice(0, 19).replace('T', ' ')}</b>
|
||||
</div>
|
||||
</div>
|
||||
{customer.remark !== null ? (
|
||||
<div className="lx-client-hint">{customer.remark}</div>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section title="交付偏好">
|
||||
<div className="lx-client-kv" id="prefs">
|
||||
<div className="lx-client-kv__item">
|
||||
<span>命名习惯</span>
|
||||
<b>{customer.preferences.naming || '—'}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>目录结构</span>
|
||||
<b>{customer.preferences.directory || '—'}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
常用规格:{customer.preferences.specs.join('、') || '—'}
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
素材禁忌:{customer.preferences.taboo.join('、') || '—'}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="基础字段"
|
||||
action={
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!canWrite || customer.baseFields.length >= 10}
|
||||
onClick={() => {
|
||||
setModal('base');
|
||||
}}
|
||||
>
|
||||
添加基础字段
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
>
|
||||
<div id="base-fields">
|
||||
{customer.baseFields.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无基础字段</div>
|
||||
) : null}
|
||||
{customer.baseFields.map((field, index) => (
|
||||
<div key={`${field.key}-${index}`} className="lx-client-memberrow">
|
||||
<b>{field.key}</b>
|
||||
<span>{field.value}</span>
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
<Button
|
||||
size="small"
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
disabled={!canWrite}
|
||||
onClick={() => void removeBaseField(index)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
自由键值对,仅用于检索与报表,永不进入生成;每客户 ≤ 10 个(
|
||||
{customer.baseFields.length}/10)。
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="进入生成字段"
|
||||
action={
|
||||
<Can permission={Permission.CUSTOMER_FIELD_APPLY}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!canWrite || customer.genFieldCount >= 5}
|
||||
onClick={() => {
|
||||
setModal('field');
|
||||
}}
|
||||
>
|
||||
申请字段
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
>
|
||||
<div id="gen-fields">
|
||||
{fieldRows.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无进入生成字段</div>
|
||||
) : null}
|
||||
{fieldRows.map((field) => (
|
||||
<div key={field.id} className="lx-client-memberrow">
|
||||
<b>{field.name}</b>
|
||||
<span>
|
||||
{field.code} · {field.type} · {field.purpose}
|
||||
</span>
|
||||
<FieldStatusTag field={field} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
需申请 → 审批 → 生效;每客户 ≤ 5 个({customer.genFieldCount}/5)。
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="客户组"
|
||||
action={
|
||||
<Can permission={Permission.CUSTOMER_GROUP_MANAGE}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setModal('groups');
|
||||
}}
|
||||
>
|
||||
管理客户组
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
>
|
||||
<div className="lx-client-chips" id="groups">
|
||||
{customer.groups.length === 0 ? (
|
||||
<span className="lx-client-hint">未加入客户组</span>
|
||||
) : null}
|
||||
{customer.groups.map((group) => (
|
||||
<span key={group.id} className="lx-client-chip">
|
||||
{group.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="行业资产包">
|
||||
<div className="lx-client-assetempty" id="asset">
|
||||
<b>该行业尚未发布可用行业包</b>
|
||||
行业包发布后可在此一键套用模板、场景、人物、提示词与卖点,并替换为客户 Logo 与警示语。
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<aside className="lx-client-side">
|
||||
<section className="lx-client-sidecard">
|
||||
<h3>服务状态</h3>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>进入生成字段</span>
|
||||
<b>{customer.genFieldCount} / 5</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>基础字段</span>
|
||||
<b>{customer.baseFieldCount} / 10</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户组</span>
|
||||
<b>{customer.groups.length} 个</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>资产包</span>
|
||||
<b>待行业包发布</b>
|
||||
</div>
|
||||
</section>
|
||||
<section className="lx-client-sidecard">
|
||||
<h3>审计提示</h3>
|
||||
<div className="lx-client-hint">
|
||||
客户状态、负责人、字段与客户组的变更都会写入审计,记录前后值与操作人。
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{modal === 'transfer' ? (
|
||||
<TransferModal
|
||||
customer={customer}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{modal === 'base' ? (
|
||||
<BaseFieldModal
|
||||
customer={customer}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{modal === 'field' ? (
|
||||
<ApplyFieldModal
|
||||
customer={customer}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{modal === 'groups' ? (
|
||||
<GroupsModal
|
||||
customer={customer}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Select, Steps } from '@douyinfe/semi-ui-19';
|
||||
import type { CreateCustomerInput } 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 = ['选择行业', '基础信息', '交付偏好', '行业资产包'];
|
||||
|
||||
/** 新建客户(选定方案 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 industryOptions = useMemo(() => {
|
||||
const options: { value: string; label: string; leaf: boolean }[] = [];
|
||||
for (const root of industries.data?.items ?? []) {
|
||||
if (root.status !== 'active') continue;
|
||||
if (root.children.length === 0) {
|
||||
options.push({ value: root.id, label: root.name, leaf: true });
|
||||
} else {
|
||||
for (const child of root.children) {
|
||||
if (child.status === 'active')
|
||||
options.push({ value: child.id, label: `${root.name} / ${child.name}`, leaf: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}, [industries.data]);
|
||||
|
||||
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>
|
||||
<div className="lx-client-header__actions">
|
||||
<Button onClick={() => void navigate(ROUTES.system.customers)}>取消</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="lx-client-newgrid">
|
||||
<aside className="lx-client-stepnav">
|
||||
<Steps current={current} direction="vertical">
|
||||
{STEPS.map((title) => (
|
||||
<Steps.Step key={title} title={title} />
|
||||
))}
|
||||
</Steps>
|
||||
</aside>
|
||||
<section className="lx-client-formcard">
|
||||
<div className="lx-client-formcard__body">
|
||||
{current === 0 ? (
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field is-full">
|
||||
<label>
|
||||
客户归属行业 <i>*</i>
|
||||
</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={industryId}
|
||||
placeholder="选择末级行业"
|
||||
optionList={industryOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setIndustryId(String(value));
|
||||
}}
|
||||
/>
|
||||
<div className="lx-client-hint">
|
||||
有子行业时必须选择二级;一级行业无子行业时可直接选择。
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
onClick={() => {
|
||||
setCurrent((step) => step - 1);
|
||||
}}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
) : null}
|
||||
{current < 3 ? (
|
||||
<Button type="primary" onClick={next}>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button 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,249 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Select } from '@douyinfe/semi-ui-19';
|
||||
import { type CustomerSummary, Permission } from '@lingxi/shared';
|
||||
import { PageSkeleton } from '@lingxi/ui';
|
||||
import { IconAdd, IconSearch } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
import {
|
||||
useCustomerGroupsQuery,
|
||||
useCustomersQuery,
|
||||
useIndustriesQuery,
|
||||
} from '@/hooks/queries/use-client';
|
||||
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
|
||||
import { customerDetailPath, customerNewPath, ROUTES } from '@/routes';
|
||||
|
||||
import { CustomerStatusTag } from './CustomerStatusTag';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'active', label: '在服务' },
|
||||
{ value: 'paused', label: '暂停' },
|
||||
{ value: 'ended', label: '结束' },
|
||||
];
|
||||
|
||||
/** 客户管理(选定方案 A:全宽表格 + 独立详情页) */
|
||||
export function CustomersPage(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [industryId, setIndustryId] = useState<string | undefined>();
|
||||
const [status, setStatus] = useState<'all' | 'active' | 'paused' | 'ended'>('all');
|
||||
const [ownerUserId, setOwnerUserId] = useState<string | undefined>();
|
||||
const [groupId, setGroupId] = useState<string | undefined>();
|
||||
|
||||
const industries = useIndustriesQuery({ limit: 100 });
|
||||
const groups = useCustomerGroupsQuery();
|
||||
const users = useUserCandidatesQuery(true);
|
||||
const customers = useCustomersQuery({
|
||||
limit: 100,
|
||||
...(keyword.trim() === '' ? {} : { keyword: keyword.trim() }),
|
||||
...(industryId === undefined ? {} : { industry_id: industryId }),
|
||||
...(status === 'all' ? {} : { status }),
|
||||
...(ownerUserId === undefined ? {} : { owner_user_id: ownerUserId }),
|
||||
...(groupId === undefined ? {} : { group_id: groupId }),
|
||||
});
|
||||
|
||||
const industryOptions = useMemo(() => {
|
||||
const options: { value: string; label: string }[] = [];
|
||||
for (const root of industries.data?.items ?? []) {
|
||||
options.push({ value: root.id, label: root.name });
|
||||
for (const child of root.children) {
|
||||
options.push({ value: child.id, label: `${root.name} / ${child.name}` });
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}, [industries.data]);
|
||||
|
||||
const rows = customers.data?.items ?? [];
|
||||
|
||||
const 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
|
||||
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
|
||||
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>
|
||||
) : (
|
||||
<table className="lx-client-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户</th>
|
||||
<th>所属行业</th>
|
||||
<th>状态</th>
|
||||
<th>负责人</th>
|
||||
<th>客户组</th>
|
||||
<th>进入生成</th>
|
||||
<th>基础字段</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((customer) => (
|
||||
<tr
|
||||
key={customer.id}
|
||||
onClick={() => {
|
||||
openDetail(customer);
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<b>{customer.name}</b>
|
||||
<span className="lx-client-sub">{customer.shortName}</span>
|
||||
</td>
|
||||
<td>{customer.industryPath}</td>
|
||||
<td>
|
||||
<CustomerStatusTag status={customer.status} />
|
||||
</td>
|
||||
<td>{customer.ownerName}</td>
|
||||
<td>{customer.groups.map((group) => group.name).join('、') || '—'}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`lx-client-count${customer.genFieldCount >= 5 ? ' is-full' : ''}`}
|
||||
>
|
||||
{customer.genFieldCount}/5
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="lx-client-count">{customer.baseFieldCount}/10</span>
|
||||
</td>
|
||||
<td className="lx-client-muted">
|
||||
{customer.updatedAt.slice(0, 19).replace('T', ' ')}
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDetail(customer);
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{customers.error ? null : (
|
||||
<div className="lx-client-tablefoot">
|
||||
<span>客户名称在同一末级行业下唯一;客户不可跨行业移动</span>
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
onClick={() => void navigate(ROUTES.system.customerGroups)}
|
||||
>
|
||||
管理客户组
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
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 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-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-client-tree__name">{child.name}</span>
|
||||
<span className="lx-client-tree__count">{child.customerCount}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IndustryDetailPanel({
|
||||
detail,
|
||||
canManage,
|
||||
onAddChild,
|
||||
onEdit,
|
||||
onRefresh,
|
||||
}: {
|
||||
detail: IndustryDetail;
|
||||
canManage: boolean;
|
||||
onAddChild: () => void;
|
||||
onEdit: () => void;
|
||||
onRefresh: () => void;
|
||||
}): ReactNode {
|
||||
const setStatus = useSetIndustryStatusMutation();
|
||||
const remove = useDeleteIndustryMutation();
|
||||
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-client-right__head">
|
||||
<div>
|
||||
<h2>
|
||||
{detail.name} {statusTag(detail.status)}
|
||||
</h2>
|
||||
<p>
|
||||
{detail.level === 1 ? '一级行业' : '二级行业'}
|
||||
{detail.parentName !== null ? ` · 上级 ${detail.parentName}` : ''} · 排序{' '}
|
||||
{detail.sortOrder}
|
||||
</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
{canManage ? (
|
||||
<>
|
||||
{detail.level === 1 ? <Button onClick={onAddChild}>添加二级</Button> : null}
|
||||
<Button onClick={onEdit}>编辑</Button>
|
||||
<Button onClick={toggle}>{detail.status === 'active' ? '停用' : '启用'}</Button>
|
||||
<Button type="danger" onClick={removeIndustry}>
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-client-right__body">
|
||||
<section className="lx-client-section">
|
||||
<div className="lx-client-section__head">
|
||||
<b>基本信息</b>
|
||||
</div>
|
||||
<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.customerCount}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>子行业数</span>
|
||||
<b>{detail.references.childCount}</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>
|
||||
{detail.remark !== '' ? <div className="lx-client-hint">{detail.remark}</div> : null}
|
||||
</section>
|
||||
<section className="lx-client-section">
|
||||
<div className="lx-client-section__head">
|
||||
<b>引用客户</b>
|
||||
<span style={{ fontSize: 12, color: 'var(--lx-color-text-secondary)' }}>
|
||||
{detail.references.customerCount} 个
|
||||
</span>
|
||||
</div>
|
||||
{detail.references.customers.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无客户</div>
|
||||
) : (
|
||||
<div className="lx-client-chips">
|
||||
{detail.references.customers.map((customer) => (
|
||||
<span key={customer.id} className="lx-client-chip">
|
||||
{customer.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="lx-client-hint">
|
||||
删除前需先处理子行业、客户与资产;有启用中的二级行业时禁止停用。
|
||||
</div>
|
||||
</section>
|
||||
</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
|
||||
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}
|
||||
canManage={canManage}
|
||||
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>;
|
||||
}
|
||||
@@ -44,6 +44,36 @@ const RoleNewPage = lazy(async () => {
|
||||
return { default: module.RoleNewPage };
|
||||
});
|
||||
|
||||
const IndustriesPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/industries/IndustriesPage');
|
||||
return { default: module.IndustriesPage };
|
||||
});
|
||||
|
||||
const CustomersPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customers/CustomersPage');
|
||||
return { default: module.CustomersPage };
|
||||
});
|
||||
|
||||
const CustomerNewPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customers/CustomerNewPage');
|
||||
return { default: module.CustomerNewPage };
|
||||
});
|
||||
|
||||
const CustomerDetailPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customers/CustomerDetailPage');
|
||||
return { default: module.CustomerDetailPage };
|
||||
});
|
||||
|
||||
const CustomerGroupsPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customer-groups/CustomerGroupsPage');
|
||||
return { default: module.CustomerGroupsPage };
|
||||
});
|
||||
|
||||
const CustomerFieldsPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customer-fields/CustomerFieldsPage');
|
||||
return { default: module.CustomerFieldsPage };
|
||||
});
|
||||
|
||||
function withSkeleton(node: ReactNode): ReactNode {
|
||||
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
|
||||
}
|
||||
@@ -134,6 +164,54 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/industries',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_READ}>
|
||||
{withSkeleton(<IndustriesPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customers',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_READ}>
|
||||
{withSkeleton(<CustomersPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customers/new',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_MANAGE}>
|
||||
{withSkeleton(<CustomerNewPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customers/:id',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_READ}>
|
||||
{withSkeleton(<CustomerDetailPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customer-groups',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_GROUP_MANAGE}>
|
||||
{withSkeleton(<CustomerGroupsPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customer-fields/approvals',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_FIELD_APPROVE}>
|
||||
{withSkeleton(<CustomerFieldsPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':moduleKey/:itemKey?',
|
||||
lazy: async () => ({
|
||||
|
||||
@@ -16,6 +16,12 @@ export const ROUTES = {
|
||||
roles: '/system/roles',
|
||||
roleNew: '/system/roles/new',
|
||||
roleDetail: '/system/roles/:id',
|
||||
industries: '/system/industries',
|
||||
customers: '/system/customers',
|
||||
customerNew: '/system/customers/new',
|
||||
customerDetail: '/system/customers/:id',
|
||||
customerGroups: '/system/customer-groups',
|
||||
customerFields: '/system/customer-fields/approvals',
|
||||
},
|
||||
unauthorized: '/401',
|
||||
forbidden: '/403',
|
||||
@@ -34,6 +40,12 @@ export type RoutePath =
|
||||
| typeof ROUTES.system.roles
|
||||
| typeof ROUTES.system.roleNew
|
||||
| typeof ROUTES.system.roleDetail
|
||||
| typeof ROUTES.system.industries
|
||||
| typeof ROUTES.system.customers
|
||||
| typeof ROUTES.system.customerNew
|
||||
| typeof ROUTES.system.customerDetail
|
||||
| typeof ROUTES.system.customerGroups
|
||||
| typeof ROUTES.system.customerFields
|
||||
| typeof ROUTES.unauthorized
|
||||
| typeof ROUTES.forbidden
|
||||
| typeof ROUTES.notFound
|
||||
@@ -49,6 +61,16 @@ export function roleDetailPath(roleId: string): string {
|
||||
return `${ROUTES.system.roles}/${encodeURIComponent(roleId)}`;
|
||||
}
|
||||
|
||||
/** 客户详情深链:`/system/customers/{id}` */
|
||||
export function customerDetailPath(customerId: string): string {
|
||||
return `${ROUTES.system.customers}/${encodeURIComponent(customerId)}`;
|
||||
}
|
||||
|
||||
/** 新建客户(四步向导) */
|
||||
export function customerNewPath(): string {
|
||||
return ROUTES.system.customerNew;
|
||||
}
|
||||
|
||||
/** 新建角色深链;带来源角色时作为复制模板预填(特殊权限不随复制带入) */
|
||||
export function roleNewPath(sourceRoleId?: string): string {
|
||||
return sourceRoleId === undefined
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
/* A3 客户与行业页面(混搭布局:客户域 A / 行业与客户组 B / 字段审批 A) */
|
||||
.lx-client-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.lx-client-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.lx-client-header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lx-client-header p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--lx-text-2, #8f959e);
|
||||
}
|
||||
.lx-client-header__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
.lx-client-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.lx-client-split {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.lx-client-left {
|
||||
width: 380px;
|
||||
flex: none;
|
||||
background: #fff;
|
||||
border: 1px solid var(--lx-border, #e5e8ef);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lx-client-left__head {
|
||||
height: 48px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
}
|
||||
.lx-client-left__head b {
|
||||
font-size: 13px;
|
||||
}
|
||||
.lx-client-left__head span {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-left__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
.lx-client-left__foot {
|
||||
height: 40px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
border-top: 1px solid #eef1f6;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
border: 1px solid var(--lx-border, #e5e8ef);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lx-client-right__head {
|
||||
flex: none;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.lx-client-right__head h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.lx-client-right__head p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-right__head .lx-client-header__actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.lx-client-right__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 16px 18px 20px;
|
||||
}
|
||||
.lx-client-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.lx-client-tree__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #646a73;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
.lx-client-tree__row:hover {
|
||||
background: #f2f4f7;
|
||||
color: #1f2329;
|
||||
}
|
||||
.lx-client-tree__row.is-active {
|
||||
background: #e8f1ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
.lx-client-tree__row.is-child {
|
||||
padding-left: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
.lx-client-tree__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.lx-client-tree__count {
|
||||
font-size: 11px;
|
||||
color: #8f959e;
|
||||
}
|
||||
.lx-client-tree__row.is-active .lx-client-tree__count {
|
||||
color: #1677ff;
|
||||
}
|
||||
.lx-client-table-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lx-client-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px 24px;
|
||||
color: #646a73;
|
||||
font-size: 12px;
|
||||
}
|
||||
.lx-client-empty b {
|
||||
font-size: 13px;
|
||||
color: #1f2329;
|
||||
}
|
||||
.lx-client-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr) 280px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.lx-client-anchors {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.lx-client-anchors a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-anchors a:hover {
|
||||
background: #f2f4f7;
|
||||
color: #1f2329;
|
||||
}
|
||||
.lx-client-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.lx-client-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.lx-client-sidecard {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.lx-client-sidecard h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lx-client-kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 28px;
|
||||
}
|
||||
.lx-client-kv__item span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-kv__item b {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.lx-client-section {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
}
|
||||
.lx-client-section:last-child {
|
||||
border-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.lx-client-section__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.lx-client-section__head b {
|
||||
font-size: 14px;
|
||||
}
|
||||
.lx-client-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.lx-client-tag--active {
|
||||
background: #e8ffea;
|
||||
color: #008a24;
|
||||
}
|
||||
.lx-client-tag--paused {
|
||||
background: #fff4e8;
|
||||
color: #d25f00;
|
||||
}
|
||||
.lx-client-tag--ended {
|
||||
background: #f2f4f7;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-tag--disabled {
|
||||
background: #f2f4f7;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.lx-client-banner--paused {
|
||||
background: #fff4e8;
|
||||
color: #d25f00;
|
||||
}
|
||||
.lx-client-banner--ended {
|
||||
background: #f2f4f7;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.lx-client-chip {
|
||||
height: 28px;
|
||||
padding: 0 9px;
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
border: 1px solid #eef1f6;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.lx-client-newgrid {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.lx-client-stepnav {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.lx-client-formcard {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lx-client-formcard__body {
|
||||
padding: 20px 22px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
.lx-client-formcard__foot {
|
||||
height: 60px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 20px;
|
||||
border-top: 1px solid #eef1f6;
|
||||
}
|
||||
.lx-client-formcard__foot .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.lx-client-formgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px 20px;
|
||||
}
|
||||
.lx-client-formgrid .is-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.lx-client-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.lx-client-field > label {
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-field > label i {
|
||||
color: #f53f3f;
|
||||
font-style: normal;
|
||||
}
|
||||
.lx-client-memberlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.lx-client-memberrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #eef1f6;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.lx-client-memberrow b {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.lx-client-memberrow span {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-assetempty {
|
||||
border: 1px dashed #d0d5dd;
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
padding: 20px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-assetempty b {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #1f2329;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
.lx-client-grid {
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
}
|
||||
.lx-client-grid .lx-client-side {
|
||||
display: none;
|
||||
}
|
||||
.lx-client-left {
|
||||
width: 330px;
|
||||
}
|
||||
}
|
||||
.lx-client-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.lx-client-table th {
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
text-align: left;
|
||||
background: #fafbfc;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
color: #646a73;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lx-client-table td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
color: #646a73;
|
||||
vertical-align: middle;
|
||||
line-height: 20px;
|
||||
}
|
||||
.lx-client-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.lx-client-table tbody tr:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
.lx-client-table td b {
|
||||
font-weight: 500;
|
||||
color: #1f2329;
|
||||
}
|
||||
.lx-client-sub {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-muted {
|
||||
color: #646a73;
|
||||
font-size: 12px;
|
||||
}
|
||||
.lx-client-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 4px;
|
||||
background: #f2f4f7;
|
||||
color: #646a73;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lx-client-count.is-full {
|
||||
background: #fff4e8;
|
||||
color: #d25f00;
|
||||
}
|
||||
.lx-client-tablefoot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
border-top: 1px solid #eef1f6;
|
||||
}
|
||||
.lx-client-tablefoot .semi-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
.lx-client-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.lx-client-back:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
Reference in new issue
Block a user