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:
Stev_Wang committed 2026-11-03 21:45:38 +08:00
1 parent 8626996bb1
commit 140dc386c1
54 files changed
+9018 -147

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { setUnauthorizedHandler } from './client';
import {
applyCustomerField,
applyIndustryPack,
approveCustomerField,
createCustomer,
createCustomerGroup,
createIndustry,
deleteCustomerGroup,
deleteIndustry,
fetchCustomerDetail,
fetchCustomerFieldList,
fetchCustomerFields,
fetchCustomerGroupDetail,
fetchCustomerGroupList,
fetchCustomerList,
fetchIndustryDetail,
fetchIndustryList,
setCustomerGroups,
setCustomerStatus,
setIndustryStatus,
transferCustomer,
updateCustomer,
updateCustomerGroup,
updateCustomerGroupMembers,
updateIndustry,
} from './customers';
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ID2 = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const NOW = '2026-11-03T10:00:00.000Z';
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(status === 204 ? null : JSON.stringify(body), {
status,
headers: status === 204 ? undefined : { 'Content-Type': 'application/json' },
});
const industryNode = {
id: ID,
parentId: null,
level: 1,
name: '电商',
sortOrder: 10,
status: 'active',
remark: '',
customerCount: 1,
assetCount: 0,
updatedAt: NOW,
};
const industryTree = { ...industryNode, children: [] };
const industryDetail = {
...industryNode,
parentName: null,
references: {
childCount: 0,
activeChildCount: 0,
customerCount: 1,
customers: [],
assetCount: 0,
},
};
const customerSummary = {
id: ID,
name: '花间集',
shortName: 'HJ',
industryId: ID,
industryName: '服饰鞋包',
industryPath: '电商 / 服饰鞋包',
status: 'active',
ownerUserId: ID2,
ownerName: '陈默',
groups: [],
baseFieldCount: 1,
genFieldCount: 1,
updatedAt: NOW,
};
const customerDetail = {
...customerSummary,
contactName: '林晓',
contactPhone: '138****2210',
preferences: { naming: '', directory: '', specs: [], taboo: [] },
baseFields: [{ key: '门店', value: '沪-001' }],
fields: [],
remark: null,
createdAt: NOW,
};
const groupSummary = {
id: ID,
name: '电商核心组',
description: '',
memberCount: 0,
updatedAt: NOW,
};
const groupDetail = { ...groupSummary, members: [] };
const field = {
id: ID,
customerId: ID2,
name: '活动编码',
code: 'campaign_code',
type: 'text',
options: [],
purpose: '检索',
entersGeneration: true,
status: 'pending',
applicantName: '陈默',
approverName: null,
appliedAt: NOW,
approvedAt: null,
rejectReason: null,
};
const fieldListItem = {
...field,
customerName: '花间集',
customerShortName: 'HJ',
industryPath: '电商 / 服饰鞋包',
};
function lastCall(): { url: string; init: RequestInit | undefined } {
const fetchMock = vi.mocked(globalThis.fetch);
const call = fetchMock.mock.calls.at(-1);
const input = call?.[0];
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : (input?.url ?? '');
return { url, init: call?.[1] };
}
function lastBody(): unknown {
const body = lastCall().init?.body;
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
}
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
useAuthStore.getState().clearSession();
setUnauthorizedHandler(null);
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: ID2, name: '陈默', account: 'chen.mo' },
permissions: ['customer:read'],
});
});
describe('客户与行业接口封装', () => {
it('行业:列表 / 详情 / 新建 / 更新 / 状态 / 删除', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [industryTree], next_cursor: null }))
.mockResolvedValueOnce(jsonResponse(industryDetail))
.mockResolvedValueOnce(jsonResponse(industryDetail, 201))
.mockResolvedValueOnce(jsonResponse(industryDetail))
.mockResolvedValueOnce(jsonResponse(industryDetail))
.mockResolvedValueOnce(jsonResponse(undefined, 204));
await fetchIndustryList({ keyword: '电商', limit: 100 });
expect(lastCall().url).toContain('/industries?keyword=');
await fetchIndustryDetail(ID);
await createIndustry({ name: '电商', sortOrder: 10, remark: '' });
expect(lastBody()).toEqual({ name: '电商', sortOrder: 10, remark: '' });
await updateIndustry(ID, { name: '电商2' });
await setIndustryStatus(ID, { status: 'disabled' });
await deleteIndustry(ID);
expect(lastCall().url).toContain(`/industries/${ID}`);
});
it('客户:列表 / 详情 / 新建 / 更新 / 状态 / 转交 / 组 / 资产包', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [customerSummary], next_cursor: null }))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse(customerDetail, 201))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse({}, 200));
await fetchCustomerList({ status: 'active', limit: 100 });
expect(lastCall().url).toContain('/customers?status=active');
await fetchCustomerDetail(ID);
await createCustomer({
industryId: ID,
name: '花间集',
shortName: 'HJ',
ownerUserId: ID2,
});
await updateCustomer(ID, { shortName: 'HJ2' });
await setCustomerStatus(ID, { status: 'paused' });
await transferCustomer(ID, { ownerUserId: ID2 });
await setCustomerGroups(ID, { groupIds: [ID] });
await applyIndustryPack(ID);
expect(lastCall().url).toContain(`/customers/${ID}/apply-industry-pack`);
});
it('客户组:列表 / 详情 / 新建 / 更新 / 成员 / 删除', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [groupSummary], next_cursor: null }))
.mockResolvedValueOnce(jsonResponse(groupDetail))
.mockResolvedValueOnce(jsonResponse(groupDetail, 201))
.mockResolvedValueOnce(jsonResponse(groupDetail))
.mockResolvedValueOnce(jsonResponse(groupDetail))
.mockResolvedValueOnce(jsonResponse(undefined, 204));
await fetchCustomerGroupList();
await fetchCustomerGroupDetail(ID);
await createCustomerGroup({ name: '电商核心组', description: '' });
await updateCustomerGroup(ID, { description: '说明' });
await updateCustomerGroupMembers(ID, { addCustomerIds: [ID2], removeCustomerIds: [] });
await deleteCustomerGroup(ID);
});
it('客户字段:审批队列 / 客户字段 / 申请 / 审批', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [fieldListItem], next_cursor: null }))
.mockResolvedValueOnce(jsonResponse([field]))
.mockResolvedValueOnce(jsonResponse(field, 201))
.mockResolvedValueOnce(jsonResponse(field));
await fetchCustomerFieldList({ status: 'pending', limit: 100 });
await fetchCustomerFields(ID);
await applyCustomerField(ID, {
name: '活动编码',
code: 'campaign_code',
type: 'text',
options: [],
purpose: '检索',
entersGeneration: true,
});
await approveCustomerField(ID, { decision: 'approve' });
expect(lastCall().url).toContain(`/customer-fields/${ID}/approve`);
});
});
+277
View File
@@ -0,0 +1,277 @@
import {
type ApplyCustomerFieldInput,
applyCustomerFieldInputSchema,
type ApproveCustomerFieldInput,
approveCustomerFieldInputSchema,
type CreateCustomerGroupInput,
createCustomerGroupInputSchema,
type CreateCustomerInput,
createCustomerInputSchema,
type CreateIndustryInput,
createIndustryInputSchema,
type CustomerDetail,
customerDetailSchema,
type CustomerField,
type CustomerFieldListItem,
customerFieldListItemSchema,
type CustomerFieldListQuery,
customerFieldSchema,
type CustomerGroupDetail,
customerGroupDetailSchema,
type CustomerGroupListQuery,
type CustomerGroupSummary,
customerGroupSummarySchema,
type CustomerListQuery,
type CustomerSummary,
customerSummarySchema,
type IndustryDetail,
industryDetailSchema,
type IndustryListQuery,
type IndustryTreeItem,
industryTreeItemSchema,
listResponseSchema,
type SetCustomerGroupsInput,
setCustomerGroupsInputSchema,
type SetCustomerStatusInput,
setCustomerStatusInputSchema,
type SetIndustryStatusInput,
setIndustryStatusInputSchema,
type TransferCustomerInput,
transferCustomerInputSchema,
type UpdateCustomerGroupInput,
updateCustomerGroupInputSchema,
type UpdateCustomerGroupMembersInput,
updateCustomerGroupMembersInputSchema,
type UpdateCustomerInput,
updateCustomerInputSchema,
type UpdateIndustryInput,
updateIndustryInputSchema,
} from '@lingxi/shared';
import { request } from './client';
import { ENDPOINTS } from './endpoints';
const industryListResponseSchema = listResponseSchema(industryTreeItemSchema);
const customerListResponseSchema = listResponseSchema(customerSummarySchema);
const groupListResponseSchema = listResponseSchema(customerGroupSummarySchema);
const fieldListResponseSchema = listResponseSchema(customerFieldListItemSchema);
function searchParams(entries: Record<string, string | number | undefined>): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(entries)) {
if (value !== undefined && value !== '') params.set(key, String(value));
}
const search = params.toString();
return search === '' ? '' : `?${search}`;
}
export async function fetchIndustryList(
query: Partial<IndustryListQuery> = {},
): Promise<{ items: IndustryTreeItem[]; next_cursor: string | null }> {
const payload: unknown = await request(
`${ENDPOINTS.industries}${searchParams({
keyword: query.keyword,
status: query.status,
parent_id: query.parent_id,
limit: query.limit,
cursor: query.cursor,
})}`,
);
return industryListResponseSchema.parse(payload);
}
export async function fetchIndustryDetail(id: string): Promise<IndustryDetail> {
const payload: unknown = await request(ENDPOINTS.industry(id));
return industryDetailSchema.parse(payload);
}
export async function createIndustry(input: CreateIndustryInput): Promise<IndustryDetail> {
const body = createIndustryInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.industries, { method: 'POST', body });
return industryDetailSchema.parse(payload);
}
export async function updateIndustry(
id: string,
input: UpdateIndustryInput,
): Promise<IndustryDetail> {
const body = updateIndustryInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.industry(id), { method: 'PATCH', body });
return industryDetailSchema.parse(payload);
}
export async function setIndustryStatus(
id: string,
input: SetIndustryStatusInput,
): Promise<IndustryDetail> {
const body = setIndustryStatusInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.industryStatus(id), { method: 'PATCH', body });
return industryDetailSchema.parse(payload);
}
export async function deleteIndustry(id: string): Promise<void> {
await request<unknown>(ENDPOINTS.industry(id), { method: 'DELETE' });
}
export async function fetchCustomerList(
query: Partial<CustomerListQuery> = {},
): Promise<{ items: CustomerSummary[]; next_cursor: string | null }> {
const payload: unknown = await request(
`${ENDPOINTS.customers}${searchParams({
keyword: query.keyword,
industry_id: query.industry_id,
status: query.status,
owner_user_id: query.owner_user_id,
group_id: query.group_id,
limit: query.limit,
cursor: query.cursor,
})}`,
);
return customerListResponseSchema.parse(payload);
}
export async function fetchCustomerDetail(id: string): Promise<CustomerDetail> {
const payload: unknown = await request(ENDPOINTS.customer(id));
return customerDetailSchema.parse(payload);
}
export async function createCustomer(input: CreateCustomerInput): Promise<CustomerDetail> {
const body = createCustomerInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customers, { method: 'POST', body });
return customerDetailSchema.parse(payload);
}
export async function updateCustomer(
id: string,
input: UpdateCustomerInput,
): Promise<CustomerDetail> {
const body = updateCustomerInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customer(id), { method: 'PATCH', body });
return customerDetailSchema.parse(payload);
}
export async function setCustomerStatus(
id: string,
input: SetCustomerStatusInput,
): Promise<CustomerDetail> {
const body = setCustomerStatusInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerStatus(id), { method: 'PATCH', body });
return customerDetailSchema.parse(payload);
}
export async function transferCustomer(
id: string,
input: TransferCustomerInput,
): Promise<CustomerDetail> {
const body = transferCustomerInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerTransfer(id), { method: 'POST', body });
return customerDetailSchema.parse(payload);
}
export async function setCustomerGroups(
id: string,
input: SetCustomerGroupsInput,
): Promise<CustomerDetail> {
const body = setCustomerGroupsInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerGroups(id), { method: 'PUT', body });
return customerDetailSchema.parse(payload);
}
export async function applyIndustryPack(id: string): Promise<void> {
await request<unknown>(ENDPOINTS.customerApplyPack(id), { method: 'POST' });
}
export async function fetchCustomerGroupList(
query: Partial<CustomerGroupListQuery> = {},
): Promise<{ items: CustomerGroupSummary[]; next_cursor: string | null }> {
const payload: unknown = await request(
`${ENDPOINTS.customerGroupsList}${searchParams({ keyword: query.keyword, limit: query.limit, cursor: query.cursor })}`,
);
return groupListResponseSchema.parse(payload);
}
export async function fetchCustomerGroupDetail(id: string): Promise<CustomerGroupDetail> {
const payload: unknown = await request(ENDPOINTS.customerGroup(id));
return customerGroupDetailSchema.parse(payload);
}
export async function createCustomerGroup(
input: CreateCustomerGroupInput,
): Promise<CustomerGroupDetail> {
const body = createCustomerGroupInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerGroupsList, { method: 'POST', body });
return customerGroupDetailSchema.parse(payload);
}
export async function updateCustomerGroup(
id: string,
input: UpdateCustomerGroupInput,
): Promise<CustomerGroupDetail> {
const body = updateCustomerGroupInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerGroup(id), { method: 'PATCH', body });
return customerGroupDetailSchema.parse(payload);
}
export async function updateCustomerGroupMembers(
id: string,
input: UpdateCustomerGroupMembersInput,
): Promise<CustomerGroupDetail> {
const body = updateCustomerGroupMembersInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerGroupMembers(id), {
method: 'POST',
body,
});
return customerGroupDetailSchema.parse(payload);
}
export async function deleteCustomerGroup(id: string): Promise<void> {
await request<unknown>(ENDPOINTS.customerGroup(id), { method: 'DELETE' });
}
export async function fetchCustomerFieldList(
query: Partial<CustomerFieldListQuery> = {},
): Promise<{ items: CustomerFieldListItem[]; next_cursor: string | null }> {
const payload: unknown = await request(
`${ENDPOINTS.customerFields}${searchParams({
customer_id: query.customer_id,
status: query.status,
limit: query.limit,
cursor: query.cursor,
})}`,
);
return fieldListResponseSchema.parse(payload);
}
export async function fetchCustomerFields(customerId: string): Promise<CustomerField[]> {
const payload: unknown = await request(ENDPOINTS.customerFieldsOf(customerId));
const schema = listResponseSchema(customerFieldSchema);
// 后端该接口返回数组;列表信封仅用于 /customer-fields
if (Array.isArray(payload)) {
return payload.map((item) => customerFieldSchema.parse(item));
}
return schema.parse(payload).items;
}
export async function applyCustomerField(
customerId: string,
input: ApplyCustomerFieldInput,
): Promise<CustomerField> {
const body = applyCustomerFieldInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerFieldsOf(customerId), {
method: 'POST',
body,
});
return customerFieldSchema.parse(payload);
}
export async function approveCustomerField(
id: string,
input: ApproveCustomerFieldInput,
): Promise<CustomerField> {
const body = approveCustomerFieldInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerFieldApprove(id), {
method: 'POST',
body,
});
return customerFieldSchema.parse(payload);
}
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
+353
View File
@@ -0,0 +1,353 @@
import type {
ApplyCustomerFieldInput,
ApproveCustomerFieldInput,
CreateCustomerGroupInput,
CreateCustomerInput,
CreateIndustryInput,
CustomerDetail,
CustomerField,
CustomerFieldListItem,
CustomerFieldListQuery,
CustomerGroupDetail,
CustomerGroupSummary,
CustomerListQuery,
CustomerSummary,
IndustryDetail,
IndustryListQuery,
IndustryTreeItem,
SetCustomerGroupsInput,
SetCustomerStatusInput,
SetIndustryStatusInput,
TransferCustomerInput,
UpdateCustomerGroupInput,
UpdateCustomerGroupMembersInput,
UpdateCustomerInput,
UpdateIndustryInput,
} from '@lingxi/shared';
import {
keepPreviousData,
useMutation,
type UseMutationResult,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import {
applyCustomerField,
approveCustomerField,
createCustomer,
createCustomerGroup,
createIndustry,
deleteCustomerGroup,
deleteIndustry,
fetchCustomerDetail,
fetchCustomerFieldList,
fetchCustomerFields,
fetchCustomerGroupDetail,
fetchCustomerGroupList,
fetchCustomerList,
fetchIndustryDetail,
fetchIndustryList,
setCustomerGroups,
setCustomerStatus,
setIndustryStatus,
transferCustomer,
updateCustomer,
updateCustomerGroup,
updateCustomerGroupMembers,
updateIndustry,
} from '@/api/customers';
import { queryKeys } from '@/api/query-keys';
export function useIndustriesQuery(
query: Partial<IndustryListQuery> = {},
): UseQueryResult<{ items: IndustryTreeItem[]; next_cursor: string | null }> {
return useQuery({
queryKey: queryKeys.industryList(query),
queryFn: () => fetchIndustryList(query),
staleTime: 10_000,
});
}
export function useIndustryDetailQuery(id: string | null): UseQueryResult<IndustryDetail> {
return useQuery({
queryKey: queryKeys.industry(id ?? ''),
queryFn: () => fetchIndustryDetail(id ?? ''),
enabled: id !== null && id !== '',
});
}
export function useCustomersQuery(
query: Partial<CustomerListQuery> = {},
): UseQueryResult<{ items: CustomerSummary[]; next_cursor: string | null }> {
return useQuery({
queryKey: queryKeys.customerList(query),
queryFn: () => fetchCustomerList(query),
placeholderData: keepPreviousData,
staleTime: 10_000,
});
}
export function useCustomerDetailQuery(id: string | null): UseQueryResult<CustomerDetail> {
return useQuery({
queryKey: queryKeys.customer(id ?? ''),
queryFn: () => fetchCustomerDetail(id ?? ''),
enabled: id !== null && id !== '',
});
}
export function useCustomerGroupsQuery(): UseQueryResult<{
items: CustomerGroupSummary[];
next_cursor: string | null;
}> {
const query = { limit: 100 };
return useQuery({
queryKey: queryKeys.customerGroupList(query),
queryFn: () => fetchCustomerGroupList(query),
staleTime: 10_000,
});
}
export function useCustomerGroupDetailQuery(
id: string | null,
): UseQueryResult<CustomerGroupDetail> {
return useQuery({
queryKey: queryKeys.customerGroup(id ?? ''),
queryFn: () => fetchCustomerGroupDetail(id ?? ''),
enabled: id !== null && id !== '',
});
}
export function useCustomerFieldsQuery(
query: Partial<CustomerFieldListQuery> = {},
): UseQueryResult<{ items: CustomerFieldListItem[]; next_cursor: string | null }> {
return useQuery({
queryKey: queryKeys.customerFields(query),
queryFn: () => fetchCustomerFieldList(query),
placeholderData: keepPreviousData,
staleTime: 10_000,
});
}
export function useCustomerFieldsOfQuery(
customerId: string | null,
): UseQueryResult<CustomerField[]> {
return useQuery({
queryKey: queryKeys.customerFieldsOf(customerId ?? ''),
queryFn: () => fetchCustomerFields(customerId ?? ''),
enabled: customerId !== null && customerId !== '',
});
}
function useInvalidateClient(): () => Promise<void> {
const queryClient = useQueryClient();
return async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.industries });
await queryClient.invalidateQueries({ queryKey: queryKeys.customers });
await queryClient.invalidateQueries({ queryKey: queryKeys.customerGroups });
await queryClient.invalidateQueries({ queryKey: queryKeys.customerFieldsRoot });
};
}
export function useCreateIndustryMutation(): UseMutationResult<
IndustryDetail,
Error,
CreateIndustryInput
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: createIndustry,
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateIndustryMutation(): UseMutationResult<
IndustryDetail,
Error,
{ id: string; input: UpdateIndustryInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => updateIndustry(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useSetIndustryStatusMutation(): UseMutationResult<
IndustryDetail,
Error,
{ id: string; input: SetIndustryStatusInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => setIndustryStatus(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useDeleteIndustryMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: deleteIndustry,
onSuccess: async () => {
await invalidate();
},
});
}
export function useCreateCustomerMutation(): UseMutationResult<
CustomerDetail,
Error,
CreateCustomerInput
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: createCustomer,
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateCustomerMutation(): UseMutationResult<
CustomerDetail,
Error,
{ id: string; input: UpdateCustomerInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => updateCustomer(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useSetCustomerStatusMutation(): UseMutationResult<
CustomerDetail,
Error,
{ id: string; input: SetCustomerStatusInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => setCustomerStatus(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useTransferCustomerMutation(): UseMutationResult<
CustomerDetail,
Error,
{ id: string; input: TransferCustomerInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => transferCustomer(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useSetCustomerGroupsMutation(): UseMutationResult<
CustomerDetail,
Error,
{ id: string; input: SetCustomerGroupsInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => setCustomerGroups(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useCreateCustomerGroupMutation(): UseMutationResult<
CustomerGroupDetail,
Error,
CreateCustomerGroupInput
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: createCustomerGroup,
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateCustomerGroupMutation(): UseMutationResult<
CustomerGroupDetail,
Error,
{ id: string; input: UpdateCustomerGroupInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => updateCustomerGroup(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateCustomerGroupMembersMutation(): UseMutationResult<
CustomerGroupDetail,
Error,
{ id: string; input: UpdateCustomerGroupMembersInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => updateCustomerGroupMembers(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useDeleteCustomerGroupMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: deleteCustomerGroup,
onSuccess: async () => {
await invalidate();
},
});
}
export function useApplyCustomerFieldMutation(): UseMutationResult<
CustomerField,
Error,
{ customerId: string; input: ApplyCustomerFieldInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ customerId, input }) => applyCustomerField(customerId, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useApproveCustomerFieldMutation(): UseMutationResult<
CustomerField,
Error,
{ id: string; input: ApproveCustomerFieldInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => approveCustomerField(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
+1
View File
@@ -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');
+16 -8
View File
@@ -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>;
}
+78
View File
@@ -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 () => ({
+22
View File
@@ -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
+526
View File
@@ -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;
}