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
+1174 -215

No files matched your search

@@ -1,6 +1,6 @@
/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */
import { Button, Input, Select, Steps } from '@douyinfe/semi-ui-19';
import type { CreateCustomerInput } from '@lingxi/shared';
/* eslint-disable max-lines-per-function, complexity -- 四步向导集中编排步骤、行业选择与提交校验 */
import { Button, Input, Select } from '@douyinfe/semi-ui-19';
import type { CreateCustomerInput, IndustryTreeItem } from '@lingxi/shared';
import { notifySuccess, PageSkeleton } from '@lingxi/ui';
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';
@@ -10,9 +10,73 @@ import { useCreateCustomerMutation, useIndustriesQuery } from '@/hooks/queries/u
import { useUserCandidatesQuery } from '@/hooks/queries/use-users';
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 {
const navigate = useNavigate();
const industries = useIndustriesQuery({ limit: 100 });
@@ -32,21 +96,15 @@ export function CustomerNewPage(): ReactNode {
const [taboo, setTaboo] = useState('绝对化用语,未授权肖像');
const [error, setError] = useState<string | null>(null);
const industryOptions = useMemo(() => {
const options: { value: string; label: string; leaf: boolean }[] = [];
const industryLabel = useMemo(() => {
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 });
}
if (root.id === industryId) return root.name;
for (const child of root.children) {
if (child.id === industryId) return `${root.name} / ${child.name}`;
}
}
return options;
}, [industries.data]);
return '未选择';
}, [industries.data, industryId]);
const next = (): void => {
setError(null);
@@ -112,43 +170,64 @@ export function CustomerNewPage(): ReactNode {
<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>
<div className="lx-client-stepnav__head">
<b>创建客户</b>
<span>共 4 步</span>
</div>
{STEPS.map((step, index) => {
const state = index === current ? ' is-on' : index < current ? ' is-done' : '';
return (
<button
key={step.key}
type="button"
className={`lx-stepnav-item${state}`}
onClick={() => {
if (index < current) setCurrent(index);
}}
>
<span className="lx-stepnav-num">{index < current ? '✓' : index + 1}</span>
<span className="lx-stepnav-text">
<b>{step.label}</b>
<span>{step.desc}</span>
</span>
</button>
);
})}
</aside>
<section className="lx-client-formcard">
<div className="lx-client-formcard__head">
<b>{STEPS[current]?.label}</b>
<span>
{current === 0
? `${industryLabel} · 只能选择末级行业`
: current === 1
? '简称必填,用于全部自动命名'
: current === 2
? '命名、目录、规格与素材禁忌'
: '行业包发布后可一键套用'}
</span>
</div>
<div className="lx-client-formcard__body">
{current === 0 ? (
<div className="lx-client-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 className="lx-client-section__head">
<b>客户归属行业</b>
<span className="lx-client-hint" style={{ marginTop: 0 }}>
只能选择末级行业;一级行业没有二级时可直接选择
</span>
</div>
</div>
<IndustryPicker
items={industries.data?.items ?? []}
value={industryId}
onChange={setIndustryId}
/>
<div className="lx-client-hint">
所属行业决定行业包;客户创建后不可跨行业移动,调整行业需停用后新建。
</div>
</>
) : null}
{current === 1 ? (
<div className="lx-client-formgrid">
@@ -239,7 +318,11 @@ export function CustomerNewPage(): ReactNode {
<div className="lx-client-formcard__foot">
<span className="lx-client-hint">步骤 {current + 1} / 4 · 可回退修改</span>
<span className="spacer" />
{current > 0 ? (
{current === 0 ? (
<Button theme="outline" onClick={() => void navigate(ROUTES.system.customers)}>
取消
</Button>
) : (
<Button
onClick={() => {
setCurrent((step) => step - 1);
@@ -247,13 +330,18 @@ export function CustomerNewPage(): ReactNode {
>
上一步
</Button>
) : null}
)}
{current < 3 ? (
<Button type="primary" onClick={next}>
<Button theme="solid" type="primary" onClick={next}>
下一步
</Button>
) : (
<Button type="primary" loading={create.isPending} onClick={() => void submit()}>
<Button
theme="solid"
type="primary"
loading={create.isPending}
onClick={() => void submit()}
>
创建客户
</Button>
)}