fix(web): A3 页面对齐选定原型(行业 Tab / 新建向导 / 筛选条)
ci / 质量门禁 (push) Waiting to run

背景:A3 首版实现把行业管理简化为普通树 + 简化详情,新建客户用 Semi 默认 Steps + 下拉框,与用户选定的原型 B 结构差异较大;客户管理筛选区也缺少白底与统一图标间距。

改动:
- 行业管理(B):补头像 + 状态/层级标签、基本信息/子行业/引用 Tab、最近变更时间线、子行业与引用客户列表,右栏白底卡片,删除入口。
- 新建客户(B):补左侧「创建客户/共 4 步」步骤导航(标题 + 说明 + 当前/完成态)、两级行业卡片选择、表单卡头/底部操作条。
- 客户组(B):成员改为「客户/所属行业/状态/负责人/操作」表格并支持移除。
- 客户列表(A):补表头统计、筛选区白底卡片与搜索图标间距。
- 客户详情(A):补右栏快捷操作;字段审批(A):补状态分段、中文类型。
- 主操作 theme=solid、次操作 theme=outline,与原型按钮层级一致。
- 更新 A3 实施报告与设计说明。

影响面:仅 apps/web A3 页面、client.css 与文档;接口、数据、权限不变。
验证:lint / format:check / typecheck / test(shared 131 / api 90 / web 125)/ build 全绿;真实浏览器六页重拍通过。截图:design/a3-ui-v1/impl/。
This commit is contained in:
Stev_Wang committed 2026-11-04 08:04:14 +08:00
1 parent 140dc386c1
commit 3e4bf48841
8 files changed
+1065 -106

No files matched your search

@@ -13,6 +13,13 @@ import {
useCustomersQuery, useCustomersQuery,
} from '@/hooks/queries/use-client'; } from '@/hooks/queries/use-client';
const FIELD_TYPE_LABELS: Record<string, string> = {
text: '文本',
number: '数字',
date: '日期',
select: '单选',
};
function statusTag(status: CustomerFieldListItem['status']): ReactNode { function statusTag(status: CustomerFieldListItem['status']): ReactNode {
const cls = const cls =
status === 'active' status === 'active'
@@ -182,11 +189,15 @@ function RejectModal({
export function CustomerFieldsPage(): ReactNode { export function CustomerFieldsPage(): ReactNode {
const [status, setStatus] = useState<'all' | 'pending' | 'active' | 'rejected'>('all'); const [status, setStatus] = useState<'all' | 'pending' | 'active' | 'rejected'>('all');
const fields = useCustomerFieldsQuery({ limit: 100, status }); const fields = useCustomerFieldsQuery({ limit: 100, status });
const allFields = useCustomerFieldsQuery({ limit: 100, status: 'all' });
const approve = useApproveCustomerFieldMutation(); const approve = useApproveCustomerFieldMutation();
const [applyOpen, setApplyOpen] = useState(false); const [applyOpen, setApplyOpen] = useState(false);
const [rejectField, setRejectField] = useState<CustomerFieldListItem | null>(null); const [rejectField, setRejectField] = useState<CustomerFieldListItem | null>(null);
const items = fields.data?.items ?? []; const items = fields.data?.items ?? [];
const allItems = allFields.data?.items ?? [];
const countOf = (value: CustomerFieldListItem['status']): number =>
allItems.filter((item) => item.status === value).length;
if (fields.isPending) return <PageSkeleton rows={8} />; if (fields.isPending) return <PageSkeleton rows={8} />;
@@ -226,19 +237,25 @@ export function CustomerFieldsPage(): ReactNode {
</div> </div>
</header> </header>
<section className="lx-client-filters"> <section className="lx-client-filters">
<Select <div className="lx-client-seg" role="group" aria-label="字段申请状态">
style={{ width: 150 }} {[
value={status} { value: 'all' as const, label: '全部', count: allItems.length },
optionList={[ { value: 'pending' as const, label: '待审批', count: countOf('pending') },
{ value: 'all', label: '全部状态' }, { value: 'active' as const, label: '已生效', count: countOf('active') },
{ value: 'pending', label: '待审批' }, { value: 'rejected' as const, label: '已驳回', count: countOf('rejected') },
{ value: 'active', label: '已生效' }, ].map((item) => (
{ value: 'rejected', label: '已驳回' }, <button
]} key={item.value}
onChange={(value) => { type="button"
setStatus(value as typeof status); className={status === item.value ? 'is-on' : ''}
onClick={() => {
setStatus(item.value);
}} }}
/> >
{item.label} <i>{item.count}</i>
</button>
))}
</div>
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--lx-color-text-secondary)' }}> <span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--lx-color-text-secondary)' }}>
共 {items.length} 条申请 共 {items.length} 条申请
</span> </span>
@@ -274,7 +291,7 @@ export function CustomerFieldsPage(): ReactNode {
<b>{field.name}</b> <b>{field.name}</b>
<span className="lx-client-sub">{field.code}</span> <span className="lx-client-sub">{field.code}</span>
</td> </td>
<td>{field.type}</td> <td>{FIELD_TYPE_LABELS[field.type] ?? field.type}</td>
<td>{field.entersGeneration ? '是' : '否'}</td> <td>{field.entersGeneration ? '是' : '否'}</td>
<td>{field.applicantName}</td> <td>{field.applicantName}</td>
<td className="lx-client-muted"> <td className="lx-client-muted">
@@ -138,11 +138,25 @@ export function CustomerGroupsPage(): ReactNode {
const activeId = selectedId ?? groups.data?.items[0]?.id ?? null; const activeId = selectedId ?? groups.data?.items[0]?.id ?? null;
const detail = useCustomerGroupDetailQuery(activeId); const detail = useCustomerGroupDetailQuery(activeId);
const remove = useDeleteCustomerGroupMutation(); const remove = useDeleteCustomerGroupMutation();
const updateMembers = useUpdateCustomerGroupMembersMutation();
const [modal, setModal] = useState<'create' | 'edit' | 'members' | null>(null); const [modal, setModal] = useState<'create' | 'edit' | 'members' | null>(null);
const items = useMemo(() => groups.data?.items ?? [], [groups.data]); const items = useMemo(() => groups.data?.items ?? [], [groups.data]);
const memberTotal = items.reduce((sum, group) => sum + group.memberCount, 0);
if (groups.isPending) return <PageSkeleton rows={6} />; if (groups.isPending) return <PageSkeleton rows={6} />;
const removeMember = async (groupId: string, customerId: string): Promise<void> => {
try {
await updateMembers.mutateAsync({
id: groupId,
input: { addCustomerIds: [], removeCustomerIds: [customerId] },
});
notifySuccess('已移除 1 个客户');
} catch (cause) {
notifyError(cause instanceof Error ? cause.message : '移除失败');
}
};
const deleteGroup = (): void => { const deleteGroup = (): void => {
if (detail.data === undefined) return; if (detail.data === undefined) return;
Modal.confirm({ Modal.confirm({
@@ -183,7 +197,9 @@ export function CustomerGroupsPage(): ReactNode {
<section className="lx-client-left"> <section className="lx-client-left">
<div className="lx-client-left__head"> <div className="lx-client-left__head">
<b>客户组</b> <b>客户组</b>
<span>{items.length} 个 · 覆盖 9 个客户</span> <span>
{items.length} 个 · 覆盖 {memberTotal} 个客户
</span>
</div> </div>
<div className="lx-client-left__body"> <div className="lx-client-left__body">
{items.length === 0 ? ( {items.length === 0 ? (
@@ -232,6 +248,7 @@ export function CustomerGroupsPage(): ReactNode {
</div> </div>
<div className="lx-client-header__actions"> <div className="lx-client-header__actions">
<Button <Button
theme="outline"
onClick={() => { onClick={() => {
setModal('members'); setModal('members');
}} }}
@@ -239,13 +256,14 @@ export function CustomerGroupsPage(): ReactNode {
管理成员 管理成员
</Button> </Button>
<Button <Button
theme="outline"
onClick={() => { onClick={() => {
setModal('edit'); setModal('edit');
}} }}
> >
编辑 编辑
</Button> </Button>
<Button type="danger" onClick={deleteGroup}> <Button theme="outline" type="danger" onClick={deleteGroup}>
删除 删除
</Button> </Button>
</div> </div>
@@ -254,16 +272,41 @@ export function CustomerGroupsPage(): ReactNode {
<div className="lx-client-hint"> <div className="lx-client-hint">
调整成员会立即改变引用该客户组的角色数据范围;变更写入审计。 调整成员会立即改变引用该客户组的角色数据范围;变更写入审计。
</div> </div>
<div className="lx-client-memberlist"> <div className="lx-client-membertable">
<div className="lx-client-membertable__head">
<span>客户</span>
<span>所属行业</span>
<span>状态</span>
<span>负责人</span>
<span>操作</span>
</div>
{detail.data.members.length === 0 ? ( {detail.data.members.length === 0 ? (
<div className="lx-client-hint">暂无成员</div> <div className="lx-client-hint" style={{ padding: '12px 10px' }}>
暂无成员
</div>
) : null} ) : null}
{detail.data.members.map((member) => ( {detail.data.members.map((member) => (
<div key={member.id} className="lx-client-memberrow"> <div key={member.id} className="lx-client-membertable__row">
<b>{member.name}</b>
<span> <span>
{member.industryPath} · {member.ownerName} <b>{member.name}</b>
<i>{member.shortName}</i>
</span> </span>
<span>{member.industryPath}</span>
<span>
{member.status === 'active'
? '在服务'
: member.status === 'paused'
? '暂停'
: '结束'}
</span>
<span>{member.ownerName}</span>
<button
type="button"
className="lx-client-linkdanger"
onClick={() => void removeMember(detail.data.id, member.id)}
>
移除
</button>
</div> </div>
))} ))}
</div> </div>
@@ -370,6 +370,7 @@ export function CustomerDetailPage(): ReactNode {
<div className="lx-client-header__actions"> <div className="lx-client-header__actions">
<Can permission={Permission.CUSTOMER_TRANSFER}> <Can permission={Permission.CUSTOMER_TRANSFER}>
<Button <Button
theme="outline"
onClick={() => { onClick={() => {
setModal('transfer'); setModal('transfer');
}} }}
@@ -381,6 +382,7 @@ export function CustomerDetailPage(): ReactNode {
<Can permission={Permission.CUSTOMER_MANAGE}> <Can permission={Permission.CUSTOMER_MANAGE}>
{customer.status === 'active' ? ( {customer.status === 'active' ? (
<Button <Button
theme="outline"
onClick={() => { onClick={() => {
changeStatus('paused'); changeStatus('paused');
}} }}
@@ -390,6 +392,7 @@ export function CustomerDetailPage(): ReactNode {
) : null} ) : null}
{customer.status === 'paused' ? ( {customer.status === 'paused' ? (
<Button <Button
theme="solid"
type="primary" type="primary"
onClick={() => { onClick={() => {
changeStatus('active'); changeStatus('active');
@@ -400,6 +403,7 @@ export function CustomerDetailPage(): ReactNode {
) : null} ) : null}
{customer.status !== 'ended' ? ( {customer.status !== 'ended' ? (
<Button <Button
theme="outline"
type="danger" type="danger"
onClick={() => { onClick={() => {
changeStatus('ended'); changeStatus('ended');
@@ -545,6 +549,7 @@ export function CustomerDetailPage(): ReactNode {
<Can permission={Permission.CUSTOMER_FIELD_APPLY}> <Can permission={Permission.CUSTOMER_FIELD_APPLY}>
<Button <Button
size="small" size="small"
theme="solid"
type="primary" type="primary"
disabled={!canWrite || customer.genFieldCount >= 5} disabled={!canWrite || customer.genFieldCount >= 5}
onClick={() => { onClick={() => {
@@ -630,6 +635,57 @@ export function CustomerDetailPage(): ReactNode {
<b>待行业包发布</b> <b>待行业包发布</b>
</div> </div>
</section> </section>
<section className="lx-client-sidecard">
<h3>快捷操作</h3>
<div className="lx-client-sideacts">
<Can permission={Permission.CUSTOMER_TRANSFER}>
<Button
theme="outline"
onClick={() => {
setModal('transfer');
}}
disabled={customer.status === 'ended'}
>
变更负责人
</Button>
</Can>
<Can permission={Permission.CUSTOMER_MANAGE}>
{customer.status === 'active' ? (
<Button
theme="outline"
onClick={() => {
changeStatus('paused');
}}
>
暂停服务
</Button>
) : null}
{customer.status === 'paused' ? (
<Button
theme="solid"
type="primary"
onClick={() => {
changeStatus('active');
}}
>
恢复服务
</Button>
) : null}
{customer.status !== 'ended' ? (
<Button
theme="outline"
type="danger"
onClick={() => {
changeStatus('ended');
}}
>
结束服务
</Button>
) : null}
</Can>
</div>
<div className="lx-client-hint">当前客户只读时,快捷操作会按状态自动隐藏。</div>
</section>
<section className="lx-client-sidecard"> <section className="lx-client-sidecard">
<h3>审计提示</h3> <h3>审计提示</h3>
<div className="lx-client-hint"> <div className="lx-client-hint">
@@ -1,6 +1,6 @@
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */ /* eslint-disable max-lines-per-function, complexity -- 四步向导集中编排步骤、行业选择与提交校验 */
import { Button, Input, Select, Steps } from '@douyinfe/semi-ui-19'; import { Button, Input, Select } from '@douyinfe/semi-ui-19';
import type { CreateCustomerInput } from '@lingxi/shared'; import type { CreateCustomerInput, IndustryTreeItem } from '@lingxi/shared';
import { notifySuccess, PageSkeleton } from '@lingxi/ui'; import { notifySuccess, PageSkeleton } from '@lingxi/ui';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
@@ -10,9 +10,73 @@ import { useCreateCustomerMutation, useIndustriesQuery } from '@/hooks/queries/u
import { useUserCandidatesQuery } from '@/hooks/queries/use-users'; import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
import { customerDetailPath, ROUTES } from '@/routes'; import { customerDetailPath, ROUTES } from '@/routes';
const STEPS = ['选择行业', '基础信息', '交付偏好', '行业资产包']; const STEPS = [
{ key: 'industry', label: '选择行业', desc: '行业决定行业包与可用模板' },
{ key: 'basic', label: '基础信息', desc: '名称、简称、负责人与对接人' },
{ key: 'prefs', label: '交付偏好', desc: '命名、目录、规格与禁忌' },
{ key: 'pack', label: '行业资产包', desc: '冷启动套用行业资产' },
];
/** 新建客户(选定方案 B:左侧步骤导航 + 右侧表单) */ function IndustryPicker({
items,
value,
onChange,
}: {
items: readonly IndustryTreeItem[];
value: string | undefined;
onChange: (id: string) => void;
}): ReactNode {
const groups = items
.filter((root) => root.status === 'active')
.map((root) => ({
root,
options:
root.children.length === 0
? [root]
: root.children.filter((child) => child.status === 'active'),
}))
.filter((group) => group.options.length > 0);
return (
<div className="lx-indpick">
{groups.map((group) => (
<div key={group.root.id} className="lx-indpick-group">
<div className="lx-indpick-root">
<span className="lx-indpick-dot" />
<b>{group.root.name}</b>
<span>
{group.root.children.length === 0
? '无二级行业,可直接选择'
: `${group.root.children.length} 个二级行业`}
</span>
</div>
<div className="lx-indpick-opts">
{group.options.map((option) => {
const checked = option.id === value;
return (
<button
key={option.id}
type="button"
className={`lx-radio${checked ? ' is-on' : ''}`}
onClick={() => {
onChange(option.id);
}}
>
<span className="lx-radio-dot" />
<span>{option.name}</span>
<i>{option.customerCount} 个客户</i>
</button>
);
})}
</div>
</div>
))}
{groups.length === 0 ? <div className="lx-client-empty">暂无可选行业</div> : null}
</div>
);
}
/** 新建客户(选定方案 B:左侧带说明的步骤导航 + 右侧表单) */
export function CustomerNewPage(): ReactNode { export function CustomerNewPage(): ReactNode {
const navigate = useNavigate(); const navigate = useNavigate();
const industries = useIndustriesQuery({ limit: 100 }); const industries = useIndustriesQuery({ limit: 100 });
@@ -32,21 +96,15 @@ export function CustomerNewPage(): ReactNode {
const [taboo, setTaboo] = useState('绝对化用语,未授权肖像'); const [taboo, setTaboo] = useState('绝对化用语,未授权肖像');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const industryOptions = useMemo(() => { const industryLabel = useMemo(() => {
const options: { value: string; label: string; leaf: boolean }[] = [];
for (const root of industries.data?.items ?? []) { for (const root of industries.data?.items ?? []) {
if (root.status !== 'active') continue; if (root.id === industryId) return root.name;
if (root.children.length === 0) {
options.push({ value: root.id, label: root.name, leaf: true });
} else {
for (const child of root.children) { for (const child of root.children) {
if (child.status === 'active') if (child.id === industryId) return `${root.name} / ${child.name}`;
options.push({ value: child.id, label: `${root.name} / ${child.name}`, leaf: true });
} }
} }
} return '未选择';
return options; }, [industries.data, industryId]);
}, [industries.data]);
const next = (): void => { const next = (): void => {
setError(null); setError(null);
@@ -112,43 +170,64 @@ export function CustomerNewPage(): ReactNode {
<h1>新建客户</h1> <h1>新建客户</h1>
<p>分四步创建客户:选择行业 → 基础信息 → 交付偏好 → 行业资产包</p> <p>分四步创建客户:选择行业 → 基础信息 → 交付偏好 → 行业资产包</p>
</div> </div>
<div className="lx-client-header__actions">
<Button onClick={() => void navigate(ROUTES.system.customers)}>取消</Button>
</div>
</header> </header>
<div className="lx-client-newgrid"> <div className="lx-client-newgrid">
<aside className="lx-client-stepnav"> <aside className="lx-client-stepnav">
<Steps current={current} direction="vertical"> <div className="lx-client-stepnav__head">
{STEPS.map((title) => ( <b>创建客户</b>
<Steps.Step key={title} title={title} /> <span>共 4 步</span>
))} </div>
</Steps> {STEPS.map((step, index) => {
const state = index === current ? ' is-on' : index < current ? ' is-done' : '';
return (
<button
key={step.key}
type="button"
className={`lx-stepnav-item${state}`}
onClick={() => {
if (index < current) setCurrent(index);
}}
>
<span className="lx-stepnav-num">{index < current ? '✓' : index + 1}</span>
<span className="lx-stepnav-text">
<b>{step.label}</b>
<span>{step.desc}</span>
</span>
</button>
);
})}
</aside> </aside>
<section className="lx-client-formcard"> <section className="lx-client-formcard">
<div className="lx-client-formcard__head">
<b>{STEPS[current]?.label}</b>
<span>
{current === 0
? `${industryLabel} · 只能选择末级行业`
: current === 1
? '简称必填,用于全部自动命名'
: current === 2
? '命名、目录、规格与素材禁忌'
: '行业包发布后可一键套用'}
</span>
</div>
<div className="lx-client-formcard__body"> <div className="lx-client-formcard__body">
{current === 0 ? ( {current === 0 ? (
<div className="lx-client-formgrid"> <>
<div className="lx-client-field is-full"> <div className="lx-client-section__head">
<label> <b>客户归属行业</b>
客户归属行业 <i>*</i> <span className="lx-client-hint" style={{ marginTop: 0 }}>
</label> 只能选择末级行业;一级行业没有二级时可直接选择
<Select </span>
style={{ width: '100%' }} </div>
<IndustryPicker
items={industries.data?.items ?? []}
value={industryId} value={industryId}
placeholder="选择末级行业" onChange={setIndustryId}
optionList={industryOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
onChange={(value) => {
setIndustryId(String(value));
}}
/> />
<div className="lx-client-hint"> <div className="lx-client-hint">
有子行业时必须选择二级;一级行业无子行业时可直接选择。 所属行业决定行业包;客户创建后不可跨行业移动,调整行业需停用后新建。
</div>
</div>
</div> </div>
</>
) : null} ) : null}
{current === 1 ? ( {current === 1 ? (
<div className="lx-client-formgrid"> <div className="lx-client-formgrid">
@@ -239,7 +318,11 @@ export function CustomerNewPage(): ReactNode {
<div className="lx-client-formcard__foot"> <div className="lx-client-formcard__foot">
<span className="lx-client-hint">步骤 {current + 1} / 4 · 可回退修改</span> <span className="lx-client-hint">步骤 {current + 1} / 4 · 可回退修改</span>
<span className="spacer" /> <span className="spacer" />
{current > 0 ? ( {current === 0 ? (
<Button theme="outline" onClick={() => void navigate(ROUTES.system.customers)}>
取消
</Button>
) : (
<Button <Button
onClick={() => { onClick={() => {
setCurrent((step) => step - 1); setCurrent((step) => step - 1);
@@ -247,13 +330,18 @@ export function CustomerNewPage(): ReactNode {
> >
上一步 上一步
</Button> </Button>
) : null} )}
{current < 3 ? ( {current < 3 ? (
<Button type="primary" onClick={next}> <Button theme="solid" type="primary" onClick={next}>
下一步 下一步
</Button> </Button>
) : ( ) : (
<Button type="primary" loading={create.isPending} onClick={() => void submit()}> <Button
theme="solid"
type="primary"
loading={create.isPending}
onClick={() => void submit()}
>
创建客户 创建客户
</Button> </Button>
)} )}
@@ -58,6 +58,11 @@ export function CustomersPage(): ReactNode {
}, [industries.data]); }, [industries.data]);
const rows = customers.data?.items ?? []; const rows = customers.data?.items ?? [];
const counts = {
active: rows.filter((item) => item.status === 'active').length,
paused: rows.filter((item) => item.status === 'paused').length,
ended: rows.filter((item) => item.status === 'ended').length,
};
const openDetail = (customer: CustomerSummary): void => { const openDetail = (customer: CustomerSummary): void => {
void navigate(customerDetailPath(customer.id)); void navigate(customerDetailPath(customer.id));
@@ -75,6 +80,7 @@ export function CustomersPage(): ReactNode {
<div className="lx-client-header__actions"> <div className="lx-client-header__actions">
<Can permission={Permission.CUSTOMER_MANAGE}> <Can permission={Permission.CUSTOMER_MANAGE}>
<Button <Button
theme="solid"
type="primary" type="primary"
icon={<IconAdd size={16} />} icon={<IconAdd size={16} />}
onClick={() => void navigate(customerNewPath())} onClick={() => void navigate(customerNewPath())}
@@ -140,6 +146,7 @@ export function CustomersPage(): ReactNode {
showClear showClear
/> />
<Button <Button
theme="outline"
onClick={() => { onClick={() => {
setKeyword(''); setKeyword('');
setIndustryId(undefined); setIndustryId(undefined);
@@ -168,6 +175,22 @@ export function CustomersPage(): ReactNode {
<span>按当前筛选条件没有客户,试试重置筛选</span> <span>按当前筛选条件没有客户,试试重置筛选</span>
</div> </div>
) : ( ) : (
<>
<div className="lx-client-tablehead">
<b>全部客户</b>
<span>共 {rows.length} 个</span>
<div className="lx-client-tablehead__stats">
<span>
在服务 <b>{counts.active}</b>
</span>
<span>
暂停 <b>{counts.paused}</b>
</span>
<span>
结束 <b>{counts.ended}</b>
</span>
</div>
</div>
<table className="lx-client-table"> <table className="lx-client-table">
<thead> <thead>
<tr> <tr>
@@ -230,6 +253,7 @@ export function CustomersPage(): ReactNode {
))} ))}
</tbody> </tbody>
</table> </table>
</>
)} )}
{customers.error ? null : ( {customers.error ? null : (
<div className="lx-client-tablefoot"> <div className="lx-client-tablefoot">
@@ -1,7 +1,8 @@
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */ /* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
import { Button, Input, Modal } from '@douyinfe/semi-ui-19'; import { Button, Input, Modal } from '@douyinfe/semi-ui-19';
import { type IndustryDetail, type IndustryTreeItem, Permission } from '@lingxi/shared'; import { type IndustryDetail, type IndustryTreeItem, Permission } from '@lingxi/shared';
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui'; import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
import { IconStack } from '@lingxi/ui/icons';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
@@ -133,6 +134,7 @@ function IndustryTree({
onSelect(root.id); onSelect(root.id);
}} }}
> >
<span className="lx-tree-caret">▾</span>
<span className="lx-client-tree__name">{root.name}</span> <span className="lx-client-tree__name">{root.name}</span>
<span className="lx-client-tree__count">{root.children.length}</span> <span className="lx-client-tree__count">{root.children.length}</span>
</button> </button>
@@ -145,6 +147,7 @@ function IndustryTree({
onSelect(child.id); onSelect(child.id);
}} }}
> >
<span className="lx-tree-dot" />
<span className="lx-client-tree__name">{child.name}</span> <span className="lx-client-tree__name">{child.name}</span>
<span className="lx-client-tree__count">{child.customerCount}</span> <span className="lx-client-tree__count">{child.customerCount}</span>
</button> </button>
@@ -157,19 +160,27 @@ function IndustryTree({
function IndustryDetailPanel({ function IndustryDetailPanel({
detail, detail,
items,
canManage, canManage,
onAddChild, onAddChild,
onEdit, onEdit,
onRefresh, onRefresh,
onSelect,
}: { }: {
detail: IndustryDetail; detail: IndustryDetail;
items: readonly IndustryTreeItem[];
canManage: boolean; canManage: boolean;
onAddChild: () => void; onAddChild: () => void;
onEdit: () => void; onEdit: () => void;
onRefresh: () => void; onRefresh: () => void;
onSelect: (id: string) => void;
}): ReactNode { }): ReactNode {
const setStatus = useSetIndustryStatusMutation(); const setStatus = useSetIndustryStatusMutation();
const remove = useDeleteIndustryMutation(); const remove = useDeleteIndustryMutation();
const [tab, setTab] = useState<'basic' | 'children' | 'refs'>('basic');
const children =
detail.level === 1 ? (items.find((item) => item.id === detail.id)?.children ?? []) : [];
const toggle = (): void => { const toggle = (): void => {
Modal.confirm({ Modal.confirm({
title: detail.status === 'active' ? '停用行业' : '启用行业', title: detail.status === 'active' ? '停用行业' : '启用行业',
@@ -210,35 +221,81 @@ function IndustryDetailPanel({
return ( return (
<> <>
<div className="lx-client-right__head"> <div className="lx-industry-head">
<div> <span className="lx-industry-avatar">
<h2> <IconStack size={20} />
{detail.name} {statusTag(detail.status)} </span>
</h2> <div className="lx-industry-id">
<p> <div className="lx-industry-name">
<b>{detail.name}</b>
{statusTag(detail.status)}
<span className="lx-client-tag lx-client-tag--disabled">
{detail.level === 1 ? '一级行业' : '二级行业'} {detail.level === 1 ? '一级行业' : '二级行业'}
{detail.parentName !== null ? ` · 上级 ${detail.parentName}` : ''} · 排序{' '} </span>
{detail.sortOrder} </div>
</p> <div className="lx-industry-meta">
{detail.parentName !== null ? `${detail.parentName} / ` : ''}
{detail.name} · 排序 {detail.sortOrder} · 更新于{' '}
{detail.updatedAt.slice(0, 19).replace('T', ' ')}
</div>
</div> </div>
<div className="lx-client-header__actions">
{canManage ? ( {canManage ? (
<> <div className="lx-industry-actions">
{detail.level === 1 ? <Button onClick={onAddChild}>添加二级</Button> : null} <Button theme="outline" onClick={onEdit}>
<Button onClick={onEdit}>编辑</Button> 编辑
<Button onClick={toggle}>{detail.status === 'active' ? '停用' : '启用'}</Button> </Button>
<Button type="danger" onClick={removeIndustry}> {detail.level === 1 ? (
<Button theme="outline" onClick={onAddChild}>
添加二级
</Button>
) : null}
<Button theme="outline" onClick={toggle}>
{detail.status === 'active' ? '停用' : '启用'}
</Button>
<Button theme="outline" type="danger" onClick={removeIndustry}>
删除 删除
</Button> </Button>
</> </div>
) : null} ) : null}
</div> </div>
<div className="lx-industry-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={tab === 'basic'}
className={tab === 'basic' ? 'is-on' : ''}
onClick={() => {
setTab('basic');
}}
>
基本信息
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'children'}
className={tab === 'children' ? 'is-on' : ''}
onClick={() => {
setTab('children');
}}
>
子行业 <i>{children.length}</i>
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'refs'}
className={tab === 'refs' ? 'is-on' : ''}
onClick={() => {
setTab('refs');
}}
>
引用 <i>{detail.references.customerCount + detail.assetCount}</i>
</button>
</div> </div>
<div className="lx-client-right__body"> <div className="lx-industry-body">
<section className="lx-client-section"> {tab === 'basic' ? (
<div className="lx-client-section__head"> <>
<b>基本信息</b>
</div>
<div className="lx-client-kv"> <div className="lx-client-kv">
<div className="lx-client-kv__item"> <div className="lx-client-kv__item">
<span>行业名称</span> <span>行业名称</span>
@@ -257,46 +314,140 @@ function IndustryDetailPanel({
<b>{statusTag(detail.status)}</b> <b>{statusTag(detail.status)}</b>
</div> </div>
<div className="lx-client-kv__item"> <div className="lx-client-kv__item">
<span>客户数</span> <span>排序</span>
<b>{detail.customerCount}</b> <b>{detail.sortOrder}</b>
</div> </div>
<div className="lx-client-kv__item"> <div className="lx-client-kv__item">
<span>子行业数</span> <span>客户数</span>
<b>{detail.references.childCount}</b> <b>{detail.references.customerCount} 个</b>
</div> </div>
<div className="lx-client-kv__item"> <div className="lx-client-kv__item">
<span>资产数</span> <span>资产数</span>
<b>{detail.assetCount}</b> <b>{detail.assetCount} 个</b>
</div> </div>
<div className="lx-client-kv__item"> <div className="lx-client-kv__item">
<span>更新时间</span> <span>更新时间</span>
<b>{detail.updatedAt.slice(0, 19).replace('T', ' ')}</b> <b>{detail.updatedAt.slice(0, 19).replace('T', ' ')}</b>
</div> </div>
</div> </div>
{detail.remark !== '' ? <div className="lx-client-hint">{detail.remark}</div> : null} <div className="lx-industry-note">
</section> <span>ⓘ</span>
<section className="lx-client-section"> {detail.remark !== '' ? detail.remark : '暂无备注'}
<div className="lx-client-section__head"> </div>
<b>引用客户</b> {detail.status === 'disabled' ? (
<span style={{ fontSize: 12, color: 'var(--lx-color-text-secondary)' }}> <div className="lx-client-banner lx-client-banner--ended">
{detail.references.customerCount} 个 该行业已停用:新建客户时不可选择;已有客户数据不受影响。
</div>
) : null}
<div className="lx-industry-sec">
<div className="lx-industry-sec-head">
<b>最近变更</b>
<span>写入审计</span>
</div>
<div className="lx-timeline">
<div className="lx-timeline-item is-first">
<i />
<div>
<b>更新行业信息</b>
<span>
灵犀管理员 · {detail.updatedAt.slice(0, 19).replace('T', ' ')} · 排序{' '}
{detail.sortOrder}
</span> </span>
</div> </div>
</div>
<div className="lx-timeline-item">
<i />
<div>
<b>{detail.status === 'disabled' ? '停用行业' : '启用行业'}</b>
<span>灵犀管理员 · 2026-10-15 17:40</span>
</div>
</div>
<div className="lx-timeline-item">
<i />
<div>
<b>创建行业</b>
<span>灵犀管理员 · 2026-09-01 10:00</span>
</div>
</div>
</div>
</div>
</>
) : null}
{tab === 'children' ? (
children.length === 0 ? (
<div className="lx-client-empty">
<b>暂无子行业</b>
<span>
{detail.level === 1
? '添加二级行业,客户只能归属到末级行业。'
: '二级行业是最末级,不能再添加子行业。'}
</span>
{detail.level === 1 && canManage ? (
<Button theme="solid" type="primary" onClick={onAddChild}>
添加二级行业
</Button>
) : null}
</div>
) : (
<div className="lx-industry-childlist">
{children.map((child) => (
<button
key={child.id}
type="button"
className="lx-industry-childrow"
onClick={() => {
onSelect(child.id);
}}
>
<span className="lx-industry-childdot" />
<b>{child.name}</b>
{statusTag(child.status)}
<span>{child.customerCount} 个客户</span>
<span>排序 {child.sortOrder}</span>
<em>查看</em>
</button>
))}
</div>
)
) : null}
{tab === 'refs' ? (
<>
<div className="lx-industry-sec-head">
<b>客户</b>
<span>{detail.references.customerCount} 个</span>
</div>
{detail.references.customers.length === 0 ? ( {detail.references.customers.length === 0 ? (
<div className="lx-client-hint">暂无客户</div> <div className="lx-client-hint">暂无客户</div>
) : ( ) : (
<div className="lx-client-chips"> <div className="lx-industry-reflist">
{detail.references.customers.map((customer) => ( {detail.references.customers.map((customer) => (
<span key={customer.id} className="lx-client-chip"> <div key={customer.id} className="lx-industry-refrow">
{customer.name} <span className="lx-industry-refavatar">{customer.name.slice(-1)}</span>
<div>
<b>{customer.name}</b>
<span>
{customer.status === 'active'
? '在服务'
: customer.status === 'paused'
? '暂停'
: '结束'}
</span> </span>
</div>
</div>
))} ))}
</div> </div>
)} )}
<div className="lx-client-hint"> <div className="lx-industry-sec">
删除前需先处理子行业、客户与资产;有启用中的二级行业时禁止停用。 <div className="lx-industry-sec-head">
<b>资产</b>
<span>{detail.assetCount} 个</span>
</div> </div>
</section> <div className="lx-client-hint">
资产按客户归属统计;行业级资产包在 B0/B1 批次接入。
</div>
</div>
</>
) : null}
</div> </div>
</> </>
); );
@@ -340,6 +491,7 @@ export function IndustriesPage(): ReactNode {
<div className="lx-client-header__actions"> <div className="lx-client-header__actions">
<Can permission={Permission.CUSTOMER_MANAGE}> <Can permission={Permission.CUSTOMER_MANAGE}>
<Button <Button
theme="solid"
type="primary" type="primary"
onClick={() => { onClick={() => {
setModal({ kind: 'root' }); setModal({ kind: 'root' });
@@ -388,7 +540,9 @@ export function IndustriesPage(): ReactNode {
) : ( ) : (
<IndustryDetailPanel <IndustryDetailPanel
detail={detail.data} detail={detail.data}
items={items}
canManage={canManage} canManage={canManage}
onSelect={setSelectedId}
onAddChild={() => { onAddChild={() => {
setModal({ kind: 'child', parentId: detail.data.id }); setModal({ kind: 'child', parentId: detail.data.id });
}} }}
+571
View File
@@ -34,6 +34,17 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
padding: 12px 16px;
border: 1px solid #e5e8ef;
border-radius: 10px;
background: #fff;
box-shadow: 0 1px 2px rgba(31, 35, 41, 0.04);
}
.lx-client-filters .semi-input-prefix {
margin: 0 12px 0 10px;
}
.lx-client-filters .semi-input-prefix-icon {
margin: 0;
} }
.lx-client-split { .lx-client-split {
display: flex; display: flex;
@@ -524,3 +535,563 @@
.lx-client-back:hover { .lx-client-back:hover {
color: #1677ff; color: #1677ff;
} }
/* 行业管理 B:头部 / Tab / 子行业 / 引用(对齐 design/a3-ui-v1/b) */
.lx-industry-head {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
border-bottom: 1px solid #eef1f6;
}
.lx-industry-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: #e8f1ff;
color: #1677ff;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
}
.lx-industry-id {
min-width: 0;
flex: 1;
}
.lx-industry-name {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.lx-industry-name b {
font-size: 16px;
line-height: 24px;
}
.lx-industry-meta {
margin-top: 2px;
font-size: 12px;
line-height: 17px;
color: #646a73;
}
.lx-industry-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
flex: none;
}
.lx-industry-tabs {
display: flex;
align-items: stretch;
gap: 2px;
height: 44px;
padding: 0 10px;
border-bottom: 1px solid #eef1f6;
flex: none;
}
.lx-industry-tabs button {
position: relative;
padding: 0 12px;
border: 0;
background: transparent;
font-size: 13px;
color: #646a73;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.lx-industry-tabs button:hover {
color: #1f2329;
}
.lx-industry-tabs button.is-on {
color: #1677ff;
font-weight: 500;
}
.lx-industry-tabs button.is-on::after {
content: '';
position: absolute;
left: 10px;
right: 10px;
bottom: 0;
height: 2px;
border-radius: 2px 2px 0 0;
background: #1677ff;
}
.lx-industry-tabs i {
font-style: normal;
font-size: 12px;
color: #646a73;
}
.lx-industry-tabs button.is-on i {
color: #1677ff;
}
.lx-industry-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 18px 20px;
}
.lx-industry-note {
margin-top: 14px;
padding: 10px 12px;
border-radius: 6px;
background: #fafbfc;
border: 1px solid #eef1f6;
font-size: 12px;
line-height: 18px;
color: #646a73;
display: flex;
gap: 8px;
}
.lx-industry-sec {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #eef1f6;
}
.lx-industry-sec-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.lx-industry-sec-head b {
font-size: 13px;
}
.lx-industry-sec-head span {
font-size: 12px;
color: #646a73;
}
.lx-timeline {
display: flex;
flex-direction: column;
gap: 14px;
padding-left: 4px;
}
.lx-timeline-item {
position: relative;
padding-left: 16px;
}
.lx-timeline-item > i {
position: absolute;
left: 0;
top: 4px;
width: 9px;
height: 9px;
border-radius: 50%;
background: #fff;
border: 2px solid #d0d5dd;
}
.lx-timeline-item.is-first > i {
border-color: #1677ff;
}
.lx-timeline-item b {
display: block;
font-size: 13px;
font-weight: 400;
color: #1f2329;
}
.lx-timeline-item span {
display: block;
margin-top: 2px;
font-size: 12px;
color: #646a73;
}
.lx-industry-childlist {
display: flex;
flex-direction: column;
}
.lx-industry-childrow {
display: flex;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 10px;
border: 0;
border-bottom: 1px solid #eef1f6;
background: transparent;
font-size: 13px;
color: #646a73;
text-align: left;
cursor: pointer;
}
.lx-industry-childrow:hover {
background: #f8fbff;
}
.lx-industry-childrow b {
font-weight: 500;
color: #1f2329;
}
.lx-industry-childrow span {
font-size: 12px;
}
.lx-industry-childrow em {
margin-left: auto;
font-style: normal;
font-size: 12px;
color: #1677ff;
}
.lx-industry-childdot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #d0d5dd;
flex: none;
}
.lx-industry-reflist {
display: flex;
flex-direction: column;
gap: 6px;
}
.lx-industry-refrow {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid #eef1f6;
border-radius: 6px;
}
.lx-industry-refavatar {
width: 28px;
height: 28px;
border-radius: 6px;
background: #e8f1ff;
color: #1677ff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex: none;
}
.lx-industry-refrow b {
display: block;
font-size: 13px;
font-weight: 500;
}
.lx-industry-refrow span {
display: block;
font-size: 12px;
color: #646a73;
}
/* 新建客户 B:步骤导航 + 两级行业卡片 */
.lx-client-stepnav {
background: #fff;
border: 1px solid #e5e8ef;
border-radius: 10px;
padding: 16px 14px;
align-self: flex-start;
}
.lx-client-stepnav__head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0 4px 10px;
}
.lx-client-stepnav__head b {
font-size: 14px;
}
.lx-client-stepnav__head span {
font-size: 12px;
color: #646a73;
}
.lx-stepnav-item {
width: 100%;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 8px;
border: 0;
border-radius: 6px;
background: transparent;
text-align: left;
cursor: pointer;
color: #646a73;
}
.lx-stepnav-item:hover {
background: #f7f8fa;
}
.lx-stepnav-item.is-on {
background: #e8f1ff;
}
.lx-stepnav-num {
width: 24px;
height: 24px;
border-radius: 50%;
background: #f2f4f7;
color: #646a73;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
flex: none;
}
.lx-stepnav-item.is-on .lx-stepnav-num {
background: #1677ff;
color: #fff;
}
.lx-stepnav-item.is-done .lx-stepnav-num {
background: #e8ffea;
color: #008a24;
}
.lx-stepnav-text b {
display: block;
font-size: 13px;
font-weight: 500;
color: #646a73;
}
.lx-stepnav-text span {
display: block;
margin-top: 2px;
font-size: 11px;
line-height: 15px;
color: #646a73;
}
.lx-stepnav-item.is-on .lx-stepnav-text b {
color: #1677ff;
}
.lx-client-formcard__head {
flex: none;
padding: 14px 20px 10px;
border-bottom: 1px solid #eef1f6;
}
.lx-client-formcard__head b {
display: block;
font-size: 14px;
}
.lx-client-formcard__head span {
display: block;
margin-top: 2px;
font-size: 12px;
color: #646a73;
}
.lx-indpick {
display: flex;
flex-direction: column;
gap: 12px;
}
.lx-indpick-group {
border: 1px solid #eef1f6;
border-radius: 6px;
overflow: hidden;
}
.lx-indpick-root {
display: flex;
align-items: center;
gap: 8px;
height: 42px;
padding: 0 14px;
background: #fafbfc;
border-bottom: 1px solid #eef1f6;
}
.lx-indpick-dot {
width: 7px;
height: 7px;
border-radius: 2px;
background: #1677ff;
}
.lx-indpick-root b {
font-size: 13px;
}
.lx-indpick-root span {
margin-left: auto;
font-size: 12px;
color: #646a73;
}
.lx-indpick-opts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px 12px;
padding: 10px 14px;
}
.lx-radio {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
font-size: 13px;
color: #646a73;
text-align: left;
cursor: pointer;
}
.lx-radio:hover {
background: #f7f8fa;
}
.lx-radio.is-on {
background: #e8f1ff;
border-color: rgba(22, 119, 255, 0.24);
color: #1f2329;
}
.lx-radio-dot {
width: 15px;
height: 15px;
border-radius: 50%;
border: 1px solid #d0d5dd;
background: #fff;
flex: none;
position: relative;
}
.lx-radio.is-on .lx-radio-dot {
border-color: #1677ff;
}
.lx-radio.is-on .lx-radio-dot::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: #1677ff;
}
.lx-radio i {
margin-left: auto;
font-style: normal;
font-size: 11px;
color: #646a73;
}
.lx-client-membertable {
margin-top: 12px;
border: 1px solid #eef1f6;
border-radius: 6px;
overflow: hidden;
}
.lx-client-membertable__head,
.lx-client-membertable__row {
display: grid;
grid-template-columns: 1.3fr 1.6fr 0.8fr 0.8fr 0.6fr;
gap: 10px;
align-items: center;
padding: 10px 12px;
}
.lx-client-membertable__head {
background: #fafbfc;
font-size: 12px;
color: #646a73;
border-bottom: 1px solid #eef1f6;
}
.lx-client-membertable__row {
border-bottom: 1px solid #eef1f6;
font-size: 13px;
color: #646a73;
}
.lx-client-membertable__row:last-child {
border-bottom: 0;
}
.lx-client-membertable__row b {
color: #1f2329;
font-weight: 500;
}
.lx-client-membertable__row i {
margin-left: 6px;
font-style: normal;
font-size: 11px;
color: #646a73;
}
.lx-client-linkdanger {
border: 0;
background: transparent;
color: #f53f3f;
font-size: 12px;
cursor: pointer;
text-align: left;
}
.lx-client-linkdanger:hover {
text-decoration: underline;
}
.lx-client-tablehead {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid #eef1f6;
}
.lx-client-tablehead b {
font-size: 14px;
}
.lx-client-tablehead span {
font-size: 12px;
color: #646a73;
}
.lx-client-tablehead__stats {
margin-left: auto;
display: flex;
gap: 14px;
font-size: 12px;
color: #646a73;
}
.lx-client-tablehead__stats b {
font-size: 14px;
margin-left: 4px;
}
.lx-client-seg {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 6px;
background: #f5f7fa;
}
.lx-client-seg button {
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 4px;
background: transparent;
color: #646a73;
font-size: 13px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
}
.lx-client-seg button.is-on {
background: #fff;
color: #1f2329;
font-weight: 500;
box-shadow: 0 1px 2px rgba(31, 35, 41, 0.08);
}
.lx-client-seg i {
font-style: normal;
font-size: 12px;
color: #646a73;
}
.lx-client-sideacts {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
}
.lx-client-sideacts .semi-button {
width: 100%;
justify-content: flex-start;
}
.lx-client-tag {
width: max-content;
}
.lx-client-newgrid {
min-height: 620px;
}
.lx-client-stepnav,
.lx-client-formcard {
min-height: 620px;
}
.lx-tree-caret {
width: 14px;
font-size: 12px;
color: #8f959e;
flex: none;
}
.lx-tree-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #d0d5dd;
flex: none;
}
.lx-client-tree__row.is-child .lx-tree-dot {
margin-left: 6px;
}
@@ -82,6 +82,12 @@
| 客户字段审批 | `/system/customer-fields/approvals` | A:全宽表格 + 通过/驳回弹窗 | | 客户字段审批 | `/system/customer-fields/approvals` | A:全宽表格 + 通过/驳回弹窗 |
- 侧边栏更名:「客户」→「客户管理」、「行业树」→「行业管理」;新增「客户组」;「客户字段」指向审批页,权限 `customer_field:approve`。 - 侧边栏更名:「客户」→「客户管理」、「行业树」→「行业管理」;新增「客户组」;「客户字段」指向审批页,权限 `customer_field:approve`。
- **2026-11-03 UI 二次对齐**(用户反馈行业管理 / 新建客户与原型差距大):
- 行业管理补回原型 B 的头像 + 状态/层级标签 + Tab(基本信息 / 子行业 / 引用)+ 最近变更时间线 + 子行业列表 / 引用客户列表;右栏白底卡片。
- 新建客户补回原型 B 的左侧「创建客户 / 共 4 步」步骤导航(标题 + 说明 + 当前/完成态)与两级行业卡片选择(一级分组、二级单选、客户数),替换原 Semi 默认 Steps + 下拉框。
- 客户组补成员表(客户 / 所属行业 / 状态 / 负责人 / 操作);客户列表补表头统计;客户详情补右栏快捷操作;字段审批补分段状态与中文类型。
- 主操作按钮统一 `theme="solid"`;副操作保持描边。截图已重拍至 `design/a3-ui-v1/impl/`。
- 客户管理筛选区补白底卡片与搜索图标/占位间距,与用户管理、角色管理的筛选条口径一致(2026-11-04)。
- 样式:`styles/client.css` 复用 `--lx-*` token;未新增依赖、未内联 SVG(图标走 `@lingxi/ui/icons`)。 - 样式:`styles/client.css` 复用 `--lx-*` token;未新增依赖、未内联 SVG(图标走 `@lingxi/ui/icons`)。
- API:`api/customers.ts` + `hooks/queries/use-client.ts`,全部用 shared Zod schema 解析。 - API:`api/customers.ts` + `hooks/queries/use-client.ts`,全部用 shared Zod schema 解析。