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/。
354 lines
13 KiB
TypeScript
354 lines
13 KiB
TypeScript
/* 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>
|
||
);
|
||
}
|