背景: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:
1 parent
140dc386c1
commit
3e4bf48841
8 files changed
+1174
-215
No files matched your search
@@ -13,6 +13,13 @@ import {
|
||||
useCustomersQuery,
|
||||
} from '@/hooks/queries/use-client';
|
||||
|
||||
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
text: '文本',
|
||||
number: '数字',
|
||||
date: '日期',
|
||||
select: '单选',
|
||||
};
|
||||
|
||||
function statusTag(status: CustomerFieldListItem['status']): ReactNode {
|
||||
const cls =
|
||||
status === 'active'
|
||||
@@ -182,11 +189,15 @@ function RejectModal({
|
||||
export function CustomerFieldsPage(): ReactNode {
|
||||
const [status, setStatus] = useState<'all' | 'pending' | 'active' | 'rejected'>('all');
|
||||
const fields = useCustomerFieldsQuery({ limit: 100, status });
|
||||
const allFields = useCustomerFieldsQuery({ limit: 100, status: 'all' });
|
||||
const approve = useApproveCustomerFieldMutation();
|
||||
const [applyOpen, setApplyOpen] = useState(false);
|
||||
const [rejectField, setRejectField] = useState<CustomerFieldListItem | null>(null);
|
||||
|
||||
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} />;
|
||||
|
||||
@@ -226,19 +237,25 @@ export function CustomerFieldsPage(): ReactNode {
|
||||
</div>
|
||||
</header>
|
||||
<section className="lx-client-filters">
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
value={status}
|
||||
optionList={[
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'pending', label: '待审批' },
|
||||
{ value: 'active', label: '已生效' },
|
||||
{ value: 'rejected', label: '已驳回' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setStatus(value as typeof status);
|
||||
}}
|
||||
/>
|
||||
<div className="lx-client-seg" role="group" aria-label="字段申请状态">
|
||||
{[
|
||||
{ value: 'all' as const, label: '全部', count: allItems.length },
|
||||
{ value: 'pending' as const, label: '待审批', count: countOf('pending') },
|
||||
{ value: 'active' as const, label: '已生效', count: countOf('active') },
|
||||
{ value: 'rejected' as const, label: '已驳回', count: countOf('rejected') },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
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)' }}>
|
||||
共 {items.length} 条申请
|
||||
</span>
|
||||
@@ -274,7 +291,7 @@ export function CustomerFieldsPage(): ReactNode {
|
||||
<b>{field.name}</b>
|
||||
<span className="lx-client-sub">{field.code}</span>
|
||||
</td>
|
||||
<td>{field.type}</td>
|
||||
<td>{FIELD_TYPE_LABELS[field.type] ?? field.type}</td>
|
||||
<td>{field.entersGeneration ? '是' : '否'}</td>
|
||||
<td>{field.applicantName}</td>
|
||||
<td className="lx-client-muted">
|
||||
|
||||
@@ -138,11 +138,25 @@ export function CustomerGroupsPage(): ReactNode {
|
||||
const activeId = selectedId ?? groups.data?.items[0]?.id ?? null;
|
||||
const detail = useCustomerGroupDetailQuery(activeId);
|
||||
const remove = useDeleteCustomerGroupMutation();
|
||||
const updateMembers = useUpdateCustomerGroupMembersMutation();
|
||||
const [modal, setModal] = useState<'create' | 'edit' | 'members' | null>(null);
|
||||
|
||||
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} />;
|
||||
|
||||
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 => {
|
||||
if (detail.data === undefined) return;
|
||||
Modal.confirm({
|
||||
@@ -183,7 +197,9 @@ export function CustomerGroupsPage(): ReactNode {
|
||||
<section className="lx-client-left">
|
||||
<div className="lx-client-left__head">
|
||||
<b>客户组</b>
|
||||
<span>{items.length} 个 · 覆盖 9 个客户</span>
|
||||
<span>
|
||||
{items.length} 个 · 覆盖 {memberTotal} 个客户
|
||||
</span>
|
||||
</div>
|
||||
<div className="lx-client-left__body">
|
||||
{items.length === 0 ? (
|
||||
@@ -232,6 +248,7 @@ export function CustomerGroupsPage(): ReactNode {
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => {
|
||||
setModal('members');
|
||||
}}
|
||||
@@ -239,13 +256,14 @@ export function CustomerGroupsPage(): ReactNode {
|
||||
管理成员
|
||||
</Button>
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => {
|
||||
setModal('edit');
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="danger" onClick={deleteGroup}>
|
||||
<Button theme="outline" type="danger" onClick={deleteGroup}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
@@ -254,16 +272,41 @@ export function CustomerGroupsPage(): ReactNode {
|
||||
<div className="lx-client-hint">
|
||||
调整成员会立即改变引用该客户组的角色数据范围;变更写入审计。
|
||||
</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 ? (
|
||||
<div className="lx-client-hint">暂无成员</div>
|
||||
<div className="lx-client-hint" style={{ padding: '12px 10px' }}>
|
||||
暂无成员
|
||||
</div>
|
||||
) : null}
|
||||
{detail.data.members.map((member) => (
|
||||
<div key={member.id} className="lx-client-memberrow">
|
||||
<b>{member.name}</b>
|
||||
<div key={member.id} className="lx-client-membertable__row">
|
||||
<span>
|
||||
{member.industryPath} · {member.ownerName}
|
||||
<b>{member.name}</b>
|
||||
<i>{member.shortName}</i>
|
||||
</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>
|
||||
|
||||
@@ -370,6 +370,7 @@ export function CustomerDetailPage(): ReactNode {
|
||||
<div className="lx-client-header__actions">
|
||||
<Can permission={Permission.CUSTOMER_TRANSFER}>
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => {
|
||||
setModal('transfer');
|
||||
}}
|
||||
@@ -381,6 +382,7 @@ export function CustomerDetailPage(): ReactNode {
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
{customer.status === 'active' ? (
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => {
|
||||
changeStatus('paused');
|
||||
}}
|
||||
@@ -390,6 +392,7 @@ export function CustomerDetailPage(): ReactNode {
|
||||
) : null}
|
||||
{customer.status === 'paused' ? (
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
changeStatus('active');
|
||||
@@ -400,6 +403,7 @@ export function CustomerDetailPage(): ReactNode {
|
||||
) : null}
|
||||
{customer.status !== 'ended' ? (
|
||||
<Button
|
||||
theme="outline"
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
changeStatus('ended');
|
||||
@@ -545,6 +549,7 @@ export function CustomerDetailPage(): ReactNode {
|
||||
<Can permission={Permission.CUSTOMER_FIELD_APPLY}>
|
||||
<Button
|
||||
size="small"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
disabled={!canWrite || customer.genFieldCount >= 5}
|
||||
onClick={() => {
|
||||
@@ -630,6 +635,57 @@ export function CustomerDetailPage(): ReactNode {
|
||||
<b>待行业包发布</b>
|
||||
</div>
|
||||
</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">
|
||||
<h3>审计提示</h3>
|
||||
<div className="lx-client-hint">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -58,6 +58,11 @@ export function CustomersPage(): ReactNode {
|
||||
}, [industries.data]);
|
||||
|
||||
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 => {
|
||||
void navigate(customerDetailPath(customer.id));
|
||||
@@ -75,6 +80,7 @@ export function CustomersPage(): ReactNode {
|
||||
<div className="lx-client-header__actions">
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconAdd size={16} />}
|
||||
onClick={() => void navigate(customerNewPath())}
|
||||
@@ -140,6 +146,7 @@ export function CustomersPage(): ReactNode {
|
||||
showClear
|
||||
/>
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => {
|
||||
setKeyword('');
|
||||
setIndustryId(undefined);
|
||||
@@ -168,68 +175,85 @@ export function CustomersPage(): ReactNode {
|
||||
<span>按当前筛选条件没有客户,试试重置筛选</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="lx-client-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户</th>
|
||||
<th>所属行业</th>
|
||||
<th>状态</th>
|
||||
<th>负责人</th>
|
||||
<th>客户组</th>
|
||||
<th>进入生成</th>
|
||||
<th>基础字段</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((customer) => (
|
||||
<tr
|
||||
key={customer.id}
|
||||
onClick={() => {
|
||||
openDetail(customer);
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<b>{customer.name}</b>
|
||||
<span className="lx-client-sub">{customer.shortName}</span>
|
||||
</td>
|
||||
<td>{customer.industryPath}</td>
|
||||
<td>
|
||||
<CustomerStatusTag status={customer.status} />
|
||||
</td>
|
||||
<td>{customer.ownerName}</td>
|
||||
<td>{customer.groups.map((group) => group.name).join('、') || '—'}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`lx-client-count${customer.genFieldCount >= 5 ? ' is-full' : ''}`}
|
||||
>
|
||||
{customer.genFieldCount}/5
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="lx-client-count">{customer.baseFieldCount}/10</span>
|
||||
</td>
|
||||
<td className="lx-client-muted">
|
||||
{customer.updatedAt.slice(0, 19).replace('T', ' ')}
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDetail(customer);
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</td>
|
||||
<>
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>客户</th>
|
||||
<th>所属行业</th>
|
||||
<th>状态</th>
|
||||
<th>负责人</th>
|
||||
<th>客户组</th>
|
||||
<th>进入生成</th>
|
||||
<th>基础字段</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((customer) => (
|
||||
<tr
|
||||
key={customer.id}
|
||||
onClick={() => {
|
||||
openDetail(customer);
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<b>{customer.name}</b>
|
||||
<span className="lx-client-sub">{customer.shortName}</span>
|
||||
</td>
|
||||
<td>{customer.industryPath}</td>
|
||||
<td>
|
||||
<CustomerStatusTag status={customer.status} />
|
||||
</td>
|
||||
<td>{customer.ownerName}</td>
|
||||
<td>{customer.groups.map((group) => group.name).join('、') || '—'}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`lx-client-count${customer.genFieldCount >= 5 ? ' is-full' : ''}`}
|
||||
>
|
||||
{customer.genFieldCount}/5
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="lx-client-count">{customer.baseFieldCount}/10</span>
|
||||
</td>
|
||||
<td className="lx-client-muted">
|
||||
{customer.updatedAt.slice(0, 19).replace('T', ' ')}
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDetail(customer);
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
{customers.error ? null : (
|
||||
<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 { type IndustryDetail, type IndustryTreeItem, Permission } from '@lingxi/shared';
|
||||
import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui';
|
||||
import { IconStack } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
@@ -133,6 +134,7 @@ function IndustryTree({
|
||||
onSelect(root.id);
|
||||
}}
|
||||
>
|
||||
<span className="lx-tree-caret">▾</span>
|
||||
<span className="lx-client-tree__name">{root.name}</span>
|
||||
<span className="lx-client-tree__count">{root.children.length}</span>
|
||||
</button>
|
||||
@@ -145,6 +147,7 @@ function IndustryTree({
|
||||
onSelect(child.id);
|
||||
}}
|
||||
>
|
||||
<span className="lx-tree-dot" />
|
||||
<span className="lx-client-tree__name">{child.name}</span>
|
||||
<span className="lx-client-tree__count">{child.customerCount}</span>
|
||||
</button>
|
||||
@@ -157,19 +160,27 @@ function IndustryTree({
|
||||
|
||||
function IndustryDetailPanel({
|
||||
detail,
|
||||
items,
|
||||
canManage,
|
||||
onAddChild,
|
||||
onEdit,
|
||||
onRefresh,
|
||||
onSelect,
|
||||
}: {
|
||||
detail: IndustryDetail;
|
||||
items: readonly IndustryTreeItem[];
|
||||
canManage: boolean;
|
||||
onAddChild: () => void;
|
||||
onEdit: () => void;
|
||||
onRefresh: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
}): ReactNode {
|
||||
const setStatus = useSetIndustryStatusMutation();
|
||||
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 => {
|
||||
Modal.confirm({
|
||||
title: detail.status === 'active' ? '停用行业' : '启用行业',
|
||||
@@ -210,93 +221,233 @@ function IndustryDetailPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="lx-client-right__head">
|
||||
<div>
|
||||
<h2>
|
||||
{detail.name} {statusTag(detail.status)}
|
||||
</h2>
|
||||
<p>
|
||||
{detail.level === 1 ? '一级行业' : '二级行业'}
|
||||
{detail.parentName !== null ? ` · 上级 ${detail.parentName}` : ''} · 排序{' '}
|
||||
{detail.sortOrder}
|
||||
</p>
|
||||
</div>
|
||||
<div className="lx-client-header__actions">
|
||||
{canManage ? (
|
||||
<>
|
||||
{detail.level === 1 ? <Button onClick={onAddChild}>添加二级</Button> : null}
|
||||
<Button onClick={onEdit}>编辑</Button>
|
||||
<Button onClick={toggle}>{detail.status === 'active' ? '停用' : '启用'}</Button>
|
||||
<Button type="danger" onClick={removeIndustry}>
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-client-right__body">
|
||||
<section className="lx-client-section">
|
||||
<div className="lx-client-section__head">
|
||||
<b>基本信息</b>
|
||||
</div>
|
||||
<div className="lx-client-kv">
|
||||
<div className="lx-client-kv__item">
|
||||
<span>行业名称</span>
|
||||
<b>{detail.name}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>层级</span>
|
||||
<b>{detail.level === 1 ? '一级行业' : '二级行业'}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>上级行业</span>
|
||||
<b>{detail.parentName ?? '—'}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>状态</span>
|
||||
<b>{statusTag(detail.status)}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户数</span>
|
||||
<b>{detail.customerCount}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>子行业数</span>
|
||||
<b>{detail.references.childCount}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>资产数</span>
|
||||
<b>{detail.assetCount}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>更新时间</span>
|
||||
<b>{detail.updatedAt.slice(0, 19).replace('T', ' ')}</b>
|
||||
</div>
|
||||
</div>
|
||||
{detail.remark !== '' ? <div className="lx-client-hint">{detail.remark}</div> : null}
|
||||
</section>
|
||||
<section className="lx-client-section">
|
||||
<div className="lx-client-section__head">
|
||||
<b>引用客户</b>
|
||||
<span style={{ fontSize: 12, color: 'var(--lx-color-text-secondary)' }}>
|
||||
{detail.references.customerCount} 个
|
||||
<div className="lx-industry-head">
|
||||
<span className="lx-industry-avatar">
|
||||
<IconStack size={20} />
|
||||
</span>
|
||||
<div className="lx-industry-id">
|
||||
<div className="lx-industry-name">
|
||||
<b>{detail.name}</b>
|
||||
{statusTag(detail.status)}
|
||||
<span className="lx-client-tag lx-client-tag--disabled">
|
||||
{detail.level === 1 ? '一级行业' : '二级行业'}
|
||||
</span>
|
||||
</div>
|
||||
{detail.references.customers.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无客户</div>
|
||||
<div className="lx-industry-meta">
|
||||
{detail.parentName !== null ? `${detail.parentName} / ` : ''}
|
||||
{detail.name} · 排序 {detail.sortOrder} · 更新于{' '}
|
||||
{detail.updatedAt.slice(0, 19).replace('T', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="lx-industry-actions">
|
||||
<Button theme="outline" onClick={onEdit}>
|
||||
编辑
|
||||
</Button>
|
||||
{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>
|
||||
</div>
|
||||
) : null}
|
||||
</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 className="lx-industry-body">
|
||||
{tab === 'basic' ? (
|
||||
<>
|
||||
<div className="lx-client-kv">
|
||||
<div className="lx-client-kv__item">
|
||||
<span>行业名称</span>
|
||||
<b>{detail.name}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>层级</span>
|
||||
<b>{detail.level === 1 ? '一级行业' : '二级行业'}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>上级行业</span>
|
||||
<b>{detail.parentName ?? '—'}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>状态</span>
|
||||
<b>{statusTag(detail.status)}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>排序</span>
|
||||
<b>{detail.sortOrder}</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>客户数</span>
|
||||
<b>{detail.references.customerCount} 个</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>资产数</span>
|
||||
<b>{detail.assetCount} 个</b>
|
||||
</div>
|
||||
<div className="lx-client-kv__item">
|
||||
<span>更新时间</span>
|
||||
<b>{detail.updatedAt.slice(0, 19).replace('T', ' ')}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-industry-note">
|
||||
<span>ⓘ</span>
|
||||
{detail.remark !== '' ? detail.remark : '暂无备注'}
|
||||
</div>
|
||||
{detail.status === 'disabled' ? (
|
||||
<div className="lx-client-banner lx-client-banner--ended">
|
||||
该行业已停用:新建客户时不可选择;已有客户数据不受影响。
|
||||
</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>
|
||||
</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-client-chips">
|
||||
{detail.references.customers.map((customer) => (
|
||||
<span key={customer.id} className="lx-client-chip">
|
||||
{customer.name}
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
<div className="lx-client-hint">
|
||||
删除前需先处理子行业、客户与资产;有启用中的二级行业时禁止停用。
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
) : null}
|
||||
{tab === 'refs' ? (
|
||||
<>
|
||||
<div className="lx-industry-sec-head">
|
||||
<b>客户</b>
|
||||
<span>{detail.references.customerCount} 个</span>
|
||||
</div>
|
||||
{detail.references.customers.length === 0 ? (
|
||||
<div className="lx-client-hint">暂无客户</div>
|
||||
) : (
|
||||
<div className="lx-industry-reflist">
|
||||
{detail.references.customers.map((customer) => (
|
||||
<div key={customer.id} className="lx-industry-refrow">
|
||||
<span className="lx-industry-refavatar">{customer.name.slice(-1)}</span>
|
||||
<div>
|
||||
<b>{customer.name}</b>
|
||||
<span>
|
||||
{customer.status === 'active'
|
||||
? '在服务'
|
||||
: customer.status === 'paused'
|
||||
? '暂停'
|
||||
: '结束'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="lx-industry-sec">
|
||||
<div className="lx-industry-sec-head">
|
||||
<b>资产</b>
|
||||
<span>{detail.assetCount} 个</span>
|
||||
</div>
|
||||
<div className="lx-client-hint">
|
||||
资产按客户归属统计;行业级资产包在 B0/B1 批次接入。
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -340,6 +491,7 @@ export function IndustriesPage(): ReactNode {
|
||||
<div className="lx-client-header__actions">
|
||||
<Can permission={Permission.CUSTOMER_MANAGE}>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setModal({ kind: 'root' });
|
||||
@@ -388,7 +540,9 @@ export function IndustriesPage(): ReactNode {
|
||||
) : (
|
||||
<IndustryDetailPanel
|
||||
detail={detail.data}
|
||||
items={items}
|
||||
canManage={canManage}
|
||||
onSelect={setSelectedId}
|
||||
onAddChild={() => {
|
||||
setModal({ kind: 'child', parentId: detail.data.id });
|
||||
}}
|
||||
|
||||
@@ -34,6 +34,17 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -524,3 +535,563 @@
|
||||
.lx-client-back:hover {
|
||||
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:全宽表格 + 通过/驳回弹窗 |
|
||||
|
||||
- 侧边栏更名:「客户」→「客户管理」、「行业树」→「行业管理」;新增「客户组」;「客户字段」指向审批页,权限 `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`)。
|
||||
- API:`api/customers.ts` + `hooks/queries/use-client.ts`,全部用 shared Zod schema 解析。
|
||||
|
||||
|
||||
Reference in new issue
Block a user