feat(client): A3 客户与行业目录(两级行业、客户组、字段审批)
背景:A3 需把行业 / 客户 / 客户组 / 客户级字段立为资产根节点;用户确认两级行业树与混搭 UI(行业 B / 客户列表 A / 详情 A / 新建 B / 客户组 B / 字段 A)。 改动: - 新增 ADR-002,行业改为两级树,客户挂末级行业;同步 PRD / 技术架构 / 开发计划。 - 新增 client 模块:industries / customers / customer-groups / customer-fields 接口、真实 PrismaCustomerDirectory、客户状态机与客户转交、客户组数据范围解析、跨客户 404。 - 新增迁移与幂等种子(电商/金融两级行业、2 客户、1 客户组、4 条字段)。 - 前端新增行业/客户/客户组/字段审批页面与路由、导航更名(客户管理 / 行业管理 / 客户组),复用现有 token。 - 补充 shared 契约单测、client 集成用例、A3 浏览器 e2e。 影响面:client 模块与 client 表、shared 契约、web 路由/导航、ScopeContext 数据范围、OpenAPI 与种子;A1 客户转交占位由 client 实现替换。 回滚:回滚本提交;数据库保留新增表或按报告执行 down SQL;CUSTOMER_DIRECTORY 可切回空集实现。 验证:format:check / lint / typecheck / test 全绿(shared 131 / api 90 / web 125,共 346);test:int 5 文件 39 条;A3 e2e 1/1;build 8/8;真实浏览器六页验证通过。
This commit is contained in:
1 parent
8626996bb1
commit
140dc386c1
54 files changed
+9018
-147
No files matched your search
@@ -0,0 +1,236 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
import { setUnauthorizedHandler } from './client';
|
||||
import {
|
||||
applyCustomerField,
|
||||
applyIndustryPack,
|
||||
approveCustomerField,
|
||||
createCustomer,
|
||||
createCustomerGroup,
|
||||
createIndustry,
|
||||
deleteCustomerGroup,
|
||||
deleteIndustry,
|
||||
fetchCustomerDetail,
|
||||
fetchCustomerFieldList,
|
||||
fetchCustomerFields,
|
||||
fetchCustomerGroupDetail,
|
||||
fetchCustomerGroupList,
|
||||
fetchCustomerList,
|
||||
fetchIndustryDetail,
|
||||
fetchIndustryList,
|
||||
setCustomerGroups,
|
||||
setCustomerStatus,
|
||||
setIndustryStatus,
|
||||
transferCustomer,
|
||||
updateCustomer,
|
||||
updateCustomerGroup,
|
||||
updateCustomerGroupMembers,
|
||||
updateIndustry,
|
||||
} from './customers';
|
||||
|
||||
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
|
||||
const ID2 = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
|
||||
const NOW = '2026-11-03T10:00:00.000Z';
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||
new Response(status === 204 ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: status === 204 ? undefined : { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const industryNode = {
|
||||
id: ID,
|
||||
parentId: null,
|
||||
level: 1,
|
||||
name: '电商',
|
||||
sortOrder: 10,
|
||||
status: 'active',
|
||||
remark: '',
|
||||
customerCount: 1,
|
||||
assetCount: 0,
|
||||
updatedAt: NOW,
|
||||
};
|
||||
const industryTree = { ...industryNode, children: [] };
|
||||
const industryDetail = {
|
||||
...industryNode,
|
||||
parentName: null,
|
||||
references: {
|
||||
childCount: 0,
|
||||
activeChildCount: 0,
|
||||
customerCount: 1,
|
||||
customers: [],
|
||||
assetCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const customerSummary = {
|
||||
id: ID,
|
||||
name: '花间集',
|
||||
shortName: 'HJ',
|
||||
industryId: ID,
|
||||
industryName: '服饰鞋包',
|
||||
industryPath: '电商 / 服饰鞋包',
|
||||
status: 'active',
|
||||
ownerUserId: ID2,
|
||||
ownerName: '陈默',
|
||||
groups: [],
|
||||
baseFieldCount: 1,
|
||||
genFieldCount: 1,
|
||||
updatedAt: NOW,
|
||||
};
|
||||
const customerDetail = {
|
||||
...customerSummary,
|
||||
contactName: '林晓',
|
||||
contactPhone: '138****2210',
|
||||
preferences: { naming: '', directory: '', specs: [], taboo: [] },
|
||||
baseFields: [{ key: '门店', value: '沪-001' }],
|
||||
fields: [],
|
||||
remark: null,
|
||||
createdAt: NOW,
|
||||
};
|
||||
const groupSummary = {
|
||||
id: ID,
|
||||
name: '电商核心组',
|
||||
description: '',
|
||||
memberCount: 0,
|
||||
updatedAt: NOW,
|
||||
};
|
||||
const groupDetail = { ...groupSummary, members: [] };
|
||||
const field = {
|
||||
id: ID,
|
||||
customerId: ID2,
|
||||
name: '活动编码',
|
||||
code: 'campaign_code',
|
||||
type: 'text',
|
||||
options: [],
|
||||
purpose: '检索',
|
||||
entersGeneration: true,
|
||||
status: 'pending',
|
||||
applicantName: '陈默',
|
||||
approverName: null,
|
||||
appliedAt: NOW,
|
||||
approvedAt: null,
|
||||
rejectReason: null,
|
||||
};
|
||||
const fieldListItem = {
|
||||
...field,
|
||||
customerName: '花间集',
|
||||
customerShortName: 'HJ',
|
||||
industryPath: '电商 / 服饰鞋包',
|
||||
};
|
||||
|
||||
function lastCall(): { url: string; init: RequestInit | undefined } {
|
||||
const fetchMock = vi.mocked(globalThis.fetch);
|
||||
const call = fetchMock.mock.calls.at(-1);
|
||||
const input = call?.[0];
|
||||
const url =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.href : (input?.url ?? '');
|
||||
return { url, init: call?.[1] };
|
||||
}
|
||||
|
||||
function lastBody(): unknown {
|
||||
const body = lastCall().init?.body;
|
||||
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
useAuthStore.getState().clearSession();
|
||||
setUnauthorizedHandler(null);
|
||||
useAuthStore.getState().setSession({
|
||||
accessToken: 'token',
|
||||
user: { id: ID2, name: '陈默', account: 'chen.mo' },
|
||||
permissions: ['customer:read'],
|
||||
});
|
||||
});
|
||||
|
||||
describe('客户与行业接口封装', () => {
|
||||
it('行业:列表 / 详情 / 新建 / 更新 / 状态 / 删除', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(jsonResponse({ items: [industryTree], next_cursor: null }))
|
||||
.mockResolvedValueOnce(jsonResponse(industryDetail))
|
||||
.mockResolvedValueOnce(jsonResponse(industryDetail, 201))
|
||||
.mockResolvedValueOnce(jsonResponse(industryDetail))
|
||||
.mockResolvedValueOnce(jsonResponse(industryDetail))
|
||||
.mockResolvedValueOnce(jsonResponse(undefined, 204));
|
||||
|
||||
await fetchIndustryList({ keyword: '电商', limit: 100 });
|
||||
expect(lastCall().url).toContain('/industries?keyword=');
|
||||
await fetchIndustryDetail(ID);
|
||||
await createIndustry({ name: '电商', sortOrder: 10, remark: '' });
|
||||
expect(lastBody()).toEqual({ name: '电商', sortOrder: 10, remark: '' });
|
||||
await updateIndustry(ID, { name: '电商2' });
|
||||
await setIndustryStatus(ID, { status: 'disabled' });
|
||||
await deleteIndustry(ID);
|
||||
expect(lastCall().url).toContain(`/industries/${ID}`);
|
||||
});
|
||||
|
||||
it('客户:列表 / 详情 / 新建 / 更新 / 状态 / 转交 / 组 / 资产包', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(jsonResponse({ items: [customerSummary], next_cursor: null }))
|
||||
.mockResolvedValueOnce(jsonResponse(customerDetail))
|
||||
.mockResolvedValueOnce(jsonResponse(customerDetail, 201))
|
||||
.mockResolvedValueOnce(jsonResponse(customerDetail))
|
||||
.mockResolvedValueOnce(jsonResponse(customerDetail))
|
||||
.mockResolvedValueOnce(jsonResponse(customerDetail))
|
||||
.mockResolvedValueOnce(jsonResponse(customerDetail))
|
||||
.mockResolvedValueOnce(jsonResponse({}, 200));
|
||||
|
||||
await fetchCustomerList({ status: 'active', limit: 100 });
|
||||
expect(lastCall().url).toContain('/customers?status=active');
|
||||
await fetchCustomerDetail(ID);
|
||||
await createCustomer({
|
||||
industryId: ID,
|
||||
name: '花间集',
|
||||
shortName: 'HJ',
|
||||
ownerUserId: ID2,
|
||||
});
|
||||
await updateCustomer(ID, { shortName: 'HJ2' });
|
||||
await setCustomerStatus(ID, { status: 'paused' });
|
||||
await transferCustomer(ID, { ownerUserId: ID2 });
|
||||
await setCustomerGroups(ID, { groupIds: [ID] });
|
||||
await applyIndustryPack(ID);
|
||||
expect(lastCall().url).toContain(`/customers/${ID}/apply-industry-pack`);
|
||||
});
|
||||
|
||||
it('客户组:列表 / 详情 / 新建 / 更新 / 成员 / 删除', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(jsonResponse({ items: [groupSummary], next_cursor: null }))
|
||||
.mockResolvedValueOnce(jsonResponse(groupDetail))
|
||||
.mockResolvedValueOnce(jsonResponse(groupDetail, 201))
|
||||
.mockResolvedValueOnce(jsonResponse(groupDetail))
|
||||
.mockResolvedValueOnce(jsonResponse(groupDetail))
|
||||
.mockResolvedValueOnce(jsonResponse(undefined, 204));
|
||||
|
||||
await fetchCustomerGroupList();
|
||||
await fetchCustomerGroupDetail(ID);
|
||||
await createCustomerGroup({ name: '电商核心组', description: '' });
|
||||
await updateCustomerGroup(ID, { description: '说明' });
|
||||
await updateCustomerGroupMembers(ID, { addCustomerIds: [ID2], removeCustomerIds: [] });
|
||||
await deleteCustomerGroup(ID);
|
||||
});
|
||||
|
||||
it('客户字段:审批队列 / 客户字段 / 申请 / 审批', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(jsonResponse({ items: [fieldListItem], next_cursor: null }))
|
||||
.mockResolvedValueOnce(jsonResponse([field]))
|
||||
.mockResolvedValueOnce(jsonResponse(field, 201))
|
||||
.mockResolvedValueOnce(jsonResponse(field));
|
||||
|
||||
await fetchCustomerFieldList({ status: 'pending', limit: 100 });
|
||||
await fetchCustomerFields(ID);
|
||||
await applyCustomerField(ID, {
|
||||
name: '活动编码',
|
||||
code: 'campaign_code',
|
||||
type: 'text',
|
||||
options: [],
|
||||
purpose: '检索',
|
||||
entersGeneration: true,
|
||||
});
|
||||
await approveCustomerField(ID, { decision: 'approve' });
|
||||
expect(lastCall().url).toContain(`/customer-fields/${ID}/approve`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
type ApplyCustomerFieldInput,
|
||||
applyCustomerFieldInputSchema,
|
||||
type ApproveCustomerFieldInput,
|
||||
approveCustomerFieldInputSchema,
|
||||
type CreateCustomerGroupInput,
|
||||
createCustomerGroupInputSchema,
|
||||
type CreateCustomerInput,
|
||||
createCustomerInputSchema,
|
||||
type CreateIndustryInput,
|
||||
createIndustryInputSchema,
|
||||
type CustomerDetail,
|
||||
customerDetailSchema,
|
||||
type CustomerField,
|
||||
type CustomerFieldListItem,
|
||||
customerFieldListItemSchema,
|
||||
type CustomerFieldListQuery,
|
||||
customerFieldSchema,
|
||||
type CustomerGroupDetail,
|
||||
customerGroupDetailSchema,
|
||||
type CustomerGroupListQuery,
|
||||
type CustomerGroupSummary,
|
||||
customerGroupSummarySchema,
|
||||
type CustomerListQuery,
|
||||
type CustomerSummary,
|
||||
customerSummarySchema,
|
||||
type IndustryDetail,
|
||||
industryDetailSchema,
|
||||
type IndustryListQuery,
|
||||
type IndustryTreeItem,
|
||||
industryTreeItemSchema,
|
||||
listResponseSchema,
|
||||
type SetCustomerGroupsInput,
|
||||
setCustomerGroupsInputSchema,
|
||||
type SetCustomerStatusInput,
|
||||
setCustomerStatusInputSchema,
|
||||
type SetIndustryStatusInput,
|
||||
setIndustryStatusInputSchema,
|
||||
type TransferCustomerInput,
|
||||
transferCustomerInputSchema,
|
||||
type UpdateCustomerGroupInput,
|
||||
updateCustomerGroupInputSchema,
|
||||
type UpdateCustomerGroupMembersInput,
|
||||
updateCustomerGroupMembersInputSchema,
|
||||
type UpdateCustomerInput,
|
||||
updateCustomerInputSchema,
|
||||
type UpdateIndustryInput,
|
||||
updateIndustryInputSchema,
|
||||
} from '@lingxi/shared';
|
||||
|
||||
import { request } from './client';
|
||||
import { ENDPOINTS } from './endpoints';
|
||||
|
||||
const industryListResponseSchema = listResponseSchema(industryTreeItemSchema);
|
||||
const customerListResponseSchema = listResponseSchema(customerSummarySchema);
|
||||
const groupListResponseSchema = listResponseSchema(customerGroupSummarySchema);
|
||||
const fieldListResponseSchema = listResponseSchema(customerFieldListItemSchema);
|
||||
|
||||
function searchParams(entries: Record<string, string | number | undefined>): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (value !== undefined && value !== '') params.set(key, String(value));
|
||||
}
|
||||
const search = params.toString();
|
||||
return search === '' ? '' : `?${search}`;
|
||||
}
|
||||
|
||||
export async function fetchIndustryList(
|
||||
query: Partial<IndustryListQuery> = {},
|
||||
): Promise<{ items: IndustryTreeItem[]; next_cursor: string | null }> {
|
||||
const payload: unknown = await request(
|
||||
`${ENDPOINTS.industries}${searchParams({
|
||||
keyword: query.keyword,
|
||||
status: query.status,
|
||||
parent_id: query.parent_id,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
})}`,
|
||||
);
|
||||
return industryListResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function fetchIndustryDetail(id: string): Promise<IndustryDetail> {
|
||||
const payload: unknown = await request(ENDPOINTS.industry(id));
|
||||
return industryDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function createIndustry(input: CreateIndustryInput): Promise<IndustryDetail> {
|
||||
const body = createIndustryInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.industries, { method: 'POST', body });
|
||||
return industryDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function updateIndustry(
|
||||
id: string,
|
||||
input: UpdateIndustryInput,
|
||||
): Promise<IndustryDetail> {
|
||||
const body = updateIndustryInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.industry(id), { method: 'PATCH', body });
|
||||
return industryDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function setIndustryStatus(
|
||||
id: string,
|
||||
input: SetIndustryStatusInput,
|
||||
): Promise<IndustryDetail> {
|
||||
const body = setIndustryStatusInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.industryStatus(id), { method: 'PATCH', body });
|
||||
return industryDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function deleteIndustry(id: string): Promise<void> {
|
||||
await request<unknown>(ENDPOINTS.industry(id), { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function fetchCustomerList(
|
||||
query: Partial<CustomerListQuery> = {},
|
||||
): Promise<{ items: CustomerSummary[]; next_cursor: string | null }> {
|
||||
const payload: unknown = await request(
|
||||
`${ENDPOINTS.customers}${searchParams({
|
||||
keyword: query.keyword,
|
||||
industry_id: query.industry_id,
|
||||
status: query.status,
|
||||
owner_user_id: query.owner_user_id,
|
||||
group_id: query.group_id,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
})}`,
|
||||
);
|
||||
return customerListResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function fetchCustomerDetail(id: string): Promise<CustomerDetail> {
|
||||
const payload: unknown = await request(ENDPOINTS.customer(id));
|
||||
return customerDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function createCustomer(input: CreateCustomerInput): Promise<CustomerDetail> {
|
||||
const body = createCustomerInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customers, { method: 'POST', body });
|
||||
return customerDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function updateCustomer(
|
||||
id: string,
|
||||
input: UpdateCustomerInput,
|
||||
): Promise<CustomerDetail> {
|
||||
const body = updateCustomerInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customer(id), { method: 'PATCH', body });
|
||||
return customerDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function setCustomerStatus(
|
||||
id: string,
|
||||
input: SetCustomerStatusInput,
|
||||
): Promise<CustomerDetail> {
|
||||
const body = setCustomerStatusInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerStatus(id), { method: 'PATCH', body });
|
||||
return customerDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function transferCustomer(
|
||||
id: string,
|
||||
input: TransferCustomerInput,
|
||||
): Promise<CustomerDetail> {
|
||||
const body = transferCustomerInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerTransfer(id), { method: 'POST', body });
|
||||
return customerDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function setCustomerGroups(
|
||||
id: string,
|
||||
input: SetCustomerGroupsInput,
|
||||
): Promise<CustomerDetail> {
|
||||
const body = setCustomerGroupsInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerGroups(id), { method: 'PUT', body });
|
||||
return customerDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function applyIndustryPack(id: string): Promise<void> {
|
||||
await request<unknown>(ENDPOINTS.customerApplyPack(id), { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function fetchCustomerGroupList(
|
||||
query: Partial<CustomerGroupListQuery> = {},
|
||||
): Promise<{ items: CustomerGroupSummary[]; next_cursor: string | null }> {
|
||||
const payload: unknown = await request(
|
||||
`${ENDPOINTS.customerGroupsList}${searchParams({ keyword: query.keyword, limit: query.limit, cursor: query.cursor })}`,
|
||||
);
|
||||
return groupListResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function fetchCustomerGroupDetail(id: string): Promise<CustomerGroupDetail> {
|
||||
const payload: unknown = await request(ENDPOINTS.customerGroup(id));
|
||||
return customerGroupDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function createCustomerGroup(
|
||||
input: CreateCustomerGroupInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
const body = createCustomerGroupInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerGroupsList, { method: 'POST', body });
|
||||
return customerGroupDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function updateCustomerGroup(
|
||||
id: string,
|
||||
input: UpdateCustomerGroupInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
const body = updateCustomerGroupInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerGroup(id), { method: 'PATCH', body });
|
||||
return customerGroupDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function updateCustomerGroupMembers(
|
||||
id: string,
|
||||
input: UpdateCustomerGroupMembersInput,
|
||||
): Promise<CustomerGroupDetail> {
|
||||
const body = updateCustomerGroupMembersInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerGroupMembers(id), {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
return customerGroupDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function deleteCustomerGroup(id: string): Promise<void> {
|
||||
await request<unknown>(ENDPOINTS.customerGroup(id), { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function fetchCustomerFieldList(
|
||||
query: Partial<CustomerFieldListQuery> = {},
|
||||
): Promise<{ items: CustomerFieldListItem[]; next_cursor: string | null }> {
|
||||
const payload: unknown = await request(
|
||||
`${ENDPOINTS.customerFields}${searchParams({
|
||||
customer_id: query.customer_id,
|
||||
status: query.status,
|
||||
limit: query.limit,
|
||||
cursor: query.cursor,
|
||||
})}`,
|
||||
);
|
||||
return fieldListResponseSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function fetchCustomerFields(customerId: string): Promise<CustomerField[]> {
|
||||
const payload: unknown = await request(ENDPOINTS.customerFieldsOf(customerId));
|
||||
const schema = listResponseSchema(customerFieldSchema);
|
||||
// 后端该接口返回数组;列表信封仅用于 /customer-fields
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.map((item) => customerFieldSchema.parse(item));
|
||||
}
|
||||
return schema.parse(payload).items;
|
||||
}
|
||||
|
||||
export async function applyCustomerField(
|
||||
customerId: string,
|
||||
input: ApplyCustomerFieldInput,
|
||||
): Promise<CustomerField> {
|
||||
const body = applyCustomerFieldInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerFieldsOf(customerId), {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
return customerFieldSchema.parse(payload);
|
||||
}
|
||||
|
||||
export async function approveCustomerField(
|
||||
id: string,
|
||||
input: ApproveCustomerFieldInput,
|
||||
): Promise<CustomerField> {
|
||||
const body = approveCustomerFieldInputSchema.parse(input);
|
||||
const payload: unknown = await request(ENDPOINTS.customerFieldApprove(id), {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
return customerFieldSchema.parse(payload);
|
||||
}
|
||||
@@ -28,4 +28,28 @@ export const ENDPOINTS = {
|
||||
roles: '/roles',
|
||||
role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`,
|
||||
permissions: '/permissions',
|
||||
// A3 client
|
||||
industries: '/industries',
|
||||
industry: (industryId: string): string => `/industries/${encodeURIComponent(industryId)}`,
|
||||
industryStatus: (industryId: string): string =>
|
||||
`/industries/${encodeURIComponent(industryId)}/status`,
|
||||
customers: '/customers',
|
||||
customer: (customerId: string): string => `/customers/${encodeURIComponent(customerId)}`,
|
||||
customerStatus: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/status`,
|
||||
customerTransfer: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/transfer`,
|
||||
customerGroups: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/groups`,
|
||||
customerApplyPack: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/apply-industry-pack`,
|
||||
customerGroupsList: '/customer-groups',
|
||||
customerGroup: (groupId: string): string => `/customer-groups/${encodeURIComponent(groupId)}`,
|
||||
customerGroupMembers: (groupId: string): string =>
|
||||
`/customer-groups/${encodeURIComponent(groupId)}/members`,
|
||||
customerFields: '/customer-fields',
|
||||
customerFieldsOf: (customerId: string): string =>
|
||||
`/customers/${encodeURIComponent(customerId)}/fields`,
|
||||
customerFieldApprove: (fieldId: string): string =>
|
||||
`/customer-fields/${encodeURIComponent(fieldId)}/approve`,
|
||||
} as const;
|
||||
+706
-37
@@ -177,26 +177,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}/transfer-customers': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* 客户转交(占位)
|
||||
* @description A3 客户模块落地前返回 42201
|
||||
*/
|
||||
post: operations['UsersController_transferCustomers'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/departments/tree': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -366,6 +346,287 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/industries': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 两级行业树(一级 → 二级) */
|
||||
get: operations['IndustriesController_list'];
|
||||
put?: never;
|
||||
/** 新建一级 / 二级行业 */
|
||||
post: operations['IndustriesController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/industries/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 行业详情与引用(子行业 / 客户 / 资产) */
|
||||
get: operations['IndustriesController_detail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** 删除行业(仅无子行业 / 客户 / 资产时) */
|
||||
delete: operations['IndustriesController_remove'];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 更新行业名称 / 排序 / 备注(层级与上级不可变) */
|
||||
patch: operations['IndustriesController_update'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/industries/{id}/status': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 停用 / 启用行业(有启用子行业时禁止停用) */
|
||||
patch: operations['IndustriesController_setStatus'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选) */
|
||||
get: operations['CustomersController_list'];
|
||||
put?: never;
|
||||
/** 新建客户(简称必填;行业必须为末级行业) */
|
||||
post: operations['CustomersController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户详情(跨客户访问统一 404) */
|
||||
get: operations['CustomersController_detail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 更新客户档案(所属行业不可改;暂停 / 结束只读) */
|
||||
patch: operations['CustomersController_update'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/status': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆) */
|
||||
patch: operations['CustomersController_setStatus'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/transfer': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 变更客户负责人(写审计) */
|
||||
post: operations['CustomersController_transfer'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/groups': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** 设置客户所属客户组(覆盖式) */
|
||||
put: operations['CustomersController_setGroups'];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/apply-industry-pack': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 套用行业资产包(B0 批次提供;当前返回业务提示) */
|
||||
post: operations['CustomersController_applyIndustryPack'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/users/{id}/transfer-customers': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 把用户名下客户转交给另一负责人 */
|
||||
post: operations['CustomerTransferController_transfer'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-groups': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户组列表(授权与数据范围最小单位) */
|
||||
get: operations['CustomerGroupsController_list'];
|
||||
put?: never;
|
||||
/** 新建客户组 */
|
||||
post: operations['CustomerGroupsController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-groups/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户组详情与成员 */
|
||||
get: operations['CustomerGroupsController_detail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** 删除客户组(被角色数据范围引用时拒绝) */
|
||||
delete: operations['CustomerGroupsController_remove'];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** 更新客户组名称 / 说明 */
|
||||
patch: operations['CustomerGroupsController_update'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-groups/{id}/members': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 批量添加 / 移除成员(幂等) */
|
||||
post: operations['CustomerGroupsController_updateMembers'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-fields': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 字段审批队列(待审批 / 已生效 / 已驳回) */
|
||||
get: operations['CustomerFieldsController_list'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customer-fields/{id}/approve': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** 审批客户级字段(通过 / 驳回;驳回原因必填) */
|
||||
post: operations['CustomerFieldsController_approve'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/customers/{id}/fields': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 客户级字段列表(基础字段在 customers.custom_fields) */
|
||||
get: operations['CustomerFieldsOfCustomerController_list'];
|
||||
put?: never;
|
||||
/** 申请客户级字段(进入生成字段需审批;每客户 ≤ 5) */
|
||||
post: operations['CustomerFieldsOfCustomerController_apply'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/health': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -684,23 +945,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
UsersController_transferCustomers: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
DepartmentsController_tree: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -890,6 +1134,431 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_detail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_remove: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IndustriesController_setStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_detail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_setStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_transfer: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_setGroups: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomersController_applyIndustryPack: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerTransferController_transfer: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_detail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_remove: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerGroupsController_updateMembers: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerFieldsController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerFieldsController_approve: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerFieldsOfCustomerController_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
CustomerFieldsOfCustomerController_apply: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
HealthController_check: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -13,4 +13,17 @@ export const queryKeys = {
|
||||
roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const,
|
||||
role: (roleId: string) => ['roles', 'detail', roleId] as const,
|
||||
permissions: ['permissions'] as const,
|
||||
// A3 client
|
||||
industries: ['industries'] as const,
|
||||
industryList: (query: object) => ['industries', 'list', query] as const,
|
||||
industry: (industryId: string) => ['industries', 'detail', industryId] as const,
|
||||
customers: ['customers'] as const,
|
||||
customerList: (query: object) => ['customers', 'list', query] as const,
|
||||
customer: (customerId: string) => ['customers', 'detail', customerId] as const,
|
||||
customerFieldsOf: (customerId: string) => ['customers', 'fields', customerId] as const,
|
||||
customerGroups: ['customer-groups'] as const,
|
||||
customerGroupList: (query: object) => ['customer-groups', 'list', query] as const,
|
||||
customerGroup: (groupId: string) => ['customer-groups', 'detail', groupId] as const,
|
||||
customerFields: (query: object) => ['customer-fields', 'list', query] as const,
|
||||
customerFieldsRoot: ['customer-fields'] as const,
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
import type {
|
||||
ApplyCustomerFieldInput,
|
||||
ApproveCustomerFieldInput,
|
||||
CreateCustomerGroupInput,
|
||||
CreateCustomerInput,
|
||||
CreateIndustryInput,
|
||||
CustomerDetail,
|
||||
CustomerField,
|
||||
CustomerFieldListItem,
|
||||
CustomerFieldListQuery,
|
||||
CustomerGroupDetail,
|
||||
CustomerGroupSummary,
|
||||
CustomerListQuery,
|
||||
CustomerSummary,
|
||||
IndustryDetail,
|
||||
IndustryListQuery,
|
||||
IndustryTreeItem,
|
||||
SetCustomerGroupsInput,
|
||||
SetCustomerStatusInput,
|
||||
SetIndustryStatusInput,
|
||||
TransferCustomerInput,
|
||||
UpdateCustomerGroupInput,
|
||||
UpdateCustomerGroupMembersInput,
|
||||
UpdateCustomerInput,
|
||||
UpdateIndustryInput,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
type UseMutationResult,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
applyCustomerField,
|
||||
approveCustomerField,
|
||||
createCustomer,
|
||||
createCustomerGroup,
|
||||
createIndustry,
|
||||
deleteCustomerGroup,
|
||||
deleteIndustry,
|
||||
fetchCustomerDetail,
|
||||
fetchCustomerFieldList,
|
||||
fetchCustomerFields,
|
||||
fetchCustomerGroupDetail,
|
||||
fetchCustomerGroupList,
|
||||
fetchCustomerList,
|
||||
fetchIndustryDetail,
|
||||
fetchIndustryList,
|
||||
setCustomerGroups,
|
||||
setCustomerStatus,
|
||||
setIndustryStatus,
|
||||
transferCustomer,
|
||||
updateCustomer,
|
||||
updateCustomerGroup,
|
||||
updateCustomerGroupMembers,
|
||||
updateIndustry,
|
||||
} from '@/api/customers';
|
||||
import { queryKeys } from '@/api/query-keys';
|
||||
|
||||
export function useIndustriesQuery(
|
||||
query: Partial<IndustryListQuery> = {},
|
||||
): UseQueryResult<{ items: IndustryTreeItem[]; next_cursor: string | null }> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.industryList(query),
|
||||
queryFn: () => fetchIndustryList(query),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIndustryDetailQuery(id: string | null): UseQueryResult<IndustryDetail> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.industry(id ?? ''),
|
||||
queryFn: () => fetchIndustryDetail(id ?? ''),
|
||||
enabled: id !== null && id !== '',
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomersQuery(
|
||||
query: Partial<CustomerListQuery> = {},
|
||||
): UseQueryResult<{ items: CustomerSummary[]; next_cursor: string | null }> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.customerList(query),
|
||||
queryFn: () => fetchCustomerList(query),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomerDetailQuery(id: string | null): UseQueryResult<CustomerDetail> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.customer(id ?? ''),
|
||||
queryFn: () => fetchCustomerDetail(id ?? ''),
|
||||
enabled: id !== null && id !== '',
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomerGroupsQuery(): UseQueryResult<{
|
||||
items: CustomerGroupSummary[];
|
||||
next_cursor: string | null;
|
||||
}> {
|
||||
const query = { limit: 100 };
|
||||
return useQuery({
|
||||
queryKey: queryKeys.customerGroupList(query),
|
||||
queryFn: () => fetchCustomerGroupList(query),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomerGroupDetailQuery(
|
||||
id: string | null,
|
||||
): UseQueryResult<CustomerGroupDetail> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.customerGroup(id ?? ''),
|
||||
queryFn: () => fetchCustomerGroupDetail(id ?? ''),
|
||||
enabled: id !== null && id !== '',
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomerFieldsQuery(
|
||||
query: Partial<CustomerFieldListQuery> = {},
|
||||
): UseQueryResult<{ items: CustomerFieldListItem[]; next_cursor: string | null }> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.customerFields(query),
|
||||
queryFn: () => fetchCustomerFieldList(query),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomerFieldsOfQuery(
|
||||
customerId: string | null,
|
||||
): UseQueryResult<CustomerField[]> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.customerFieldsOf(customerId ?? ''),
|
||||
queryFn: () => fetchCustomerFields(customerId ?? ''),
|
||||
enabled: customerId !== null && customerId !== '',
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateClient(): () => Promise<void> {
|
||||
const queryClient = useQueryClient();
|
||||
return async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.industries });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.customers });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.customerGroups });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.customerFieldsRoot });
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateIndustryMutation(): UseMutationResult<
|
||||
IndustryDetail,
|
||||
Error,
|
||||
CreateIndustryInput
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: createIndustry,
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateIndustryMutation(): UseMutationResult<
|
||||
IndustryDetail,
|
||||
Error,
|
||||
{ id: string; input: UpdateIndustryInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => updateIndustry(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetIndustryStatusMutation(): UseMutationResult<
|
||||
IndustryDetail,
|
||||
Error,
|
||||
{ id: string; input: SetIndustryStatusInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => setIndustryStatus(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteIndustryMutation(): UseMutationResult<void, Error, string> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteIndustry,
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCustomerMutation(): UseMutationResult<
|
||||
CustomerDetail,
|
||||
Error,
|
||||
CreateCustomerInput
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: createCustomer,
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCustomerMutation(): UseMutationResult<
|
||||
CustomerDetail,
|
||||
Error,
|
||||
{ id: string; input: UpdateCustomerInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => updateCustomer(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetCustomerStatusMutation(): UseMutationResult<
|
||||
CustomerDetail,
|
||||
Error,
|
||||
{ id: string; input: SetCustomerStatusInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => setCustomerStatus(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransferCustomerMutation(): UseMutationResult<
|
||||
CustomerDetail,
|
||||
Error,
|
||||
{ id: string; input: TransferCustomerInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => transferCustomer(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetCustomerGroupsMutation(): UseMutationResult<
|
||||
CustomerDetail,
|
||||
Error,
|
||||
{ id: string; input: SetCustomerGroupsInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => setCustomerGroups(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCustomerGroupMutation(): UseMutationResult<
|
||||
CustomerGroupDetail,
|
||||
Error,
|
||||
CreateCustomerGroupInput
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: createCustomerGroup,
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCustomerGroupMutation(): UseMutationResult<
|
||||
CustomerGroupDetail,
|
||||
Error,
|
||||
{ id: string; input: UpdateCustomerGroupInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => updateCustomerGroup(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCustomerGroupMembersMutation(): UseMutationResult<
|
||||
CustomerGroupDetail,
|
||||
Error,
|
||||
{ id: string; input: UpdateCustomerGroupMembersInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => updateCustomerGroupMembers(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCustomerGroupMutation(): UseMutationResult<void, Error, string> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteCustomerGroup,
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useApplyCustomerFieldMutation(): UseMutationResult<
|
||||
CustomerField,
|
||||
Error,
|
||||
{ customerId: string; input: ApplyCustomerFieldInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ customerId, input }) => applyCustomerField(customerId, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useApproveCustomerFieldMutation(): UseMutationResult<
|
||||
CustomerField,
|
||||
Error,
|
||||
{ id: string; input: ApproveCustomerFieldInput }
|
||||
> {
|
||||
const invalidate = useInvalidateClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }) => approveCustomerField(id, input),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '@/styles/users.css';
|
||||
import '@/styles/roles.css';
|
||||
import '@/styles/role-editor.css';
|
||||
import '@/styles/departments.css';
|
||||
import '@/styles/client.css';
|
||||
|
||||
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||
import { localeZhCN } from '@lingxi/ui';
|
||||
|
||||
@@ -53,6 +53,20 @@ describe('导航配置', () => {
|
||||
expect(findActiveNav(NAV_MODULES, '/system/departments')?.item?.label).toBe('部门管理');
|
||||
});
|
||||
|
||||
it('客户与行业菜单使用查看权限,名称按 A3 口径更名', () => {
|
||||
const systemItems = NAV_MODULES.find((module) => module.key === 'system')?.items ?? [];
|
||||
const customersItem = systemItems.find((item) => item.key === 'customers');
|
||||
const industriesItem = systemItems.find((item) => item.key === 'industries');
|
||||
expect(customersItem?.label).toBe('客户管理');
|
||||
expect(customersItem?.permission).toBe('customer:read');
|
||||
expect(industriesItem?.label).toBe('行业管理');
|
||||
expect(industriesItem?.permission).toBe('customer:read');
|
||||
expect(
|
||||
findActiveNav(NAV_MODULES, '/system/customers/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.item?.label,
|
||||
).toBe('客户管理');
|
||||
expect(findActiveNav(NAV_MODULES, '/system/industries')?.item?.label).toBe('行业管理');
|
||||
});
|
||||
|
||||
it('按路径定位当前模块与菜单', () => {
|
||||
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
|
||||
expect(active?.module.key).toBe('material');
|
||||
|
||||
@@ -273,19 +273,27 @@ export const NAV_MODULES: readonly NavModule[] = [
|
||||
},
|
||||
{
|
||||
key: 'customers',
|
||||
label: '客户',
|
||||
label: '客户管理',
|
||||
path: '/system/customers',
|
||||
icon: IconGlobal,
|
||||
permission: Permission.CUSTOMER_MANAGE,
|
||||
description: '客户、行业归属与数据范围',
|
||||
permission: Permission.CUSTOMER_READ,
|
||||
description: '客户档案、状态与交付偏好',
|
||||
},
|
||||
{
|
||||
key: 'customer-groups',
|
||||
label: '客户组',
|
||||
path: '/system/customer-groups',
|
||||
icon: IconApps,
|
||||
permission: Permission.CUSTOMER_GROUP_MANAGE,
|
||||
description: '客户组与批量授权单位',
|
||||
},
|
||||
{
|
||||
key: 'industries',
|
||||
label: '行业树',
|
||||
label: '行业管理',
|
||||
path: '/system/industries',
|
||||
icon: IconStack,
|
||||
permission: Permission.INDUSTRY_MANAGE,
|
||||
description: '行业分类树与行业包绑定',
|
||||
permission: Permission.CUSTOMER_READ,
|
||||
description: '两级行业树、状态与客户归属',
|
||||
},
|
||||
{
|
||||
key: 'dicts',
|
||||
@@ -338,9 +346,9 @@ export const NAV_MODULES: readonly NavModule[] = [
|
||||
{
|
||||
key: 'customer-fields',
|
||||
label: '客户字段',
|
||||
path: '/system/customer-fields',
|
||||
path: '/system/customer-fields/approvals',
|
||||
icon: IconTask,
|
||||
permission: Permission.CUSTOMER_MANAGE,
|
||||
permission: Permission.CUSTOMER_FIELD_APPROVE,
|
||||
description: '客户级扩展字段与审批',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
|
||||
import { type CustomerFieldListItem, Permission } from '@lingxi/shared';
|
||||
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
import {
|
||||
useApplyCustomerFieldMutation,
|
||||
useApproveCustomerFieldMutation,
|
||||
useCustomerFieldsQuery,
|
||||
useCustomersQuery,
|
||||
} from '@/hooks/queries/use-client';
|
||||
|
||||
function statusTag(status: CustomerFieldListItem['status']): ReactNode {
|
||||
const cls =
|
||||
status === 'active'
|
||||
? 'active'
|
||||
: status === 'pending'
|
||||
? 'paused'
|
||||
: status === 'rejected'
|
||||
? 'ended'
|
||||
: 'disabled';
|
||||
const label =
|
||||
status === 'active'
|
||||
? '已生效'
|
||||
: status === 'pending'
|
||||
? '待审批'
|
||||
: status === 'rejected'
|
||||
? '已驳回'
|
||||
: '已停用';
|
||||
return <span className={`lx-client-tag lx-client-tag--${cls}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function ApplyModal({ onClose }: { onClose: () => void }): ReactNode {
|
||||
const customers = useCustomersQuery({ limit: 100, status: 'active' });
|
||||
const apply = useApplyCustomerFieldMutation();
|
||||
const [customerId, setCustomerId] = useState<string | undefined>();
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [purpose, setPurpose] = useState('');
|
||||
const [type, setType] = useState<'text' | 'number' | 'date' | 'select'>('text');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async (): Promise<void> => {
|
||||
if (
|
||||
customerId === undefined ||
|
||||
name.trim() === '' ||
|
||||
code.trim() === '' ||
|
||||
purpose.trim() === ''
|
||||
) {
|
||||
setError('请完整填写客户、字段名称、编码与用途');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apply.mutateAsync({
|
||||
customerId,
|
||||
input: { name, code, type, purpose, entersGeneration: true, options: [] },
|
||||
});
|
||||
notifySuccess('字段申请已提交,等待审批');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '提交失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="申请客户字段"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="提交申请"
|
||||
>
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field is-full">
|
||||
<label>申请客户</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={customerId}
|
||||
placeholder="选择客户"
|
||||
optionList={(customers.data?.items ?? []).map((customer) => ({
|
||||
value: customer.id,
|
||||
label: customer.name,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setCustomerId(String(value));
|
||||
}}
|
||||
filter
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段名称</label>
|
||||
<Input value={name} onChange={setName} placeholder="如:活动编码" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段编码</label>
|
||||
<Input value={code} onChange={setCode} placeholder="如:campaign_code" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段类型</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={type}
|
||||
optionList={[
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'date', label: '日期' },
|
||||
{ value: 'select', label: '单选' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setType(value as typeof type);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>用途说明</label>
|
||||
<Input value={purpose} onChange={setPurpose} placeholder="说明字段用途与进入生成的原因" />
|
||||
</div>
|
||||
</div>
|
||||
{error !== null ? (
|
||||
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function RejectModal({
|
||||
field,
|
||||
onClose,
|
||||
}: {
|
||||
field: CustomerFieldListItem;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const approve = useApproveCustomerFieldMutation();
|
||||
const [reason, setReason] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async (): Promise<void> => {
|
||||
if (reason.trim() === '') {
|
||||
setError('驳回原因必填');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await approve.mutateAsync({ id: field.id, input: { decision: 'reject', reason } });
|
||||
notifySuccess('已驳回,申请人将收到通知');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '驳回失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="驳回字段申请"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ type: 'danger' }}
|
||||
>
|
||||
<div className="lx-client-hint">
|
||||
{field.customerName} · {field.name}({field.code})
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>驳回原因</label>
|
||||
<Input
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
placeholder="例如:与基础字段重复,仅需检索无需进入生成"
|
||||
/>
|
||||
</div>
|
||||
{error !== null ? (
|
||||
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** 客户字段审批(选定方案 A:全宽表格 + 审批弹窗) */
|
||||
export function CustomerFieldsPage(): ReactNode {
|
||||
const [status, setStatus] = useState<'all' | 'pending' | 'active' | 'rejected'>('all');
|
||||
const fields = useCustomerFieldsQuery({ limit: 100, status });
|
||||
const approve = useApproveCustomerFieldMutation();
|
||||
const [applyOpen, setApplyOpen] = useState(false);
|
||||
const [rejectField, setRejectField] = useState<CustomerFieldListItem | null>(null);
|
||||
|
||||
const items = fields.data?.items ?? [];
|
||||
|
||||
if (fields.isPending) return <PageSkeleton rows={8} />;
|
||||
|
||||
const approveField = (field: CustomerFieldListItem): void => {
|
||||
Modal.confirm({
|
||||
title: '通过字段申请',
|
||||
content: `通过后「${field.name}(${field.code})」立即生效并进入客户生成链路,操作会写入审计。`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await approve.mutateAsync({ id: field.id, input: { decision: 'approve' } });
|
||||
notifySuccess('已通过审批,字段已生效');
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '审批失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<header className="lx-client-header">
|
||||
<div>
|
||||
<h1>客户字段审批</h1>
|
||||
<p>客户级字段分两层:基础字段仅检索报表、无需审批;进入生成字段必须申请 → 审批 → 生效</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Can permission={Permission.CUSTOMER_FIELD_APPLY}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setApplyOpen(true);
|
||||
}}
|
||||
>
|
||||
申请字段
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
</header>
|
||||
<section className="lx-client-filters">
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
value={status}
|
||||
optionList={[
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'pending', label: '待审批' },
|
||||
{ value: 'active', label: '已生效' },
|
||||
{ value: 'rejected', label: '已驳回' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setStatus(value as typeof status);
|
||||
}}
|
||||
/>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--lx-color-text-secondary)' }}>
|
||||
共 {items.length} 条申请
|
||||
</span>
|
||||
</section>
|
||||
<section className="lx-client-table-card">
|
||||
{items.length === 0 ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>暂无字段申请</b>
|
||||
<span>客户提交进入生成字段的申请后,会出现在这里等待审批</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="lx-client-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户</th>
|
||||
<th>字段</th>
|
||||
<th>类型</th>
|
||||
<th>进入生成</th>
|
||||
<th>申请人</th>
|
||||
<th>申请时间</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((field) => (
|
||||
<tr key={field.id}>
|
||||
<td>
|
||||
<b>{field.customerName}</b>
|
||||
<span className="lx-client-sub">{field.industryPath}</span>
|
||||
</td>
|
||||
<td>
|
||||
<b>{field.name}</b>
|
||||
<span className="lx-client-sub">{field.code}</span>
|
||||
</td>
|
||||
<td>{field.type}</td>
|
||||
<td>{field.entersGeneration ? '是' : '否'}</td>
|
||||
<td>{field.applicantName}</td>
|
||||
<td className="lx-client-muted">
|
||||
{field.appliedAt.slice(0, 19).replace('T', ' ')}
|
||||
</td>
|
||||
<td>{statusTag(field.status)}</td>
|
||||
<td>
|
||||
{field.status === 'pending' ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setRejectField(field);
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
approveField(field);
|
||||
}}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="lx-client-muted">已处理</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="lx-client-tablefoot">
|
||||
审批与变更写入审计 · 未审批字段不进入生成 · 每客户进入生成字段 ≤ 5 个
|
||||
</div>
|
||||
</section>
|
||||
{applyOpen ? (
|
||||
<ApplyModal
|
||||
onClose={() => {
|
||||
setApplyOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{rejectField !== null ? (
|
||||
<RejectModal
|
||||
field={rejectField}
|
||||
onClose={() => {
|
||||
setRejectField(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
|
||||
import type { CustomerGroupDetail } from '@lingxi/shared';
|
||||
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
useCreateCustomerGroupMutation,
|
||||
useCustomerGroupDetailQuery,
|
||||
useCustomerGroupsQuery,
|
||||
useCustomersQuery,
|
||||
useDeleteCustomerGroupMutation,
|
||||
useUpdateCustomerGroupMembersMutation,
|
||||
useUpdateCustomerGroupMutation,
|
||||
} from '@/hooks/queries/use-client';
|
||||
|
||||
function GroupFormModal({
|
||||
group,
|
||||
onClose,
|
||||
}: {
|
||||
group?: CustomerGroupDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const create = useCreateCustomerGroupMutation();
|
||||
const update = useUpdateCustomerGroupMutation();
|
||||
const [name, setName] = useState(group?.name ?? '');
|
||||
const [description, setDescription] = useState(group?.description ?? '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async (): Promise<void> => {
|
||||
if (name.trim().length < 2) {
|
||||
setError('客户组名称至少 2 个字符');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (group !== undefined) {
|
||||
await update.mutateAsync({ id: group.id, input: { name, description } });
|
||||
notifySuccess('客户组已保存');
|
||||
} else {
|
||||
await create.mutateAsync({ name, description });
|
||||
notifySuccess('客户组已创建');
|
||||
}
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '保存失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title={group === undefined ? '新建客户组' : '编辑客户组'}
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="保存"
|
||||
>
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field is-full">
|
||||
<label>客户组名称</label>
|
||||
<Input value={name} onChange={setName} placeholder="如:电商核心组" />
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>说明</label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="说明客户组的用途与授权范围"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error !== null ? (
|
||||
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersModal({
|
||||
group,
|
||||
onClose,
|
||||
}: {
|
||||
group: CustomerGroupDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const customers = useCustomersQuery({ limit: 100 });
|
||||
const update = useUpdateCustomerGroupMembersMutation();
|
||||
const currentIds = group.members.map((member) => member.id);
|
||||
const [selected, setSelected] = useState<string[]>(currentIds);
|
||||
const submit = async (): Promise<void> => {
|
||||
const addCustomerIds = selected.filter((id) => !currentIds.includes(id));
|
||||
const removeCustomerIds = currentIds.filter((id) => !selected.includes(id));
|
||||
try {
|
||||
await update.mutateAsync({ id: group.id, input: { addCustomerIds, removeCustomerIds } });
|
||||
notifySuccess(
|
||||
`已更新客户组成员(新增 ${addCustomerIds.length} / 移除 ${removeCustomerIds.length})`,
|
||||
);
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '保存失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="管理客户组成员"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="保存成员"
|
||||
>
|
||||
<div className="lx-client-field">
|
||||
<label>成员客户</label>
|
||||
<Select
|
||||
multiple
|
||||
style={{ width: '100%' }}
|
||||
value={selected}
|
||||
optionList={(customers.data?.items ?? []).map((customer) => ({
|
||||
value: customer.id,
|
||||
label: `${customer.name}(${customer.industryPath})`,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setSelected(value as string[]);
|
||||
}}
|
||||
filter
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
调整成员会立即改变引用该客户组的角色可见客户集合;变更写入审计。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** 客户组(选定方案 B:左列表 + 右成员详情) */
|
||||
export function CustomerGroupsPage(): ReactNode {
|
||||
const groups = useCustomerGroupsQuery();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const activeId = selectedId ?? groups.data?.items[0]?.id ?? null;
|
||||
const detail = useCustomerGroupDetailQuery(activeId);
|
||||
const remove = useDeleteCustomerGroupMutation();
|
||||
const [modal, setModal] = useState<'create' | 'edit' | 'members' | null>(null);
|
||||
|
||||
const items = useMemo(() => groups.data?.items ?? [], [groups.data]);
|
||||
if (groups.isPending) return <PageSkeleton rows={6} />;
|
||||
|
||||
const deleteGroup = (): void => {
|
||||
if (detail.data === undefined) return;
|
||||
Modal.confirm({
|
||||
title: '删除客户组',
|
||||
content: '删除前需确认无角色数据范围引用;删除后不可恢复。',
|
||||
okButtonProps: { type: 'danger' },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await remove.mutateAsync(detail.data.id);
|
||||
notifySuccess('客户组已删除');
|
||||
setSelectedId(null);
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<header className="lx-client-header">
|
||||
<div>
|
||||
<h1>客户组</h1>
|
||||
<p>把客户按行业、团队或服务等级分组;客户组是批量授权与数据范围的最小单位</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setModal('create');
|
||||
}}
|
||||
>
|
||||
新建客户组
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="lx-client-split">
|
||||
<section className="lx-client-left">
|
||||
<div className="lx-client-left__head">
|
||||
<b>客户组</b>
|
||||
<span>{items.length} 个 · 覆盖 9 个客户</span>
|
||||
</div>
|
||||
<div className="lx-client-left__body">
|
||||
{items.length === 0 ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>暂无客户组</b>
|
||||
<span>新建客户组,把客户按行业 / 团队分组</span>
|
||||
</div>
|
||||
) : (
|
||||
items.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
className={`lx-client-tree__row${group.id === activeId ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedId(group.id);
|
||||
}}
|
||||
style={{ height: 46 }}
|
||||
>
|
||||
<span className="lx-client-tree__name">
|
||||
<b style={{ display: 'block', fontWeight: 500 }}>{group.name}</b>
|
||||
<span style={{ fontSize: 11, color: 'var(--lx-color-text-secondary)' }}>
|
||||
{group.description}
|
||||
</span>
|
||||
</span>
|
||||
<span className="lx-client-tree__count">{group.memberCount} 个</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="lx-client-left__foot">共 {items.length} 个客户组 · 授权最小单位</div>
|
||||
</section>
|
||||
<section className="lx-client-right">
|
||||
{detail.data === undefined ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>选择客户组</b>
|
||||
<span>从左侧选择客户组查看成员</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="lx-client-right__head">
|
||||
<div>
|
||||
<h2>{detail.data.name}</h2>
|
||||
<p>
|
||||
{detail.data.description || '暂无说明'} · {detail.data.memberCount} 个客户
|
||||
</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModal('members');
|
||||
}}
|
||||
>
|
||||
管理成员
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModal('edit');
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="danger" onClick={deleteGroup}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-client-right__body">
|
||||
<div className="lx-client-hint">
|
||||
调整成员会立即改变引用该客户组的角色数据范围;变更写入审计。
|
||||
</div>
|
||||
<div className="lx-client-memberlist">
|
||||
{detail.data.members.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无成员</div>
|
||||
) : null}
|
||||
{detail.data.members.map((member) => (
|
||||
<div key={member.id} className="lx-client-memberrow">
|
||||
<b>{member.name}</b>
|
||||
<span>
|
||||
{member.industryPath} · {member.ownerName}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{modal === 'create' ? (
|
||||
<GroupFormModal
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{modal === 'edit' && detail.data !== undefined ? (
|
||||
<GroupFormModal
|
||||
group={detail.data}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{modal === 'members' && detail.data !== undefined ? (
|
||||
<MembersModal
|
||||
group={detail.data}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19';
|
||||
import { type CustomerDetail, type CustomerField, Permission } from '@lingxi/shared';
|
||||
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||
import { IconBack } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
import {
|
||||
useApplyCustomerFieldMutation,
|
||||
useCustomerDetailQuery,
|
||||
useCustomerFieldsOfQuery,
|
||||
useCustomerGroupsQuery,
|
||||
useSetCustomerGroupsMutation,
|
||||
useSetCustomerStatusMutation,
|
||||
useTransferCustomerMutation,
|
||||
useUpdateCustomerMutation,
|
||||
} from '@/hooks/queries/use-client';
|
||||
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
|
||||
import { ROUTES } from '@/routes';
|
||||
|
||||
import { CustomerStatusTag } from './CustomerStatusTag';
|
||||
|
||||
function Section({
|
||||
title,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<section className="lx-client-section">
|
||||
<div className="lx-client-section__head">
|
||||
<b>{title}</b>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldStatusTag({ field }: { field: CustomerField }): ReactNode {
|
||||
const cls =
|
||||
field.status === 'active'
|
||||
? 'active'
|
||||
: field.status === 'pending'
|
||||
? 'paused'
|
||||
: field.status === 'rejected'
|
||||
? 'ended'
|
||||
: 'disabled';
|
||||
const label =
|
||||
field.status === 'active'
|
||||
? '已生效'
|
||||
: field.status === 'pending'
|
||||
? '待审批'
|
||||
: field.status === 'rejected'
|
||||
? '已驳回'
|
||||
: '已停用';
|
||||
return <span className={`lx-client-tag lx-client-tag--${cls}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function TransferModal({
|
||||
customer,
|
||||
onClose,
|
||||
}: {
|
||||
customer: CustomerDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const users = useUserCandidatesQuery(true);
|
||||
const transfer = useTransferCustomerMutation();
|
||||
const [ownerUserId, setOwnerUserId] = useState(customer.ownerUserId);
|
||||
const submit = async (): Promise<void> => {
|
||||
try {
|
||||
await transfer.mutateAsync({ id: customer.id, input: { ownerUserId } });
|
||||
notifySuccess('客户负责人已转交,已写入审计');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '转交失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="变更客户负责人"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="确认转交"
|
||||
>
|
||||
<div className="lx-client-field">
|
||||
<label>新的客户负责人</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={ownerUserId}
|
||||
optionList={(users.data?.items ?? []).map((user) => ({
|
||||
value: user.id,
|
||||
label: `${user.name}(${user.departmentName})`,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setOwnerUserId(String(value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
转交后该客户会从原负责人的「负责客户」范围移出,并写入审计。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function BaseFieldModal({
|
||||
customer,
|
||||
onClose,
|
||||
}: {
|
||||
customer: CustomerDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const update = useUpdateCustomerMutation();
|
||||
const [key, setKey] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const submit = async (): Promise<void> => {
|
||||
if (key.trim() === '') return;
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
id: customer.id,
|
||||
input: { baseFields: [...customer.baseFields, { key: key.trim(), value: value.trim() }] },
|
||||
});
|
||||
notifySuccess('基础字段已保存');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '保存失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal visible title="添加基础字段" onCancel={onClose} onOk={() => void submit()} okText="保存">
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field">
|
||||
<label>字段键</label>
|
||||
<Input value={key} onChange={setKey} placeholder="如:门店编号" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段值</label>
|
||||
<Input value={value} onChange={setValue} placeholder="如:沪-012" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
基础字段仅用于检索与报表,永不进入生成;每客户最多 10 个。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ApplyFieldModal({
|
||||
customer,
|
||||
onClose,
|
||||
}: {
|
||||
customer: CustomerDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const apply = useApplyCustomerFieldMutation();
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [type, setType] = useState<'text' | 'number' | 'date' | 'select'>('text');
|
||||
const [purpose, setPurpose] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async (): Promise<void> => {
|
||||
if (name.trim() === '' || code.trim() === '' || purpose.trim() === '') {
|
||||
setError('请填写字段名称、编码与用途说明');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apply.mutateAsync({
|
||||
customerId: customer.id,
|
||||
input: { name, code, type, purpose, entersGeneration: true, options: [] },
|
||||
});
|
||||
notifySuccess('字段申请已提交,等待审批');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '提交失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="申请客户字段"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="提交申请"
|
||||
>
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field">
|
||||
<label>字段名称</label>
|
||||
<Input value={name} onChange={setName} placeholder="如:活动编码" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段编码</label>
|
||||
<Input value={code} onChange={setCode} placeholder="如:campaign_code" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>字段类型</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={type}
|
||||
optionList={[
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'date', label: '日期' },
|
||||
{ value: 'select', label: '单选' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setType(value as typeof type);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>用途说明</label>
|
||||
<Input value={purpose} onChange={setPurpose} placeholder="说明字段用途与进入生成的原因" />
|
||||
</div>
|
||||
</div>
|
||||
{error !== null ? (
|
||||
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="lx-client-hint">进入生成字段需审批后生效;每客户最多 5 个。</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupsModal({
|
||||
customer,
|
||||
onClose,
|
||||
}: {
|
||||
customer: CustomerDetail;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const groups = useCustomerGroupsQuery();
|
||||
const update = useSetCustomerGroupsMutation();
|
||||
const [groupIds, setGroupIds] = useState<string[]>(customer.groups.map((group) => group.id));
|
||||
const submit = async (): Promise<void> => {
|
||||
try {
|
||||
await update.mutateAsync({ id: customer.id, input: { groupIds } });
|
||||
notifySuccess('客户组归属已更新');
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '保存失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="管理客户所属客户组"
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="保存"
|
||||
>
|
||||
<div className="lx-client-field">
|
||||
<label>客户组</label>
|
||||
<Select
|
||||
multiple
|
||||
style={{ width: '100%' }}
|
||||
value={groupIds}
|
||||
optionList={(groups.data?.items ?? []).map((group) => ({
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setGroupIds(value as string[]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
客户组是数据范围授权的最小单位;调整会立即影响引用该客户组的角色可见范围。
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** 客户详情(选定方案 A:独立三栏档案页) */
|
||||
export function CustomerDetailPage(): ReactNode {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const detail = useCustomerDetailQuery(id ?? null);
|
||||
const fields = useCustomerFieldsOfQuery(id ?? null);
|
||||
const setStatus = useSetCustomerStatusMutation();
|
||||
const update = useUpdateCustomerMutation();
|
||||
const [modal, setModal] = useState<'transfer' | 'base' | 'field' | 'groups' | null>(null);
|
||||
|
||||
if (detail.isPending) return <PageSkeleton rows={8} />;
|
||||
if (detail.error) {
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<div className="lx-client-empty">
|
||||
<b>客户加载失败</b>
|
||||
<span>{detail.error.message}</span>
|
||||
<Button onClick={() => void navigate(ROUTES.system.customers)}>返回客户列表</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const customer = detail.data;
|
||||
const canWrite = customer.status === 'active';
|
||||
const changeStatus = (target: 'paused' | 'ended' | 'active'): void => {
|
||||
Modal.confirm({
|
||||
title:
|
||||
target === 'paused' ? '暂停客户服务' : target === 'ended' ? '结束客户服务' : '恢复客户服务',
|
||||
content:
|
||||
target === 'paused'
|
||||
? '暂停后档案只读,不能发起新的生成任务;可随时恢复。'
|
||||
: target === 'ended'
|
||||
? '结束后全部数据只读归档,且不能恢复;如继续合作需新建客户。'
|
||||
: '恢复后档案可编辑,并可以重新发起生成任务。',
|
||||
okButtonProps: target === 'ended' ? { type: 'danger' } : undefined,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await setStatus.mutateAsync({ id: customer.id, input: { status: target } });
|
||||
notifySuccess(
|
||||
target === 'paused'
|
||||
? '客户已暂停'
|
||||
: target === 'ended'
|
||||
? '客户已结束并归档'
|
||||
: '客户已恢复为在服务',
|
||||
);
|
||||
void detail.refetch();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '操作失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const removeBaseField = async (index: number): Promise<void> => {
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
id: customer.id,
|
||||
input: {
|
||||
baseFields: customer.baseFields.filter((_item, itemIndex) => itemIndex !== index),
|
||||
},
|
||||
});
|
||||
notifySuccess('基础字段已删除');
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '删除失败');
|
||||
}
|
||||
};
|
||||
const fieldRows = fields.data ?? customer.fields;
|
||||
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<header className="lx-client-header">
|
||||
<div>
|
||||
<a
|
||||
className="lx-client-back"
|
||||
href={ROUTES.system.customers}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate(ROUTES.system.customers);
|
||||
}}
|
||||
>
|
||||
<IconBack size={14} /> 返回客户列表
|
||||
</a>
|
||||
<h1>
|
||||
{customer.name} <CustomerStatusTag status={customer.status} />
|
||||
</h1>
|
||||
<p>
|
||||
{customer.shortName} · {customer.industryPath} · 负责人 {customer.ownerName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Can permission={Permission.CUSTOMER_TRANSFER}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModal('transfer');
|
||||
}}
|
||||
disabled={customer.status === 'ended'}
|
||||
>
|
||||
变更负责人
|
||||
</Button>
|
||||
</Can>
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
{customer.status === 'active' ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
changeStatus('paused');
|
||||
}}
|
||||
>
|
||||
暂停服务
|
||||
</Button>
|
||||
) : null}
|
||||
{customer.status === 'paused' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
changeStatus('active');
|
||||
}}
|
||||
>
|
||||
恢复服务
|
||||
</Button>
|
||||
) : null}
|
||||
{customer.status !== 'ended' ? (
|
||||
<Button
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
changeStatus('ended');
|
||||
}}
|
||||
>
|
||||
结束服务
|
||||
</Button>
|
||||
) : null}
|
||||
</Can>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{customer.status !== 'active' ? (
|
||||
<div
|
||||
className={`lx-client-banner lx-client-banner--${customer.status === 'paused' ? 'paused' : 'ended'}`}
|
||||
>
|
||||
{customer.status === 'paused'
|
||||
? '客户已暂停:档案只读,不能发起新的生成任务。恢复为「在服务」后可继续。'
|
||||
: '客户已结束服务:全部数据只读归档,不能编辑或发起任务。'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="lx-client-grid">
|
||||
<aside className="lx-client-anchors">
|
||||
<a href="#basic">基本信息</a>
|
||||
<a href="#prefs">交付偏好</a>
|
||||
<a href="#base-fields">基础字段</a>
|
||||
<a href="#gen-fields">进入生成</a>
|
||||
<a href="#groups">客户组</a>
|
||||
<a href="#asset">资产包</a>
|
||||
</aside>
|
||||
<div className="lx-client-panel">
|
||||
<Section title="基本信息">
|
||||
<div className="lx-client-kv" id="basic">
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户名称</span>
|
||||
<b>{customer.name}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户简称</span>
|
||||
<b>{customer.shortName}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>所属行业</span>
|
||||
<b>{customer.industryPath}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>服务状态</span>
|
||||
<b>
|
||||
<CustomerStatusTag status={customer.status} />
|
||||
</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户负责人</span>
|
||||
<b>{customer.ownerName}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户侧对接人</span>
|
||||
<b>
|
||||
{customer.contactName ?? '—'} {customer.contactPhone ?? ''}
|
||||
</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>创建时间</span>
|
||||
<b>{customer.createdAt.slice(0, 10)}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>最近更新</span>
|
||||
<b>{customer.updatedAt.slice(0, 19).replace('T', ' ')}</b>
|
||||
</div>
|
||||
</div>
|
||||
{customer.remark !== null ? (
|
||||
<div className="lx-client-hint">{customer.remark}</div>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section title="交付偏好">
|
||||
<div className="lx-client-kv" id="prefs">
|
||||
<div className="lx-client-kv__item">
|
||||
<span>命名习惯</span>
|
||||
<b>{customer.preferences.naming || '—'}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>目录结构</span>
|
||||
<b>{customer.preferences.directory || '—'}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
常用规格:{customer.preferences.specs.join('、') || '—'}
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
素材禁忌:{customer.preferences.taboo.join('、') || '—'}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="基础字段"
|
||||
action={
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!canWrite || customer.baseFields.length >= 10}
|
||||
onClick={() => {
|
||||
setModal('base');
|
||||
}}
|
||||
>
|
||||
添加基础字段
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
>
|
||||
<div id="base-fields">
|
||||
{customer.baseFields.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无基础字段</div>
|
||||
) : null}
|
||||
{customer.baseFields.map((field, index) => (
|
||||
<div key={`${field.key}-${index}`} className="lx-client-memberrow">
|
||||
<b>{field.key}</b>
|
||||
<span>{field.value}</span>
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
<Button
|
||||
size="small"
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
disabled={!canWrite}
|
||||
onClick={() => void removeBaseField(index)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
自由键值对,仅用于检索与报表,永不进入生成;每客户 ≤ 10 个(
|
||||
{customer.baseFields.length}/10)。
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="进入生成字段"
|
||||
action={
|
||||
<Can permission={Permission.CUSTOMER_FIELD_APPLY}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!canWrite || customer.genFieldCount >= 5}
|
||||
onClick={() => {
|
||||
setModal('field');
|
||||
}}
|
||||
>
|
||||
申请字段
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
>
|
||||
<div id="gen-fields">
|
||||
{fieldRows.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无进入生成字段</div>
|
||||
) : null}
|
||||
{fieldRows.map((field) => (
|
||||
<div key={field.id} className="lx-client-memberrow">
|
||||
<b>{field.name}</b>
|
||||
<span>
|
||||
{field.code} · {field.type} · {field.purpose}
|
||||
</span>
|
||||
<FieldStatusTag field={field} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
需申请 → 审批 → 生效;每客户 ≤ 5 个({customer.genFieldCount}/5)。
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="客户组"
|
||||
action={
|
||||
<Can permission={Permission.CUSTOMER_GROUP_MANAGE}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setModal('groups');
|
||||
}}
|
||||
>
|
||||
管理客户组
|
||||
</Button>
|
||||
</Can>
|
||||
}
|
||||
>
|
||||
<div className="lx-client-chips" id="groups">
|
||||
{customer.groups.length === 0 ? (
|
||||
<span className="lx-client-hint">未加入客户组</span>
|
||||
) : null}
|
||||
{customer.groups.map((group) => (
|
||||
<span key={group.id} className="lx-client-chip">
|
||||
{group.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="行业资产包">
|
||||
<div className="lx-client-assetempty" id="asset">
|
||||
<b>该行业尚未发布可用行业包</b>
|
||||
行业包发布后可在此一键套用模板、场景、人物、提示词与卖点,并替换为客户 Logo 与警示语。
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<aside className="lx-client-side">
|
||||
<section className="lx-client-sidecard">
|
||||
<h3>服务状态</h3>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>进入生成字段</span>
|
||||
<b>{customer.genFieldCount} / 5</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>基础字段</span>
|
||||
<b>{customer.baseFieldCount} / 10</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户组</span>
|
||||
<b>{customer.groups.length} 个</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>资产包</span>
|
||||
<b>待行业包发布</b>
|
||||
</div>
|
||||
</section>
|
||||
<section className="lx-client-sidecard">
|
||||
<h3>审计提示</h3>
|
||||
<div className="lx-client-hint">
|
||||
客户状态、负责人、字段与客户组的变更都会写入审计,记录前后值与操作人。
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{modal === 'transfer' ? (
|
||||
<TransferModal
|
||||
customer={customer}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{modal === 'base' ? (
|
||||
<BaseFieldModal
|
||||
customer={customer}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{modal === 'field' ? (
|
||||
<ApplyFieldModal
|
||||
customer={customer}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{modal === 'groups' ? (
|
||||
<GroupsModal
|
||||
customer={customer}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Select, Steps } from '@douyinfe/semi-ui-19';
|
||||
import type { CreateCustomerInput } from '@lingxi/shared';
|
||||
import { notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useCreateCustomerMutation, useIndustriesQuery } from '@/hooks/queries/use-client';
|
||||
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
|
||||
import { customerDetailPath, ROUTES } from '@/routes';
|
||||
|
||||
const STEPS = ['选择行业', '基础信息', '交付偏好', '行业资产包'];
|
||||
|
||||
/** 新建客户(选定方案 B:左侧步骤导航 + 右侧表单) */
|
||||
export function CustomerNewPage(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const industries = useIndustriesQuery({ limit: 100 });
|
||||
const users = useUserCandidatesQuery(true);
|
||||
const create = useCreateCustomerMutation();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [industryId, setIndustryId] = useState<string | undefined>();
|
||||
const [name, setName] = useState('');
|
||||
const [shortName, setShortName] = useState('');
|
||||
const [ownerUserId, setOwnerUserId] = useState<string | undefined>();
|
||||
const [contactName, setContactName] = useState('');
|
||||
const [contactPhone, setContactPhone] = useState('');
|
||||
const [remark, setRemark] = useState('');
|
||||
const [naming, setNaming] = useState('客户名_方向_日期_序号');
|
||||
const [directory, setDirectory] = useState('客户名/年份/方向');
|
||||
const [specs, setSpecs] = useState('1080×1920,1080×1080');
|
||||
const [taboo, setTaboo] = useState('绝对化用语,未授权肖像');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const industryOptions = useMemo(() => {
|
||||
const options: { value: string; label: string; leaf: boolean }[] = [];
|
||||
for (const root of industries.data?.items ?? []) {
|
||||
if (root.status !== 'active') continue;
|
||||
if (root.children.length === 0) {
|
||||
options.push({ value: root.id, label: root.name, leaf: true });
|
||||
} else {
|
||||
for (const child of root.children) {
|
||||
if (child.status === 'active')
|
||||
options.push({ value: child.id, label: `${root.name} / ${child.name}`, leaf: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}, [industries.data]);
|
||||
|
||||
const next = (): void => {
|
||||
setError(null);
|
||||
if (current === 0 && industryId === undefined) {
|
||||
setError('请选择客户归属行业');
|
||||
return;
|
||||
}
|
||||
if (current === 1) {
|
||||
if (name.trim() === '') {
|
||||
setError('请输入客户名称');
|
||||
return;
|
||||
}
|
||||
if (shortName.trim().length < 2) {
|
||||
setError('客户简称必填,用于自动命名(42201)');
|
||||
return;
|
||||
}
|
||||
if (ownerUserId === undefined) {
|
||||
setError('请选择客户负责人');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCurrent((step) => Math.min(step + 1, 3));
|
||||
};
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (industryId === undefined || ownerUserId === undefined) return;
|
||||
const input: CreateCustomerInput = {
|
||||
industryId,
|
||||
name,
|
||||
shortName,
|
||||
ownerUserId,
|
||||
contactName: contactName === '' ? undefined : contactName,
|
||||
contactPhone: contactPhone === '' ? undefined : contactPhone,
|
||||
remark: remark === '' ? undefined : remark,
|
||||
preferences: {
|
||||
naming,
|
||||
directory,
|
||||
specs: specs
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== ''),
|
||||
taboo: taboo
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== ''),
|
||||
},
|
||||
};
|
||||
try {
|
||||
const created = await create.mutateAsync(input);
|
||||
notifySuccess('客户已创建');
|
||||
void navigate(customerDetailPath(created.id));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (industries.isPending) return <PageSkeleton rows={6} />;
|
||||
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<header className="lx-client-header">
|
||||
<div>
|
||||
<h1>新建客户</h1>
|
||||
<p>分四步创建客户:选择行业 → 基础信息 → 交付偏好 → 行业资产包</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Button onClick={() => void navigate(ROUTES.system.customers)}>取消</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="lx-client-newgrid">
|
||||
<aside className="lx-client-stepnav">
|
||||
<Steps current={current} direction="vertical">
|
||||
{STEPS.map((title) => (
|
||||
<Steps.Step key={title} title={title} />
|
||||
))}
|
||||
</Steps>
|
||||
</aside>
|
||||
<section className="lx-client-formcard">
|
||||
<div className="lx-client-formcard__body">
|
||||
{current === 0 ? (
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field is-full">
|
||||
<label>
|
||||
客户归属行业 <i>*</i>
|
||||
</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={industryId}
|
||||
placeholder="选择末级行业"
|
||||
optionList={industryOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setIndustryId(String(value));
|
||||
}}
|
||||
/>
|
||||
<div className="lx-client-hint">
|
||||
有子行业时必须选择二级;一级行业无子行业时可直接选择。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{current === 1 ? (
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field">
|
||||
<label>
|
||||
客户名称 <i>*</i>
|
||||
</label>
|
||||
<Input value={name} onChange={setName} placeholder="如:花间集" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>
|
||||
客户简称 <i>*</i>
|
||||
</label>
|
||||
<Input value={shortName} onChange={setShortName} placeholder="如:HJ" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>
|
||||
客户负责人 <i>*</i>
|
||||
</label>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={ownerUserId}
|
||||
optionList={(users.data?.items ?? []).map((user) => ({
|
||||
value: user.id,
|
||||
label: `${user.name}(${user.departmentName})`,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setOwnerUserId(String(value));
|
||||
}}
|
||||
filter
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>客户侧对接人</label>
|
||||
<Input value={contactName} onChange={setContactName} placeholder="如:林晓" />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>对接人电话</label>
|
||||
<Input
|
||||
value={contactPhone}
|
||||
onChange={setContactPhone}
|
||||
placeholder="如:138****2210"
|
||||
/>
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>备注</label>
|
||||
<Input
|
||||
value={remark}
|
||||
onChange={setRemark}
|
||||
placeholder="客户偏好、合作背景等(可选)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{current === 2 ? (
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field">
|
||||
<label>命名习惯</label>
|
||||
<Input value={naming} onChange={setNaming} />
|
||||
</div>
|
||||
<div className="lx-client-field">
|
||||
<label>目录结构</label>
|
||||
<Input value={directory} onChange={setDirectory} />
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>常用规格(逗号分隔)</label>
|
||||
<Input value={specs} onChange={setSpecs} />
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>素材禁忌(逗号分隔)</label>
|
||||
<Input value={taboo} onChange={setTaboo} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{current === 3 ? (
|
||||
<div className="lx-client-assetempty">
|
||||
<b>该行业尚未发布可用行业包</b>
|
||||
行业包发布后可在此一键套用模板、场景、人物、提示词与卖点,并自动替换为客户 Logo
|
||||
与警示语。当前可以先完成客户创建,行业包发布后回客户详情套用。
|
||||
</div>
|
||||
) : null}
|
||||
{error !== null ? (
|
||||
<div className="lx-client-hint" style={{ color: 'var(--lx-color-danger)' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="lx-client-formcard__foot">
|
||||
<span className="lx-client-hint">步骤 {current + 1} / 4 · 可回退修改</span>
|
||||
<span className="spacer" />
|
||||
{current > 0 ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCurrent((step) => step - 1);
|
||||
}}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
) : null}
|
||||
{current < 3 ? (
|
||||
<Button type="primary" onClick={next}>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" loading={create.isPending} onClick={() => void submit()}>
|
||||
创建客户
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { CustomerStatus } from '@lingxi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const LABELS: Record<CustomerStatus, string> = {
|
||||
active: '在服务',
|
||||
paused: '暂停',
|
||||
ended: '结束',
|
||||
};
|
||||
|
||||
export function CustomerStatusTag({ status }: { status: CustomerStatus }): ReactNode {
|
||||
return <span className={`lx-client-tag lx-client-tag--${status}`}>{LABELS[status]}</span>;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Select } from '@douyinfe/semi-ui-19';
|
||||
import { type CustomerSummary, Permission } from '@lingxi/shared';
|
||||
import { PageSkeleton } from '@lingxi/ui';
|
||||
import { IconAdd, IconSearch } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
import {
|
||||
useCustomerGroupsQuery,
|
||||
useCustomersQuery,
|
||||
useIndustriesQuery,
|
||||
} from '@/hooks/queries/use-client';
|
||||
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
|
||||
import { customerDetailPath, customerNewPath, ROUTES } from '@/routes';
|
||||
|
||||
import { CustomerStatusTag } from './CustomerStatusTag';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'active', label: '在服务' },
|
||||
{ value: 'paused', label: '暂停' },
|
||||
{ value: 'ended', label: '结束' },
|
||||
];
|
||||
|
||||
/** 客户管理(选定方案 A:全宽表格 + 独立详情页) */
|
||||
export function CustomersPage(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [industryId, setIndustryId] = useState<string | undefined>();
|
||||
const [status, setStatus] = useState<'all' | 'active' | 'paused' | 'ended'>('all');
|
||||
const [ownerUserId, setOwnerUserId] = useState<string | undefined>();
|
||||
const [groupId, setGroupId] = useState<string | undefined>();
|
||||
|
||||
const industries = useIndustriesQuery({ limit: 100 });
|
||||
const groups = useCustomerGroupsQuery();
|
||||
const users = useUserCandidatesQuery(true);
|
||||
const customers = useCustomersQuery({
|
||||
limit: 100,
|
||||
...(keyword.trim() === '' ? {} : { keyword: keyword.trim() }),
|
||||
...(industryId === undefined ? {} : { industry_id: industryId }),
|
||||
...(status === 'all' ? {} : { status }),
|
||||
...(ownerUserId === undefined ? {} : { owner_user_id: ownerUserId }),
|
||||
...(groupId === undefined ? {} : { group_id: groupId }),
|
||||
});
|
||||
|
||||
const industryOptions = useMemo(() => {
|
||||
const options: { value: string; label: string }[] = [];
|
||||
for (const root of industries.data?.items ?? []) {
|
||||
options.push({ value: root.id, label: root.name });
|
||||
for (const child of root.children) {
|
||||
options.push({ value: child.id, label: `${root.name} / ${child.name}` });
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}, [industries.data]);
|
||||
|
||||
const rows = customers.data?.items ?? [];
|
||||
|
||||
const openDetail = (customer: CustomerSummary): void => {
|
||||
void navigate(customerDetailPath(customer.id));
|
||||
};
|
||||
|
||||
if (customers.isPending) return <PageSkeleton rows={8} />;
|
||||
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<header className="lx-client-header">
|
||||
<div>
|
||||
<h1>客户管理</h1>
|
||||
<p>客户是资产与生产任务的隔离单元;暂停后只读,结束服务后全部数据只读归档</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconAdd size={16} />}
|
||||
onClick={() => void navigate(customerNewPath())}
|
||||
>
|
||||
新建客户
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="lx-client-filters" aria-label="筛选客户">
|
||||
<Input
|
||||
style={{ width: 220 }}
|
||||
value={keyword}
|
||||
prefix={<IconSearch size={16} />}
|
||||
placeholder="搜索客户名称或简称"
|
||||
showClear
|
||||
onChange={setKeyword}
|
||||
aria-label="搜索客户"
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
value={industryId}
|
||||
placeholder="全部行业"
|
||||
optionList={[{ value: '', label: '全部行业' }, ...industryOptions]}
|
||||
onChange={(value) => {
|
||||
setIndustryId(typeof value === 'string' && value !== '' ? value : undefined);
|
||||
}}
|
||||
showClear
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={status}
|
||||
optionList={STATUS_OPTIONS}
|
||||
onChange={(value) => {
|
||||
setStatus(value as typeof status);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 160 }}
|
||||
value={ownerUserId}
|
||||
placeholder="全部负责人"
|
||||
optionList={[
|
||||
{ value: '', label: '全部负责人' },
|
||||
...(users.data?.items ?? []).map((user) => ({ value: user.id, label: user.name })),
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setOwnerUserId(typeof value === 'string' && value !== '' ? value : undefined);
|
||||
}}
|
||||
showClear
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 160 }}
|
||||
value={groupId}
|
||||
placeholder="全部客户组"
|
||||
optionList={[
|
||||
{ value: '', label: '全部客户组' },
|
||||
...(groups.data?.items ?? []).map((group) => ({ value: group.id, label: group.name })),
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setGroupId(typeof value === 'string' && value !== '' ? value : undefined);
|
||||
}}
|
||||
showClear
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeyword('');
|
||||
setIndustryId(undefined);
|
||||
setStatus('all');
|
||||
setOwnerUserId(undefined);
|
||||
setGroupId(undefined);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--lx-color-text-secondary)' }}>
|
||||
共 {rows.length} 个客户
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className="lx-client-table-card">
|
||||
{customers.error ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>客户加载失败</b>
|
||||
<span>{customers.error.message}</span>
|
||||
<Button onClick={() => void customers.refetch()}>重试</Button>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>暂无客户</b>
|
||||
<span>按当前筛选条件没有客户,试试重置筛选</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="lx-client-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户</th>
|
||||
<th>所属行业</th>
|
||||
<th>状态</th>
|
||||
<th>负责人</th>
|
||||
<th>客户组</th>
|
||||
<th>进入生成</th>
|
||||
<th>基础字段</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((customer) => (
|
||||
<tr
|
||||
key={customer.id}
|
||||
onClick={() => {
|
||||
openDetail(customer);
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<b>{customer.name}</b>
|
||||
<span className="lx-client-sub">{customer.shortName}</span>
|
||||
</td>
|
||||
<td>{customer.industryPath}</td>
|
||||
<td>
|
||||
<CustomerStatusTag status={customer.status} />
|
||||
</td>
|
||||
<td>{customer.ownerName}</td>
|
||||
<td>{customer.groups.map((group) => group.name).join('、') || '—'}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`lx-client-count${customer.genFieldCount >= 5 ? ' is-full' : ''}`}
|
||||
>
|
||||
{customer.genFieldCount}/5
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="lx-client-count">{customer.baseFieldCount}/10</span>
|
||||
</td>
|
||||
<td className="lx-client-muted">
|
||||
{customer.updatedAt.slice(0, 19).replace('T', ' ')}
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDetail(customer);
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{customers.error ? null : (
|
||||
<div className="lx-client-tablefoot">
|
||||
<span>客户名称在同一末级行业下唯一;客户不可跨行业移动</span>
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
onClick={() => void navigate(ROUTES.system.customerGroups)}
|
||||
>
|
||||
管理客户组
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
|
||||
import { Button, Input, Modal } from '@douyinfe/semi-ui-19';
|
||||
import { type IndustryDetail, type IndustryTreeItem, Permission } from '@lingxi/shared';
|
||||
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Can } from '@/components/Can';
|
||||
import {
|
||||
useCreateIndustryMutation,
|
||||
useDeleteIndustryMutation,
|
||||
useIndustriesQuery,
|
||||
useIndustryDetailQuery,
|
||||
useSetIndustryStatusMutation,
|
||||
useUpdateIndustryMutation,
|
||||
} from '@/hooks/queries/use-client';
|
||||
import { usePermission } from '@/hooks/use-permission';
|
||||
|
||||
type ModalState =
|
||||
| { kind: 'root' }
|
||||
| { kind: 'child'; parentId: string }
|
||||
| { kind: 'edit'; industry: IndustryDetail }
|
||||
| null;
|
||||
|
||||
function statusTag(status: string): ReactNode {
|
||||
return (
|
||||
<span className={`lx-client-tag lx-client-tag--${status === 'active' ? 'active' : 'disabled'}`}>
|
||||
{status === 'active' ? '启用' : '停用'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function IndustryFormModal({
|
||||
state,
|
||||
parentName,
|
||||
onClose,
|
||||
}: {
|
||||
state: Exclude<ModalState, null>;
|
||||
parentName?: string;
|
||||
onClose: () => void;
|
||||
}): ReactNode {
|
||||
const create = useCreateIndustryMutation();
|
||||
const update = useUpdateIndustryMutation();
|
||||
const isEdit = state.kind === 'edit';
|
||||
const [name, setName] = useState(isEdit ? state.industry.name : '');
|
||||
const [sortOrder, setSortOrder] = useState(isEdit ? String(state.industry.sortOrder) : '10');
|
||||
const [remark, setRemark] = useState(isEdit ? state.industry.remark : '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (name.trim().length < 2) {
|
||||
setError('行业名称至少 2 个字符');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (isEdit) {
|
||||
await update.mutateAsync({
|
||||
id: state.industry.id,
|
||||
input: { name, sortOrder: Number(sortOrder), remark },
|
||||
});
|
||||
notifySuccess('行业已保存');
|
||||
} else {
|
||||
await create.mutateAsync({
|
||||
name,
|
||||
sortOrder: Number(sortOrder),
|
||||
remark,
|
||||
...(state.kind === 'child' ? { parentId: state.parentId } : {}),
|
||||
});
|
||||
notifySuccess('行业已创建');
|
||||
}
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title={isEdit ? '编辑行业' : state.kind === 'child' ? '添加二级行业' : '新建一级行业'}
|
||||
onCancel={onClose}
|
||||
onOk={() => void submit()}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={create.isPending || update.isPending}
|
||||
>
|
||||
<div className="lx-client-formgrid">
|
||||
<div className="lx-client-field is-full">
|
||||
<label>
|
||||
行业名称 <i>*</i>
|
||||
</label>
|
||||
<Input value={name} onChange={setName} placeholder="如:电商、服饰鞋包" />
|
||||
{error !== null ? (
|
||||
<span style={{ color: 'var(--lx-color-danger)', fontSize: 12 }}>{error}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{state.kind === 'child' ? (
|
||||
<div className="lx-client-field is-full">
|
||||
<label>上级行业</label>
|
||||
<Input value={parentName ?? ''} disabled />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="lx-client-field">
|
||||
<label>排序</label>
|
||||
<Input value={sortOrder} onChange={setSortOrder} />
|
||||
</div>
|
||||
<div className="lx-client-field is-full">
|
||||
<label>备注</label>
|
||||
<Input value={remark} onChange={setRemark} placeholder="行业说明(可选)" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function IndustryTree({
|
||||
items,
|
||||
activeId,
|
||||
onSelect,
|
||||
}: {
|
||||
items: readonly IndustryTreeItem[];
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className="lx-client-tree">
|
||||
{items.map((root) => (
|
||||
<div key={root.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`lx-client-tree__row${root.id === activeId ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
onSelect(root.id);
|
||||
}}
|
||||
>
|
||||
<span className="lx-client-tree__name">{root.name}</span>
|
||||
<span className="lx-client-tree__count">{root.children.length}</span>
|
||||
</button>
|
||||
{root.children.map((child) => (
|
||||
<button
|
||||
key={child.id}
|
||||
type="button"
|
||||
className={`lx-client-tree__row is-child${child.id === activeId ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
onSelect(child.id);
|
||||
}}
|
||||
>
|
||||
<span className="lx-client-tree__name">{child.name}</span>
|
||||
<span className="lx-client-tree__count">{child.customerCount}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IndustryDetailPanel({
|
||||
detail,
|
||||
canManage,
|
||||
onAddChild,
|
||||
onEdit,
|
||||
onRefresh,
|
||||
}: {
|
||||
detail: IndustryDetail;
|
||||
canManage: boolean;
|
||||
onAddChild: () => void;
|
||||
onEdit: () => void;
|
||||
onRefresh: () => void;
|
||||
}): ReactNode {
|
||||
const setStatus = useSetIndustryStatusMutation();
|
||||
const remove = useDeleteIndustryMutation();
|
||||
const toggle = (): void => {
|
||||
Modal.confirm({
|
||||
title: detail.status === 'active' ? '停用行业' : '启用行业',
|
||||
content:
|
||||
detail.status === 'active'
|
||||
? '停用后新建客户不可选择该行业;已有客户数据不受影响。'
|
||||
: '启用后新建客户可以选择该行业。',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await setStatus.mutateAsync({
|
||||
id: detail.id,
|
||||
input: { status: detail.status === 'active' ? 'disabled' : 'active' },
|
||||
});
|
||||
notifySuccess(detail.status === 'active' ? '行业已停用' : '行业已启用');
|
||||
onRefresh();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '操作失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const removeIndustry = (): void => {
|
||||
Modal.confirm({
|
||||
title: '删除行业',
|
||||
content: '仅无子行业、无客户、无资产时可删除;否则只能停用。',
|
||||
okButtonProps: { type: 'danger' },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await remove.mutateAsync(detail.id);
|
||||
notifySuccess('行业已删除');
|
||||
onRefresh();
|
||||
} catch (cause) {
|
||||
notifyError(cause instanceof Error ? cause.message : '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="lx-client-right__head">
|
||||
<div>
|
||||
<h2>
|
||||
{detail.name} {statusTag(detail.status)}
|
||||
</h2>
|
||||
<p>
|
||||
{detail.level === 1 ? '一级行业' : '二级行业'}
|
||||
{detail.parentName !== null ? ` · 上级 ${detail.parentName}` : ''} · 排序{' '}
|
||||
{detail.sortOrder}
|
||||
</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
{canManage ? (
|
||||
<>
|
||||
{detail.level === 1 ? <Button onClick={onAddChild}>添加二级</Button> : null}
|
||||
<Button onClick={onEdit}>编辑</Button>
|
||||
<Button onClick={toggle}>{detail.status === 'active' ? '停用' : '启用'}</Button>
|
||||
<Button type="danger" onClick={removeIndustry}>
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-client-right__body">
|
||||
<section className="lx-client-section">
|
||||
<div className="lx-client-section__head">
|
||||
<b>基本信息</b>
|
||||
</div>
|
||||
<div className="lx-client-kv">
|
||||
<div className="lx-client-kv__item">
|
||||
<span>行业名称</span>
|
||||
<b>{detail.name}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>层级</span>
|
||||
<b>{detail.level === 1 ? '一级行业' : '二级行业'}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>上级行业</span>
|
||||
<b>{detail.parentName ?? '—'}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>状态</span>
|
||||
<b>{statusTag(detail.status)}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户数</span>
|
||||
<b>{detail.customerCount}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>子行业数</span>
|
||||
<b>{detail.references.childCount}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>资产数</span>
|
||||
<b>{detail.assetCount}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>更新时间</span>
|
||||
<b>{detail.updatedAt.slice(0, 19).replace('T', ' ')}</b>
|
||||
</div>
|
||||
</div>
|
||||
{detail.remark !== '' ? <div className="lx-client-hint">{detail.remark}</div> : null}
|
||||
</section>
|
||||
<section className="lx-client-section">
|
||||
<div className="lx-client-section__head">
|
||||
<b>引用客户</b>
|
||||
<span style={{ fontSize: 12, color: 'var(--lx-color-text-secondary)' }}>
|
||||
{detail.references.customerCount} 个
|
||||
</span>
|
||||
</div>
|
||||
{detail.references.customers.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无客户</div>
|
||||
) : (
|
||||
<div className="lx-client-chips">
|
||||
{detail.references.customers.map((customer) => (
|
||||
<span key={customer.id} className="lx-client-chip">
|
||||
{customer.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="lx-client-hint">
|
||||
删除前需先处理子行业、客户与资产;有启用中的二级行业时禁止停用。
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 行业管理(选定方案 B:左两级树 + 右详情面板) */
|
||||
export function IndustriesPage(): ReactNode {
|
||||
const canManage = usePermission(Permission.CUSTOMER_MANAGE);
|
||||
const list = useIndustriesQuery({ limit: 100 });
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const activeId = selectedId ?? list.data?.items[0]?.id ?? null;
|
||||
const detail = useIndustryDetailQuery(activeId);
|
||||
const [modal, setModal] = useState<ModalState>(null);
|
||||
const items = useMemo(() => list.data?.items ?? [], [list.data]);
|
||||
|
||||
const refresh = (): void => {
|
||||
void list.refetch();
|
||||
void detail.refetch();
|
||||
};
|
||||
|
||||
if (list.isPending) return <PageSkeleton rows={8} />;
|
||||
if (list.error) {
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<div className="lx-client-empty">
|
||||
<b>行业加载失败</b>
|
||||
<span>{list.error.message}</span>
|
||||
<Button onClick={() => void list.refetch()}>重试</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lx-client-page">
|
||||
<header className="lx-client-header">
|
||||
<div>
|
||||
<h1>行业管理</h1>
|
||||
<p>维护两级行业树;客户挂末级行业,删除前需先处理子行业、客户与资产</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setModal({ kind: 'root' });
|
||||
}}
|
||||
>
|
||||
新建一级行业
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
</header>
|
||||
<div className="lx-client-split">
|
||||
<section className="lx-client-left">
|
||||
<div className="lx-client-left__head">
|
||||
<b>行业树</b>
|
||||
<span>
|
||||
两级 · 共 {items.length + items.reduce((sum, item) => sum + item.children.length, 0)}{' '}
|
||||
个
|
||||
</span>
|
||||
</div>
|
||||
<div className="lx-client-left__body">
|
||||
{items.length === 0 ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>暂无行业</b>
|
||||
<span>先创建一级行业,再添加二级行业</span>
|
||||
</div>
|
||||
) : (
|
||||
<IndustryTree items={items} activeId={activeId} onSelect={setSelectedId} />
|
||||
)}
|
||||
</div>
|
||||
<div className="lx-client-left__foot">
|
||||
一级 {items.length} · 二级 {items.reduce((sum, item) => sum + item.children.length, 0)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="lx-client-right">
|
||||
{detail.isPending || activeId === null ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>选择行业</b>
|
||||
<span>从左侧选择一级或二级行业查看详情</span>
|
||||
</div>
|
||||
) : detail.error ? (
|
||||
<div className="lx-client-empty">
|
||||
<b>行业详情加载失败</b>
|
||||
<span>{detail.error.message}</span>
|
||||
<Button onClick={() => void detail.refetch()}>重试</Button>
|
||||
</div>
|
||||
) : (
|
||||
<IndustryDetailPanel
|
||||
detail={detail.data}
|
||||
canManage={canManage}
|
||||
onAddChild={() => {
|
||||
setModal({ kind: 'child', parentId: detail.data.id });
|
||||
}}
|
||||
onEdit={() => {
|
||||
setModal({ kind: 'edit', industry: detail.data });
|
||||
}}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{modal !== null ? (
|
||||
<IndustryFormModal
|
||||
state={modal}
|
||||
parentName={
|
||||
modal.kind === 'child'
|
||||
? items.find((item) => item.id === modal.parentId)?.name
|
||||
: undefined
|
||||
}
|
||||
onClose={() => {
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 供路由复用的空态 */
|
||||
export function IndustriesEmpty(): ReactNode {
|
||||
return <div className="lx-client-empty">暂无行业</div>;
|
||||
}
|
||||
@@ -44,6 +44,36 @@ const RoleNewPage = lazy(async () => {
|
||||
return { default: module.RoleNewPage };
|
||||
});
|
||||
|
||||
const IndustriesPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/industries/IndustriesPage');
|
||||
return { default: module.IndustriesPage };
|
||||
});
|
||||
|
||||
const CustomersPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customers/CustomersPage');
|
||||
return { default: module.CustomersPage };
|
||||
});
|
||||
|
||||
const CustomerNewPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customers/CustomerNewPage');
|
||||
return { default: module.CustomerNewPage };
|
||||
});
|
||||
|
||||
const CustomerDetailPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customers/CustomerDetailPage');
|
||||
return { default: module.CustomerDetailPage };
|
||||
});
|
||||
|
||||
const CustomerGroupsPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customer-groups/CustomerGroupsPage');
|
||||
return { default: module.CustomerGroupsPage };
|
||||
});
|
||||
|
||||
const CustomerFieldsPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/customer-fields/CustomerFieldsPage');
|
||||
return { default: module.CustomerFieldsPage };
|
||||
});
|
||||
|
||||
function withSkeleton(node: ReactNode): ReactNode {
|
||||
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
|
||||
}
|
||||
@@ -134,6 +164,54 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/industries',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_READ}>
|
||||
{withSkeleton(<IndustriesPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customers',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_READ}>
|
||||
{withSkeleton(<CustomersPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customers/new',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_MANAGE}>
|
||||
{withSkeleton(<CustomerNewPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customers/:id',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_READ}>
|
||||
{withSkeleton(<CustomerDetailPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customer-groups',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_GROUP_MANAGE}>
|
||||
{withSkeleton(<CustomerGroupsPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/customer-fields/approvals',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.CUSTOMER_FIELD_APPROVE}>
|
||||
{withSkeleton(<CustomerFieldsPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':moduleKey/:itemKey?',
|
||||
lazy: async () => ({
|
||||
|
||||
@@ -16,6 +16,12 @@ export const ROUTES = {
|
||||
roles: '/system/roles',
|
||||
roleNew: '/system/roles/new',
|
||||
roleDetail: '/system/roles/:id',
|
||||
industries: '/system/industries',
|
||||
customers: '/system/customers',
|
||||
customerNew: '/system/customers/new',
|
||||
customerDetail: '/system/customers/:id',
|
||||
customerGroups: '/system/customer-groups',
|
||||
customerFields: '/system/customer-fields/approvals',
|
||||
},
|
||||
unauthorized: '/401',
|
||||
forbidden: '/403',
|
||||
@@ -34,6 +40,12 @@ export type RoutePath =
|
||||
| typeof ROUTES.system.roles
|
||||
| typeof ROUTES.system.roleNew
|
||||
| typeof ROUTES.system.roleDetail
|
||||
| typeof ROUTES.system.industries
|
||||
| typeof ROUTES.system.customers
|
||||
| typeof ROUTES.system.customerNew
|
||||
| typeof ROUTES.system.customerDetail
|
||||
| typeof ROUTES.system.customerGroups
|
||||
| typeof ROUTES.system.customerFields
|
||||
| typeof ROUTES.unauthorized
|
||||
| typeof ROUTES.forbidden
|
||||
| typeof ROUTES.notFound
|
||||
@@ -49,6 +61,16 @@ export function roleDetailPath(roleId: string): string {
|
||||
return `${ROUTES.system.roles}/${encodeURIComponent(roleId)}`;
|
||||
}
|
||||
|
||||
/** 客户详情深链:`/system/customers/{id}` */
|
||||
export function customerDetailPath(customerId: string): string {
|
||||
return `${ROUTES.system.customers}/${encodeURIComponent(customerId)}`;
|
||||
}
|
||||
|
||||
/** 新建客户(四步向导) */
|
||||
export function customerNewPath(): string {
|
||||
return ROUTES.system.customerNew;
|
||||
}
|
||||
|
||||
/** 新建角色深链;带来源角色时作为复制模板预填(特殊权限不随复制带入) */
|
||||
export function roleNewPath(sourceRoleId?: string): string {
|
||||
return sourceRoleId === undefined
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
/* A3 客户与行业页面(混搭布局:客户域 A / 行业与客户组 B / 字段审批 A) */
|
||||
.lx-client-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.lx-client-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.lx-client-header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lx-client-header p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--lx-text-2, #8f959e);
|
||||
}
|
||||
.lx-client-header__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
.lx-client-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.lx-client-split {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.lx-client-left {
|
||||
width: 380px;
|
||||
flex: none;
|
||||
background: #fff;
|
||||
border: 1px solid var(--lx-border, #e5e8ef);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lx-client-left__head {
|
||||
height: 48px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
}
|
||||
.lx-client-left__head b {
|
||||
font-size: 13px;
|
||||
}
|
||||
.lx-client-left__head span {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-left__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
.lx-client-left__foot {
|
||||
height: 40px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
border-top: 1px solid #eef1f6;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
border: 1px solid var(--lx-border, #e5e8ef);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lx-client-right__head {
|
||||
flex: none;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.lx-client-right__head h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.lx-client-right__head p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-right__head .lx-client-header__actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.lx-client-right__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 16px 18px 20px;
|
||||
}
|
||||
.lx-client-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.lx-client-tree__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #646a73;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
.lx-client-tree__row:hover {
|
||||
background: #f2f4f7;
|
||||
color: #1f2329;
|
||||
}
|
||||
.lx-client-tree__row.is-active {
|
||||
background: #e8f1ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
.lx-client-tree__row.is-child {
|
||||
padding-left: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
.lx-client-tree__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.lx-client-tree__count {
|
||||
font-size: 11px;
|
||||
color: #8f959e;
|
||||
}
|
||||
.lx-client-tree__row.is-active .lx-client-tree__count {
|
||||
color: #1677ff;
|
||||
}
|
||||
.lx-client-table-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lx-client-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px 24px;
|
||||
color: #646a73;
|
||||
font-size: 12px;
|
||||
}
|
||||
.lx-client-empty b {
|
||||
font-size: 13px;
|
||||
color: #1f2329;
|
||||
}
|
||||
.lx-client-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr) 280px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.lx-client-anchors {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.lx-client-anchors a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-anchors a:hover {
|
||||
background: #f2f4f7;
|
||||
color: #1f2329;
|
||||
}
|
||||
.lx-client-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.lx-client-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.lx-client-sidecard {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.lx-client-sidecard h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lx-client-kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 28px;
|
||||
}
|
||||
.lx-client-kv__item span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-kv__item b {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.lx-client-section {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
}
|
||||
.lx-client-section:last-child {
|
||||
border-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.lx-client-section__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.lx-client-section__head b {
|
||||
font-size: 14px;
|
||||
}
|
||||
.lx-client-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.lx-client-tag--active {
|
||||
background: #e8ffea;
|
||||
color: #008a24;
|
||||
}
|
||||
.lx-client-tag--paused {
|
||||
background: #fff4e8;
|
||||
color: #d25f00;
|
||||
}
|
||||
.lx-client-tag--ended {
|
||||
background: #f2f4f7;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-tag--disabled {
|
||||
background: #f2f4f7;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.lx-client-banner--paused {
|
||||
background: #fff4e8;
|
||||
color: #d25f00;
|
||||
}
|
||||
.lx-client-banner--ended {
|
||||
background: #f2f4f7;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.lx-client-chip {
|
||||
height: 28px;
|
||||
padding: 0 9px;
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
border: 1px solid #eef1f6;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.lx-client-newgrid {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.lx-client-stepnav {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.lx-client-formcard {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lx-client-formcard__body {
|
||||
padding: 20px 22px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
.lx-client-formcard__foot {
|
||||
height: 60px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 20px;
|
||||
border-top: 1px solid #eef1f6;
|
||||
}
|
||||
.lx-client-formcard__foot .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.lx-client-formgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px 20px;
|
||||
}
|
||||
.lx-client-formgrid .is-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.lx-client-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.lx-client-field > label {
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-field > label i {
|
||||
color: #f53f3f;
|
||||
font-style: normal;
|
||||
}
|
||||
.lx-client-memberlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.lx-client-memberrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #eef1f6;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.lx-client-memberrow b {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.lx-client-memberrow span {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-assetempty {
|
||||
border: 1px dashed #d0d5dd;
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
padding: 20px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-assetempty b {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #1f2329;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
.lx-client-grid {
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
}
|
||||
.lx-client-grid .lx-client-side {
|
||||
display: none;
|
||||
}
|
||||
.lx-client-left {
|
||||
width: 330px;
|
||||
}
|
||||
}
|
||||
.lx-client-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.lx-client-table th {
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
text-align: left;
|
||||
background: #fafbfc;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
color: #646a73;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lx-client-table td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
color: #646a73;
|
||||
vertical-align: middle;
|
||||
line-height: 20px;
|
||||
}
|
||||
.lx-client-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.lx-client-table tbody tr:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
.lx-client-table td b {
|
||||
font-weight: 500;
|
||||
color: #1f2329;
|
||||
}
|
||||
.lx-client-sub {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #646a73;
|
||||
}
|
||||
.lx-client-muted {
|
||||
color: #646a73;
|
||||
font-size: 12px;
|
||||
}
|
||||
.lx-client-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 4px;
|
||||
background: #f2f4f7;
|
||||
color: #646a73;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lx-client-count.is-full {
|
||||
background: #fff4e8;
|
||||
color: #d25f00;
|
||||
}
|
||||
.lx-client-tablefoot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
border-top: 1px solid #eef1f6;
|
||||
}
|
||||
.lx-client-tablefoot .semi-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
.lx-client-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #646a73;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.lx-client-back:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
Reference in new issue
Block a user