Files
lingxi/apps/web/src/pages/system/customers/CustomerNewPage.tsx
T
Stev_Wang 3e4bf48841
ci / 质量门禁 (push) Waiting to run
fix(web): A3 页面对齐选定原型(行业 Tab / 新建向导 / 筛选条)
背景: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/。
2026-11-04 08:04:14 +08:00

354 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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';
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 = [
{ key: 'industry', label: '选择行业', desc: '行业决定行业包与可用模板' },
{ key: 'basic', label: '基础信息', desc: '名称、简称、负责人与对接人' },
{ key: 'prefs', label: '交付偏好', desc: '命名、目录、规格与禁忌' },
{ key: 'pack', label: '行业资产包', desc: '冷启动套用行业资产' },
];
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 });
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 industryLabel = useMemo(() => {
for (const root of industries.data?.items ?? []) {
if (root.id === industryId) return root.name;
for (const child of root.children) {
if (child.id === industryId) return `${root.name} / ${child.name}`;
}
}
return '未选择';
}, [industries.data, industryId]);
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>
</header>
<div className="lx-client-newgrid">
<aside className="lx-client-stepnav">
<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-section__head">
<b>客户归属行业</b>
<span className="lx-client-hint" style={{ marginTop: 0 }}>
只能选择末级行业;一级行业没有二级时可直接选择
</span>
</div>
<IndustryPicker
items={industries.data?.items ?? []}
value={industryId}
onChange={setIndustryId}
/>
<div className="lx-client-hint">
所属行业决定行业包;客户创建后不可跨行业移动,调整行业需停用后新建。
</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 theme="outline" onClick={() => void navigate(ROUTES.system.customers)}>
取消
</Button>
) : (
<Button
onClick={() => {
setCurrent((step) => step - 1);
}}
>
上一步
</Button>
)}
{current < 3 ? (
<Button theme="solid" type="primary" onClick={next}>
下一步
</Button>
) : (
<Button
theme="solid"
type="primary"
loading={create.isPending}
onClick={() => void submit()}
>
创建客户
</Button>
)}
</div>
</section>
</div>
</div>
);
}