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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user