From fe1692fd7f4f307fea8054da6c6c50d5df2bf2d8 Mon Sep 17 00:00:00 2001
From: wsj <593216709@qq.com>
Date: Tue, 27 Oct 2026 23:02:57 +0800
Subject: [PATCH] feat: 提交
---
src/pages/customers/list/utils.ts | 9
src/pages/system/departments/types.ts | 106 ++++
src/pages/customers/list/components/CustomerDetailDrawer.tsx | 120 +++++
src/pages/system/users/types.ts | 3
src/main.tsx | 5
src/pages/system/departments/index.tsx | 180 +++++++
src/pages/system/users/index.tsx | 9
.claude/skills/new-page/SKILL.md | 4
src/pages/system/departments/columns.tsx | 116 +++++
src/pages/customers/list/components/FollowUpRecordsDrawer.tsx | 186 ++++++++
README.md | 1
src/router/routeConfig.tsx | 8
src/pages/system/users/components/UserFormDrawer.tsx | 72 ++
src/pages/customers/list/index.tsx | 44 +
src/pages/system/departments/utils.ts | 52 ++
src/pages/customers/list/components/FollowUpRecordsDrawer.scss | 40 +
src/pages/customers/list/columns.tsx | 66 ++
src/pages/customers/list/server.ts | 26 +
src/pages/system/departments/components/DepartmentFormDrawer.tsx | 179 +++++++
src/pages/system/departments/server.ts | 69 +++
.claude/skills/new-page/templates/table-page/index.tsx | 10
src/pages/customers/list/types.ts | 53 ++
22 files changed, 1,322 insertions(+), 36 deletions(-)
diff --git a/.claude/skills/new-page/SKILL.md b/.claude/skills/new-page/SKILL.md
index 70fb558..4e5b61c 100644
--- a/.claude/skills/new-page/SKILL.md
+++ b/.claude/skills/new-page/SKILL.md
@@ -50,10 +50,12 @@
- 使用 `ProTable`,不要手写「筛选表单 + Table」。
- `columns` 必须放在独立 `columns.tsx`,类型为 `ProColumns<T>[]`;需要回调时导出 `createXxxColumns(options)` 工厂函数。
- 页面中用 `useMemo` 缓存 columns,用 `useCallback` 包裹事件处理函数。
+- `request` 单独定义:在组件内声明 `const fetchList = useCallback((params: XxxListParams) => getXxxList(params), [])`,再传给 `request={fetchList}`;不要在 `<ProTable />` 标签里内联箭头函数。
- 页面组件不直接写请求:列表查询、删除等接口调用统一放在同目录的 `server.ts`,组件只调用导出的方法。
- `server.ts` 使用 `@/api/request` 提供的 `request` 发送请求,不要直接使用 axios;泛型顺序为 `request<请求参数, 响应数据>`。
- 分页使用 `defaultPageSize`(不要用受控的 `pageSize`);需要切换每页条数时设置 `showSizeChanger: true`。
- 不需要右侧工具栏图标时用 `options={false}`。
+- 表格样式:有 `toolBarRender` 时不加 `cardProps`;没有 `toolBarRender` 时补充 `cardProps={{ bodyStyle: { padding: 20 } }}`。
- 表格补充 `scroll={{ x: ... }}` 以兼容窄屏。
## 3. 分支 B:非表格页
@@ -131,6 +133,8 @@
- [ ] 已选择正确分支并整体使用对应模板
- [ ] 表格页使用 ProTable,columns 独立在 `columns.tsx`
+- [ ] `request` 已提取为独立的 `useCallback` 方法,未在标签内内联
+- [ ] 表格页无 `toolBarRender` 时已加 `cardProps={{ bodyStyle: { padding: 20 } }}`,有 `toolBarRender` 时未加
- [ ] 非表格页未创建 `columns.tsx`,使用合适的 Ant Design 组件
- [ ] 两个分支均已创建 `server.ts`,组件内没有直接写请求
- [ ] 类型使用 `ProColumns<T>` / 明确接口,没有 `any`
diff --git a/.claude/skills/new-page/templates/table-page/index.tsx b/.claude/skills/new-page/templates/table-page/index.tsx
index 7655325..f7aa4a7 100644
--- a/.claude/skills/new-page/templates/table-page/index.tsx
+++ b/.claude/skills/new-page/templates/table-page/index.tsx
@@ -5,7 +5,7 @@
import { App as AntdApp, Button } from 'antd';
import { createXxxColumns } from './columns';
import { deleteXxx, getXxxList } from './server';
-import type { XxxQuery, XxxRecord } from './types';
+import type { XxxListParams, XxxQuery, XxxRecord } from './types';
import './Xxx.scss';
const Xxx = () => {
@@ -35,13 +35,17 @@
[handleDelete],
);
+ const fetchList = useCallback(
+ (params: XxxListParams) => getXxxList(params),
+ [],
+ );
+
return (
<div className="xxx-page">
<ProTable<XxxRecord, XxxQuery>
rowKey="key"
actionRef={actionRef}
columns={columns}
- cardProps={{ className: 'xxx-table-card' }}
search={{ labelWidth: 'auto', defaultCollapsed: false }}
scroll={{ x: 900 }}
options={false}
@@ -61,7 +65,7 @@
新增
</Button>,
]}
- request={(params) => getXxxList(params)}
+ request={fetchList}
/>
</div>
);
diff --git a/README.md b/README.md
index b279907..1c7ecde 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,7 @@
| 工作台 | `/dashboard` | 数据卡片、快捷入口、动态、最近订单 |
| 用户管理 | `/system/users` | 员工列表、详情、增删改、导入 / 导出 |
| 角色管理 | `/system/roles` | 角色列表、详情、启用 / 停用 |
+| 部门管理 | `/system/departments` | 组织树、增删改、启用 / 停用 |
| 订单管理 | `/business/orders` | 筛选、处理订单、导出 |
| 客户列表 | `/customers/list` | 接口数据,按归属销售员筛选 |
| 404 | 任意未知地址 | 页面不存在提示 |
diff --git a/src/main.tsx b/src/main.tsx
index 79e9751..71ccb0f 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -35,6 +35,11 @@
itemSelectedColor: '#4338ca',
itemHoverBg: '#f5f6fa',
},
+ Drawer: {
+ // 底部按钮区与内容区保持一致的留白,避免按钮贴边。
+ footerPaddingBlock: 16,
+ footerPaddingInline: 24,
+ },
},
}}
>
diff --git a/src/pages/customers/list/columns.tsx b/src/pages/customers/list/columns.tsx
index 8184b97..b7bca61 100644
--- a/src/pages/customers/list/columns.tsx
+++ b/src/pages/customers/list/columns.tsx
@@ -1,25 +1,29 @@
-import { Tag } from 'antd';
+import { Button, Space, Tag } from 'antd';
import type { ProColumns } from '@ant-design/pro-components';
import type {
CustomerLevel,
+ CustomerSource,
CustomerStage,
CustomerType,
CustomerVO,
+ FollowMethod,
+ FollowRecordType,
+ FollowStatus,
} from './types';
-const customerTypeMeta: Record<CustomerType, { text: string; color: string }> = {
+export const customerTypeMeta: Record<CustomerType, { text: string; color: string }> = {
ENTERPRISE: { text: '企业', color: 'blue' },
PERSONAL: { text: '个人', color: 'default' },
};
-const levelMeta: Record<CustomerLevel, { text: string; color: string }> = {
+export const levelMeta: Record<CustomerLevel, { text: string; color: string }> = {
VIP: { text: 'VIP', color: 'purple' },
A: { text: 'A 类', color: 'gold' },
B: { text: 'B 类', color: 'blue' },
C: { text: 'C 类', color: 'default' },
};
-const stageMeta: Record<CustomerStage, { text: string; color: string }> = {
+export const stageMeta: Record<CustomerStage, { text: string; color: string }> = {
NEW_LEAD: { text: '新线索', color: 'default' },
PENDING_ASSIGN: { text: '待分配', color: 'orange' },
CONTACTING: { text: '沟通中', color: 'processing' },
@@ -30,7 +34,35 @@
LOST: { text: '已流失', color: 'error' },
};
-const renderTags = (tags?: string) => {
+export const sourceMeta: Record<CustomerSource, string> = {
+ MINI_PROGRAM: '小程序',
+ SALES_INPUT: '销售录入',
+ EXHIBITION: '展会',
+ REFERRAL: '转介绍',
+ WEBSITE: '官网',
+};
+
+export const followMethodMeta: Record<FollowMethod, string> = {
+ PHONE: '电话',
+ WECHAT: '微信',
+ VISIT: '拜访',
+ EMAIL: '邮件',
+ MEETING: '会议',
+};
+
+export const followStatusMeta: Record<FollowStatus, { text: string; color: string }> = {
+ DONE: { text: '已完成', color: 'success' },
+ PENDING: { text: '待跟进', color: 'processing' },
+ OVERDUE: { text: '已逾期', color: 'error' },
+};
+
+export const followRecordTypeMeta: Record<FollowRecordType, { text: string; color: string }> = {
+ FOLLOW: { text: '跟进', color: 'blue' },
+ PLAN: { text: '计划', color: 'default' },
+ STAGE_CHANGE: { text: '阶段变更', color: 'purple' },
+};
+
+export const renderTags = (tags?: string) => {
const items = tags?.split(/[,,]/).map((tag) => tag.trim()).filter(Boolean) ?? [];
if (!items.length) return '-';
@@ -45,7 +77,10 @@
);
};
-export const customerColumns: ProColumns<CustomerVO>[] = [
+export const createCustomerColumns = (options: {
+ onDetail: (record: CustomerVO) => void;
+ onFollowUp: (record: CustomerVO) => void;
+}): ProColumns<CustomerVO>[] => [
{
title: '客户名称',
dataIndex: 'name',
@@ -139,7 +174,7 @@
valueType: 'digit',
tooltip: '按归属销售员 ID 筛选,不填表示全部',
fieldProps: { placeholder: '销售员 ID', min: 1, precision: 0 },
- render: (_value, record) => (record.ownerId ? `#${record.ownerId}` : '-'),
+ render: (_value, record) => record.ownerName ?? '-',
},
{
title: '最近跟进',
@@ -155,4 +190,21 @@
search: false,
render: (_value, record) => renderTags(record.tags),
},
+ {
+ title: '操作',
+ key: 'action',
+ valueType: 'option',
+ width: 150,
+ fixed: 'right',
+ render: (_value, record) => (
+ <Space size={0}>
+ <Button type="link" size="small" onClick={() => options.onDetail(record)}>
+ 详情
+ </Button>
+ <Button type="link" size="small" onClick={() => options.onFollowUp(record)}>
+ 跟进记录
+ </Button>
+ </Space>
+ ),
+ },
];
diff --git a/src/pages/customers/list/components/CustomerDetailDrawer.tsx b/src/pages/customers/list/components/CustomerDetailDrawer.tsx
new file mode 100644
index 0000000..bd4eec1
--- /dev/null
+++ b/src/pages/customers/list/components/CustomerDetailDrawer.tsx
@@ -0,0 +1,120 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Button, Descriptions, Drawer, Empty, Spin, Tag } from 'antd';
+import type { DescriptionsProps } from 'antd';
+import { customerTypeMeta, levelMeta, renderTags, sourceMeta, stageMeta } from '../columns';
+import { getCustomerDetail } from '../server';
+import type { CustomerVO } from '../types';
+import { formatDateTime } from '../utils';
+
+interface CustomerDetailDrawerProps {
+ open: boolean;
+ /** 待展示的客户 ID,关闭时为 null。 */
+ customerId: number | null;
+ onClose: () => void;
+}
+
+const renderTag = (meta?: { text: string; color: string }) =>
+ meta ? (
+ <Tag color={meta.color} bordered={false}>
+ {meta.text}
+ </Tag>
+ ) : (
+ '-'
+ );
+
+const CustomerDetailDrawer = ({ open, customerId, onClose }: CustomerDetailDrawerProps) => {
+ const [customer, setCustomer] = useState<CustomerVO | null>(null);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (!open || !customerId) return;
+
+ let active = true;
+ setCustomer(null);
+ setLoading(true);
+
+ getCustomerDetail({ id: customerId })
+ .then((data) => {
+ if (active) setCustomer(data);
+ })
+ .catch(() => {
+ // 错误已由 request 统一提示
+ if (active) setCustomer(null);
+ })
+ .finally(() => {
+ if (active) setLoading(false);
+ });
+
+ return () => {
+ active = false;
+ };
+ }, [customerId, open]);
+
+ const items = useMemo<DescriptionsProps['items']>(() => {
+ if (!customer) return [];
+
+ return [
+ { key: 'name', label: '客户名称', children: customer.name || '-' },
+ {
+ key: 'customerType',
+ label: '客户类型',
+ children: renderTag(customer.customerType ? customerTypeMeta[customer.customerType] : undefined),
+ },
+ {
+ key: 'level',
+ label: '客户等级',
+ children: renderTag(customer.level ? levelMeta[customer.level] : undefined),
+ },
+ {
+ key: 'stage',
+ label: '客户阶段',
+ children: renderTag(customer.stage ? stageMeta[customer.stage] : undefined),
+ },
+ { key: 'industry', label: '所属行业', children: customer.industry || '-' },
+ { key: 'region', label: '所在地区', children: customer.region || '-' },
+ {
+ key: 'source',
+ label: '客户来源',
+ children: customer.source ? sourceMeta[customer.source] ?? customer.source : '-',
+ },
+ { key: 'contactName', label: '联系人', children: customer.contactName || '-' },
+ { key: 'contactPhone', label: '联系电话', children: customer.contactPhone || '-' },
+ { key: 'contactPosition', label: '职位', children: customer.contactPosition || '-' },
+ { key: 'contactTel', label: '备用电话', children: customer.contactTel || '-' },
+ { key: 'email', label: '邮箱', children: customer.email || '-' },
+ { key: 'address', label: '地址', children: customer.address || '-' },
+ { key: 'demand', label: '需求描述', children: customer.demand || '-' },
+ { key: 'tags', label: '标签', children: renderTags(customer.tags) },
+ { key: 'owner', label: '归属销售员', children: customer.ownerName || '-' },
+ { key: 'lastFollowAt', label: '最近跟进', children: formatDateTime(customer.lastFollowAt) },
+ { key: 'nextFollowAt', label: '下次跟进', children: formatDateTime(customer.nextFollowAt) },
+ { key: 'dealAt', label: '成交时间', children: formatDateTime(customer.dealAt) },
+ { key: 'lostAt', label: '流失时间', children: formatDateTime(customer.lostAt) },
+ { key: 'lostReason', label: '流失原因', children: customer.lostReason || '-' },
+ ];
+ }, [customer]);
+
+ return (
+ <Drawer
+ title="客户详情"
+ width={520}
+ open={open}
+ onClose={onClose}
+ footer={
+ <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
+ <Button onClick={onClose}>关闭</Button>
+ </div>
+ }
+ >
+ <Spin spinning={loading}>
+ {customer ? (
+ <Descriptions column={1} bordered size="small" items={items} />
+ ) : (
+ <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无客户信息" />
+ )}
+ </Spin>
+ </Drawer>
+ );
+};
+
+export default CustomerDetailDrawer;
diff --git a/src/pages/customers/list/components/FollowUpRecordsDrawer.scss b/src/pages/customers/list/components/FollowUpRecordsDrawer.scss
new file mode 100644
index 0000000..8a50073
--- /dev/null
+++ b/src/pages/customers/list/components/FollowUpRecordsDrawer.scss
@@ -0,0 +1,40 @@
+.follow-up-records {
+ &__summary {
+ margin-bottom: 20px;
+ font-size: 13px;
+ color: var(--app-text-secondary);
+ }
+
+ &__footer {
+ margin-top: 4px;
+ text-align: center;
+ }
+}
+
+.follow-up-item {
+ &__header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ }
+
+ &__time {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--app-text);
+ }
+
+ &__content {
+ margin-top: 6px;
+ color: var(--app-text);
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+
+ &__meta {
+ margin-top: 4px;
+ font-size: 12.5px;
+ color: var(--app-text-secondary);
+ }
+}
diff --git a/src/pages/customers/list/components/FollowUpRecordsDrawer.tsx b/src/pages/customers/list/components/FollowUpRecordsDrawer.tsx
new file mode 100644
index 0000000..3cc14eb
--- /dev/null
+++ b/src/pages/customers/list/components/FollowUpRecordsDrawer.tsx
@@ -0,0 +1,186 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Button, Drawer, Empty, Spin, Tag, Timeline } from 'antd';
+import type { TimelineProps } from 'antd';
+import { followMethodMeta, followRecordTypeMeta, followStatusMeta, stageMeta } from '../columns';
+import { getCustomerFollowUps } from '../server';
+import type { FollowStatus, FollowUpVO } from '../types';
+import { formatDateTime } from '../utils';
+import './FollowUpRecordsDrawer.scss';
+
+interface FollowUpRecordsDrawerProps {
+ open: boolean;
+ /** 待查看跟进记录的客户,关闭时为 null。 */
+ customer: { id: number; name: string } | null;
+ onClose: () => void;
+}
+
+/** 每次加载的跟进记录条数。 */
+const PAGE_SIZE = 100;
+
+const statusDotColors: Record<FollowStatus, string> = {
+ DONE: 'green',
+ PENDING: 'blue',
+ OVERDUE: 'red',
+};
+
+const renderStageChange = (record: FollowUpVO) => {
+ if (record.recordType !== 'STAGE_CHANGE') return null;
+
+ const from = record.stageFrom ? stageMeta[record.stageFrom]?.text ?? record.stageFrom : '-';
+ const to = record.stageTo ? stageMeta[record.stageTo]?.text ?? record.stageTo : '-';
+
+ return (
+ <div className="follow-up-item__meta">
+ 阶段变化:{from} → {to}
+ </div>
+ );
+};
+
+const renderFollowUpItem = (record: FollowUpVO) => {
+ const method = record.followMethod
+ ? followMethodMeta[record.followMethod] ?? record.followMethod
+ : null;
+ const recordType = record.recordType ? followRecordTypeMeta[record.recordType] : null;
+ const status = record.followStatus ? followStatusMeta[record.followStatus] : null;
+
+ return (
+ <div className="follow-up-item">
+ <div className="follow-up-item__header">
+ <span className="follow-up-item__time">
+ {formatDateTime(record.createdAt ?? record.followAt)}
+ </span>
+ {method && <Tag bordered={false}>{method}</Tag>}
+ {recordType && (
+ <Tag color={recordType.color} bordered={false}>
+ {recordType.text}
+ </Tag>
+ )}
+ {status && (
+ <Tag color={status.color} bordered={false}>
+ {status.text}
+ </Tag>
+ )}
+ </div>
+
+ {record.content && <div className="follow-up-item__content">{record.content}</div>}
+
+ {renderStageChange(record)}
+
+ {(record.nextFollowAt || record.nextFollowPlan) && (
+ <div className="follow-up-item__meta">
+ 下次跟进:{formatDateTime(record.nextFollowAt)}
+ {record.nextFollowPlan ? ` · ${record.nextFollowPlan}` : ''}
+ </div>
+ )}
+
+ {record.followerId && (
+ <div className="follow-up-item__meta">跟进人:#{record.followerId}</div>
+ )}
+ </div>
+ );
+};
+
+const FollowUpRecordsDrawer = ({ open, customer, onClose }: FollowUpRecordsDrawerProps) => {
+ const [records, setRecords] = useState<FollowUpVO[]>([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [loading, setLoading] = useState(false);
+ const [loadingMore, setLoadingMore] = useState(false);
+
+ useEffect(() => {
+ if (!open || !customer) {
+ setRecords([]);
+ setTotal(0);
+ setPage(1);
+ return;
+ }
+
+ let active = true;
+ setRecords([]);
+ setTotal(0);
+ setPage(1);
+ setLoading(true);
+
+ getCustomerFollowUps({ id: customer.id, page: 1, size: PAGE_SIZE })
+ .then((result) => {
+ if (!active) return;
+ setRecords(result.records ?? []);
+ setTotal(result.total ?? 0);
+ })
+ .catch(() => {
+ // 错误已由 request 统一提示
+ if (active) setRecords([]);
+ })
+ .finally(() => {
+ if (active) setLoading(false);
+ });
+
+ return () => {
+ active = false;
+ };
+ }, [customer, open]);
+
+ const handleLoadMore = useCallback(async () => {
+ if (!customer) return;
+
+ const nextPage = page + 1;
+ setLoadingMore(true);
+ try {
+ const result = await getCustomerFollowUps({
+ id: customer.id,
+ page: nextPage,
+ size: PAGE_SIZE,
+ });
+ setRecords((prev) => [...prev, ...(result.records ?? [])]);
+ setTotal(result.total ?? 0);
+ setPage(nextPage);
+ } catch {
+ // 错误已由 request 统一提示
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [customer, page]);
+
+ const timelineItems = useMemo<TimelineProps['items']>(
+ () =>
+ records.map((record) => ({
+ key: record.id,
+ color: record.followStatus ? statusDotColors[record.followStatus] : 'gray',
+ children: renderFollowUpItem(record),
+ })),
+ [records],
+ );
+
+ const hasMore = records.length < total;
+
+ return (
+ <Drawer
+ title={customer ? `跟进记录 · ${customer.name}` : '跟进记录'}
+ width={560}
+ open={open}
+ onClose={onClose}
+ >
+ <Spin spinning={loading}>
+ {records.length ? (
+ <div className="follow-up-records">
+ <div className="follow-up-records__summary">共 {total} 条跟进记录</div>
+
+ <Timeline items={timelineItems} />
+
+ {hasMore && (
+ <div className="follow-up-records__footer">
+ <Button loading={loadingMore} onClick={handleLoadMore}>
+ 加载更多
+ </Button>
+ </div>
+ )}
+ </div>
+ ) : (
+ <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无跟进记录" />
+ )}
+ </Spin>
+ </Drawer>
+ );
+};
+
+export default FollowUpRecordsDrawer;
diff --git a/src/pages/customers/list/index.tsx b/src/pages/customers/list/index.tsx
index c236f68..c228154 100644
--- a/src/pages/customers/list/index.tsx
+++ b/src/pages/customers/list/index.tsx
@@ -1,20 +1,46 @@
-import { useMemo } from 'react';
+import { useCallback, useMemo, useState } from 'react';
import { ProTable } from '@ant-design/pro-components';
-import { customerColumns } from './columns';
+import { createCustomerColumns } from './columns';
+import CustomerDetailDrawer from './components/CustomerDetailDrawer';
+import FollowUpRecordsDrawer from './components/FollowUpRecordsDrawer';
import { getCustomerList } from './server';
import type { CustomerListQuery, CustomerVO } from './types';
const CustomerList = () => {
- const columns = useMemo(() => customerColumns, []);
+ const [detailId, setDetailId] = useState<number | null>(null);
+ const [followUpCustomer, setFollowUpCustomer] = useState<{ id: number; name: string } | null>(
+ null,
+ );
+
+ const openDetail = useCallback((record: CustomerVO) => {
+ setDetailId(record.id);
+ }, []);
+
+ const openFollowUps = useCallback((record: CustomerVO) => {
+ setFollowUpCustomer({ id: record.id, name: record.name });
+ }, []);
+
+ const closeDetail = useCallback(() => {
+ setDetailId(null);
+ }, []);
+
+ const closeFollowUps = useCallback(() => {
+ setFollowUpCustomer(null);
+ }, []);
+
+ const columns = useMemo(
+ () => createCustomerColumns({ onDetail: openDetail, onFollowUp: openFollowUps }),
+ [openDetail, openFollowUps],
+ );
return (
<div className="customer-list-page">
<ProTable<CustomerVO, CustomerListQuery>
rowKey="id"
columns={columns}
- cardProps={{ className: 'table-card' }}
+ cardProps={{ bodyStyle: { padding: 20 } }}
search={{ labelWidth: 'auto', defaultCollapsed: false }}
- scroll={{ x: 1510 }}
+ scroll={{ x: 1660 }}
options={false}
pagination={{
defaultPageSize: 10,
@@ -36,6 +62,14 @@
};
}}
/>
+
+ <CustomerDetailDrawer open={detailId !== null} customerId={detailId} onClose={closeDetail} />
+
+ <FollowUpRecordsDrawer
+ open={followUpCustomer !== null}
+ customer={followUpCustomer}
+ onClose={closeFollowUps}
+ />
</div>
);
};
diff --git a/src/pages/customers/list/server.ts b/src/pages/customers/list/server.ts
index fe6a086..78a208e 100644
--- a/src/pages/customers/list/server.ts
+++ b/src/pages/customers/list/server.ts
@@ -1,6 +1,13 @@
import { getAuthHeaders } from '@/api/authHeaders';
import { request } from '@/api/request';
-import type { TenantCustomerPageQuery, TenantCustomerPageResult } from './types';
+import type {
+ TenantCustomerDetailRequest,
+ TenantCustomerDetailResponse,
+ TenantCustomerFollowUpListRequest,
+ TenantCustomerFollowUpListResponse,
+ TenantCustomerPageQuery,
+ TenantCustomerPageResult,
+} from './types';
/** 查询本租户客户列表(只读)。 */
export const getCustomerList = (params: TenantCustomerPageQuery) =>
@@ -10,3 +17,20 @@
params,
headers: getAuthHeaders(),
});
+
+/** 查询客户详情(只读)。 */
+export const getCustomerDetail = ({ id }: TenantCustomerDetailRequest) =>
+ request<TenantCustomerDetailRequest, TenantCustomerDetailResponse>({
+ url: `/tenant/customers/${id}`,
+ method: 'GET',
+ headers: getAuthHeaders(),
+ });
+
+/** 查询客户跟进历史(只读,按 followAt 倒序)。 */
+export const getCustomerFollowUps = ({ id, ...params }: TenantCustomerFollowUpListRequest) =>
+ request<TenantCustomerFollowUpListRequest, TenantCustomerFollowUpListResponse>({
+ url: `/tenant/customers/${id}/follow-ups`,
+ method: 'GET',
+ params,
+ headers: getAuthHeaders(),
+ });
diff --git a/src/pages/customers/list/types.ts b/src/pages/customers/list/types.ts
index 7851cda..40b57ee 100644
--- a/src/pages/customers/list/types.ts
+++ b/src/pages/customers/list/types.ts
@@ -54,6 +54,8 @@
tags?: string;
orgId?: number;
ownerId?: number;
+ /** 归属销售员姓名。 */
+ ownerName?: string;
stage?: CustomerStage;
lastFollowAt?: string;
nextFollowAt?: string;
@@ -65,6 +67,57 @@
/** 客户列表响应数据(ResultPageResultCustomerVO 的 data)。 */
export type TenantCustomerPageResult = PageResult<CustomerVO>;
+/** 客户详情请求参数。 */
+export interface TenantCustomerDetailRequest {
+ id: number;
+}
+
+/** 客户详情响应数据(ResultCustomerVO 的 data)。 */
+export type TenantCustomerDetailResponse = CustomerVO;
+
+/** 跟进方式。 */
+export type FollowMethod = 'PHONE' | 'WECHAT' | 'VISIT' | 'EMAIL' | 'MEETING';
+
+/** 跟进状态。 */
+export type FollowStatus = 'DONE' | 'PENDING' | 'OVERDUE';
+
+/** 跟进记录类型。 */
+export type FollowRecordType = 'FOLLOW' | 'PLAN' | 'STAGE_CHANGE';
+
+/** 跟进记录(FollowUpVO)。 */
+export interface FollowUpVO {
+ id: number;
+ customerId?: number;
+ followerId?: number;
+ /** 记录创建时间(后端实际返回,时间轴按此展示)。 */
+ createdAt?: string;
+ followAt?: string;
+ followMethod?: FollowMethod;
+ content?: string;
+ nextFollowAt?: string;
+ nextFollowPlan?: string;
+ followStatus?: FollowStatus;
+ recordType?: FollowRecordType;
+ stageFrom?: CustomerStage;
+ stageTo?: CustomerStage;
+}
+
+/** 客户跟进历史查询参数(FollowUpPageQuery)。 */
+export interface FollowUpPageQuery {
+ page?: number;
+ size?: number;
+ sortBy?: string;
+ sortDir?: string;
+}
+
+/** 客户跟进历史请求参数:路径 id + 分页。 */
+export interface TenantCustomerFollowUpListRequest extends FollowUpPageQuery {
+ id: number;
+}
+
+/** 客户跟进历史响应数据(ResultPageResultFollowUpVO 的 data)。 */
+export type TenantCustomerFollowUpListResponse = PageResult<FollowUpVO>;
+
/** ProTable 搜索表单参数,字段与请求参数中可筛选的字段保持一致。 */
export interface CustomerListQuery {
ownerId?: number;
diff --git a/src/pages/customers/list/utils.ts b/src/pages/customers/list/utils.ts
new file mode 100644
index 0000000..69417f6
--- /dev/null
+++ b/src/pages/customers/list/utils.ts
@@ -0,0 +1,9 @@
+import dayjs from 'dayjs';
+
+/** 格式化后端返回的日期时间,无法解析时原样展示。 */
+export const formatDateTime = (value?: string) => {
+ if (!value) return '-';
+
+ const date = dayjs(value);
+ return date.isValid() ? date.format('YYYY-MM-DD HH:mm') : value;
+};
diff --git a/src/pages/system/departments/columns.tsx b/src/pages/system/departments/columns.tsx
new file mode 100644
index 0000000..ba67b61
--- /dev/null
+++ b/src/pages/system/departments/columns.tsx
@@ -0,0 +1,116 @@
+import { Button, Space, Tag } from 'antd';
+import type { ProColumns } from '@ant-design/pro-components';
+import type { OrgType, OrgVO } from './types';
+
+export const orgTypeMeta: Record<OrgType, { text: string; color: string }> = {
+ HQ: { text: '总部', color: 'gold' },
+ BRANCH: { text: '分公司', color: 'blue' },
+ DEALER: { text: '经销商', color: 'green' },
+ AGENT: { text: '代理商', color: 'purple' },
+ DEPT: { text: '部门', color: 'cyan' },
+ TEAM: { text: '团队', color: 'orange' },
+};
+
+/** 兼容后端返回未知类型,避免直接索引得到 undefined。 */
+export const getOrgTypeMeta = (type?: string) => {
+ const key = String(type ?? '').toUpperCase() as OrgType;
+ return orgTypeMeta[key] ?? { text: type || '-', color: 'default' };
+};
+
+export const createDepartmentColumns = (options: {
+ leaderNameMap: Map<number, string>;
+ onCreateChild: (record: OrgVO) => void;
+ onEdit: (record: OrgVO) => void;
+ onToggle: (record: OrgVO) => void;
+ onDelete: (record: OrgVO) => void;
+}): ProColumns<OrgVO>[] => [
+ {
+ title: '部门名称',
+ dataIndex: 'orgName',
+ width: 260,
+ fieldProps: { placeholder: '请输入部门名称' },
+ },
+ {
+ title: '组织类型',
+ dataIndex: 'orgType',
+ width: 120,
+ valueType: 'select',
+ valueEnum: {
+ HQ: { text: '总部' },
+ BRANCH: { text: '分公司' },
+ DEALER: { text: '经销商' },
+ AGENT: { text: '代理商' },
+ DEPT: { text: '部门' },
+ TEAM: { text: '团队' },
+ },
+ render: (_value, record) => {
+ const meta = getOrgTypeMeta(record.orgType);
+ return (
+ <Tag color={meta.color} bordered={false}>
+ {meta.text}
+ </Tag>
+ );
+ },
+ },
+ {
+ title: '负责人',
+ dataIndex: 'leaderId',
+ width: 140,
+ search: false,
+ render: (_value, record) => {
+ if (!record.leaderId) return '-';
+ return options.leaderNameMap.get(record.leaderId) ?? `#${record.leaderId}`;
+ },
+ },
+ {
+ title: '所在地区',
+ dataIndex: 'region',
+ width: 140,
+ search: false,
+ render: (_value, record) => record.region || '-',
+ },
+ {
+ title: '排序',
+ dataIndex: 'sortOrder',
+ width: 80,
+ search: false,
+ render: (_value, record) => record.sortOrder ?? '-',
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 100,
+ valueType: 'select',
+ valueEnum: {
+ true: { text: '启用' },
+ false: { text: '停用' },
+ },
+ render: (_value, record) => (
+ <Tag color={record.status ? 'green' : 'default'} bordered={false}>
+ {record.status ? '启用' : '停用'}
+ </Tag>
+ ),
+ },
+ {
+ title: '操作',
+ key: 'action',
+ valueType: 'option',
+ width: 260,
+ render: (_value, record) => (
+ <Space size={0}>
+ <Button type="link" size="small" onClick={() => options.onCreateChild(record)}>
+ 新增子部门
+ </Button>
+ <Button type="link" size="small" onClick={() => options.onEdit(record)}>
+ 编辑
+ </Button>
+ <Button type="link" size="small" onClick={() => options.onToggle(record)}>
+ {record.status ? '停用' : '启用'}
+ </Button>
+ <Button type="link" size="small" danger onClick={() => options.onDelete(record)}>
+ 删除
+ </Button>
+ </Space>
+ ),
+ },
+];
diff --git a/src/pages/system/departments/components/DepartmentFormDrawer.tsx b/src/pages/system/departments/components/DepartmentFormDrawer.tsx
new file mode 100644
index 0000000..b159038
--- /dev/null
+++ b/src/pages/system/departments/components/DepartmentFormDrawer.tsx
@@ -0,0 +1,179 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ App as AntdApp,
+ Button,
+ Drawer,
+ Form,
+ Input,
+ InputNumber,
+ Select,
+ Space,
+ TreeSelect,
+} from 'antd';
+import { createOrg, updateOrg } from '../server';
+import type { DepartmentFormValues, OrgVO } from '../types';
+import { toOrgTreeSelectData } from '../utils';
+
+interface DepartmentFormDrawerProps {
+ open: boolean;
+ mode: 'create' | 'edit';
+ /** 编辑对象;新增时为空。 */
+ record: OrgVO | null;
+ /** 新增子部门时的上级组织 ID。 */
+ parentId?: number;
+ /** 组织树,用于上级组织选择。 */
+ orgTree: OrgVO[];
+ /** 负责人员工下拉选项。 */
+ leaderOptions: { label: string; value: number }[];
+ onClose: () => void;
+ onSuccess: () => void;
+}
+
+const DepartmentFormDrawer = ({
+ open,
+ mode,
+ record,
+ parentId,
+ orgTree,
+ leaderOptions,
+ onClose,
+ onSuccess,
+}: DepartmentFormDrawerProps) => {
+ const [form] = Form.useForm<DepartmentFormValues>();
+ const [submitting, setSubmitting] = useState(false);
+ const { message } = AntdApp.useApp();
+ const isEdit = mode === 'edit';
+
+ const parentOptions = useMemo(
+ () => toOrgTreeSelectData(orgTree, isEdit ? record?.id : undefined),
+ [isEdit, orgTree, record],
+ );
+
+ useEffect(() => {
+ if (!open) return;
+
+ form.resetFields();
+ form.setFieldsValue(
+ isEdit && record
+ ? {
+ orgName: record.orgName,
+ orgType: record.orgType ?? 'DEPT',
+ parentId: record.parentId ?? undefined,
+ leaderId: record.leaderId ?? undefined,
+ region: record.region,
+ sortOrder: record.sortOrder,
+ }
+ : {
+ orgType: 'DEPT',
+ parentId,
+ },
+ );
+ }, [form, isEdit, open, parentId, record]);
+
+ const handleSubmit = useCallback(async () => {
+ let values: DepartmentFormValues;
+ try {
+ values = await form.validateFields();
+ } catch {
+ return;
+ }
+
+ setSubmitting(true);
+
+ try {
+ if (isEdit && record) {
+ await updateOrg({ id: record.id, ...values });
+ message.success('部门已更新');
+ } else {
+ await createOrg(values);
+ message.success('部门创建成功');
+ }
+
+ onSuccess();
+ } catch {
+ // 错误已由 request 统一提示
+ } finally {
+ setSubmitting(false);
+ }
+ }, [form, isEdit, message, onSuccess, record]);
+
+ const title = isEdit ? '编辑部门' : parentId ? '新增子部门' : '新增部门';
+
+ return (
+ <Drawer
+ title={title}
+ width={420}
+ open={open}
+ forceRender
+ onClose={onClose}
+ footer={
+ <Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
+ <Button onClick={onClose}>取消</Button>
+ <Button type="primary" loading={submitting} onClick={handleSubmit}>
+ {isEdit ? '保存' : '创建'}
+ </Button>
+ </Space>
+ }
+ >
+ <Form form={form} layout="vertical" preserve={false}>
+ <Form.Item
+ name="orgName"
+ label="部门名称"
+ rules={[{ required: true, whitespace: true, message: '请输入部门名称' }]}
+ >
+ <Input placeholder="请输入部门名称" maxLength={50} />
+ </Form.Item>
+ <Form.Item
+ name="orgType"
+ label="组织类型"
+ rules={[{ required: true, message: '请选择组织类型' }]}
+ >
+ <Select
+ placeholder="请选择组织类型"
+ options={[
+ { label: '总部', value: 'HQ' },
+ { label: '分公司', value: 'BRANCH' },
+ { label: '经销商', value: 'DEALER' },
+ { label: '代理商', value: 'AGENT' },
+ { label: '部门', value: 'DEPT' },
+ { label: '团队', value: 'TEAM' },
+ ]}
+ />
+ </Form.Item>
+ <Form.Item name="parentId" label="上级组织">
+ <TreeSelect
+ treeData={parentOptions}
+ treeDefaultExpandAll
+ allowClear
+ showSearch
+ treeNodeFilterProp="title"
+ placeholder="不选则作为根组织"
+ style={{ width: '100%' }}
+ />
+ </Form.Item>
+ <Form.Item name="leaderId" label="负责人">
+ <Select
+ options={leaderOptions}
+ showSearch
+ optionFilterProp="label"
+ allowClear
+ placeholder="请选择负责人"
+ />
+ </Form.Item>
+ <Form.Item name="region" label="所在地区">
+ <Input placeholder="请输入所在地区" maxLength={50} />
+ </Form.Item>
+ <Form.Item name="sortOrder" label="同级排序">
+ <InputNumber
+ min={0}
+ precision={0}
+ placeholder="数字越小越靠前"
+ style={{ width: '100%' }}
+ />
+ </Form.Item>
+ </Form>
+ </Drawer>
+ );
+};
+
+export default DepartmentFormDrawer;
diff --git a/src/pages/system/departments/index.tsx b/src/pages/system/departments/index.tsx
new file mode 100644
index 0000000..c62577d
--- /dev/null
+++ b/src/pages/system/departments/index.tsx
@@ -0,0 +1,180 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { Key } from 'react';
+import { PlusOutlined } from '@ant-design/icons';
+import { ProTable } from '@ant-design/pro-components';
+import type { ActionType } from '@ant-design/pro-components';
+import { App as AntdApp, Button } from 'antd';
+import { createDepartmentColumns } from './columns';
+import DepartmentFormDrawer from './components/DepartmentFormDrawer';
+import { deleteOrg, getLeaderOptions, getOrgTree, toggleOrgStatus } from './server';
+import type { DepartmentListQuery, OrgVO } from './types';
+import { collectOrgIds, filterOrgTree } from './utils';
+
+const Departments = () => {
+ const actionRef = useRef<ActionType>();
+ const { message, modal } = AntdApp.useApp();
+ const [orgTree, setOrgTree] = useState<OrgVO[]>([]);
+ const [expandedRowKeys, setExpandedRowKeys] = useState<Key[]>([]);
+ const [leaderOptions, setLeaderOptions] = useState<{ label: string; value: number }[]>([]);
+ const [formOpen, setFormOpen] = useState(false);
+ const [formMode, setFormMode] = useState<'create' | 'edit'>('create');
+ const [activeOrg, setActiveOrg] = useState<OrgVO | null>(null);
+ const [parentId, setParentId] = useState<number | undefined>();
+
+ useEffect(() => {
+ let active = true;
+
+ const loadLeaders = async () => {
+ try {
+ const result = await getLeaderOptions();
+ if (!active) return;
+
+ setLeaderOptions(
+ (result.records ?? []).map((employee) => ({
+ label: employee.name,
+ value: employee.id,
+ })),
+ );
+ } catch {
+ // 错误已由 request 统一提示
+ }
+ };
+
+ void loadLeaders();
+
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ const leaderNameMap = useMemo(
+ () => new Map(leaderOptions.map((option) => [option.value, option.label])),
+ [leaderOptions],
+ );
+
+ const fetchDepartments = useCallback(async (params: DepartmentListQuery) => {
+ const list = await getOrgTree();
+ setOrgTree(list);
+ setExpandedRowKeys(collectOrgIds(list));
+
+ const data = filterOrgTree(list, params);
+ return { data, total: data.length, success: true };
+ }, []);
+
+ const openCreateRoot = useCallback(() => {
+ setActiveOrg(null);
+ setParentId(undefined);
+ setFormMode('create');
+ setFormOpen(true);
+ }, []);
+
+ const openCreateChild = useCallback((record: OrgVO) => {
+ setActiveOrg(null);
+ setParentId(record.id);
+ setFormMode('create');
+ setFormOpen(true);
+ }, []);
+
+ const openEdit = useCallback((record: OrgVO) => {
+ setActiveOrg(record);
+ setParentId(undefined);
+ setFormMode('edit');
+ setFormOpen(true);
+ }, []);
+
+ const closeForm = useCallback(() => {
+ setFormOpen(false);
+ setActiveOrg(null);
+ }, []);
+
+ const handleFormSuccess = useCallback(() => {
+ closeForm();
+ actionRef.current?.reload();
+ }, [closeForm]);
+
+ const handleToggle = useCallback(
+ (record: OrgVO) => {
+ const nextAction = record.status ? '停用' : '启用';
+ modal.confirm({
+ title: `${nextAction}部门`,
+ content: `确定要${nextAction}部门「${record.orgName}」吗?`,
+ okText: nextAction,
+ cancelText: '取消',
+ okButtonProps: { danger: !!record.status },
+ onOk: async () => {
+ await toggleOrgStatus({ id: record.id });
+ message.success(`部门已${nextAction}`);
+ actionRef.current?.reload();
+ },
+ });
+ },
+ [message, modal],
+ );
+
+ const handleDelete = useCallback(
+ (record: OrgVO) => {
+ modal.confirm({
+ title: '删除部门',
+ content: `确定要删除部门「${record.orgName}」吗?存在子部门时可能无法删除。`,
+ okText: '删除',
+ cancelText: '取消',
+ okButtonProps: { danger: true },
+ onOk: async () => {
+ await deleteOrg({ id: record.id });
+ message.success('部门已删除');
+ actionRef.current?.reload();
+ },
+ });
+ },
+ [message, modal],
+ );
+
+ const columns = useMemo(
+ () =>
+ createDepartmentColumns({
+ leaderNameMap,
+ onCreateChild: openCreateChild,
+ onEdit: openEdit,
+ onToggle: handleToggle,
+ onDelete: handleDelete,
+ }),
+ [leaderNameMap, openCreateChild, openEdit, handleToggle, handleDelete],
+ );
+
+ return (
+ <div className="department-page">
+ <ProTable<OrgVO, DepartmentListQuery>
+ rowKey="id"
+ actionRef={actionRef}
+ columns={columns}
+ search={{ labelWidth: 'auto', defaultCollapsed: false }}
+ scroll={{ x: 1100 }}
+ options={false}
+ pagination={false}
+ expandable={{
+ expandedRowKeys,
+ onExpandedRowsChange: (keys) => setExpandedRowKeys([...keys]),
+ }}
+ toolBarRender={() => [
+ <Button key="create" type="primary" icon={<PlusOutlined />} onClick={openCreateRoot}>
+ 新增部门
+ </Button>,
+ ]}
+ request={fetchDepartments}
+ />
+
+ <DepartmentFormDrawer
+ open={formOpen}
+ mode={formMode}
+ record={activeOrg}
+ parentId={parentId}
+ orgTree={orgTree}
+ leaderOptions={leaderOptions}
+ onClose={closeForm}
+ onSuccess={handleFormSuccess}
+ />
+ </div>
+ );
+};
+
+export default Departments;
diff --git a/src/pages/system/departments/server.ts b/src/pages/system/departments/server.ts
new file mode 100644
index 0000000..cf003c6
--- /dev/null
+++ b/src/pages/system/departments/server.ts
@@ -0,0 +1,69 @@
+import { getAuthHeaders } from '@/api/authHeaders';
+import { request } from '@/api/request';
+import type {
+ TenantEmployeeOptionListRequest,
+ TenantEmployeeOptionListResponse,
+ TenantOrgCreateRequest,
+ TenantOrgCreateResponse,
+ TenantOrgDeleteRequest,
+ TenantOrgDeleteResponse,
+ TenantOrgToggleStatusRequest,
+ TenantOrgToggleStatusResponse,
+ TenantOrgTreeRequest,
+ TenantOrgTreeResponse,
+ TenantOrgUpdateBody,
+ TenantOrgUpdateRequest,
+ TenantOrgUpdateResponse,
+} from './types';
+
+/** 组织树。 */
+export const getOrgTree = (params: TenantOrgTreeRequest = {}) =>
+ request<TenantOrgTreeRequest, TenantOrgTreeResponse>({
+ url: '/tenant/organizations/tree',
+ method: 'GET',
+ params,
+ headers: getAuthHeaders(),
+ });
+
+/** 创建组织。 */
+export const createOrg = (data: TenantOrgCreateRequest) =>
+ request<TenantOrgCreateRequest, TenantOrgCreateResponse>({
+ url: '/tenant/organizations',
+ method: 'POST',
+ data,
+ headers: getAuthHeaders(),
+ });
+
+/** 更新组织。 */
+export const updateOrg = ({ id, ...data }: TenantOrgUpdateRequest) =>
+ request<TenantOrgUpdateBody, TenantOrgUpdateResponse>({
+ url: `/tenant/organizations/${id}`,
+ method: 'PUT',
+ data,
+ headers: getAuthHeaders(),
+ });
+
+/** 删除组织。 */
+export const deleteOrg = ({ id }: TenantOrgDeleteRequest) =>
+ request<TenantOrgDeleteRequest, TenantOrgDeleteResponse>({
+ url: `/tenant/organizations/${id}`,
+ method: 'DELETE',
+ headers: getAuthHeaders(),
+ });
+
+/** 启用 / 停用组织。 */
+export const toggleOrgStatus = ({ id }: TenantOrgToggleStatusRequest) =>
+ request<TenantOrgToggleStatusRequest, TenantOrgToggleStatusResponse>({
+ url: `/tenant/organizations/${id}/toggle-status`,
+ method: 'PUT',
+ headers: getAuthHeaders(),
+ });
+
+/** 员工列表,用于负责人下拉选项。 */
+export const getLeaderOptions = (params: TenantEmployeeOptionListRequest = { page: 1, size: 200 }) =>
+ request<TenantEmployeeOptionListRequest, TenantEmployeeOptionListResponse>({
+ url: '/tenant/employees',
+ method: 'GET',
+ params,
+ headers: getAuthHeaders(),
+ });
diff --git a/src/pages/system/departments/types.ts b/src/pages/system/departments/types.ts
new file mode 100644
index 0000000..c56653a
--- /dev/null
+++ b/src/pages/system/departments/types.ts
@@ -0,0 +1,106 @@
+import type { PageResult } from '@/types/api';
+
+/** 组织类型。 */
+export type OrgType = 'HQ' | 'BRANCH' | 'DEALER' | 'AGENT' | 'DEPT' | 'TEAM';
+
+/** 组织响应体(OrgVO)。 */
+export interface OrgVO {
+ id: number;
+ orgName: string;
+ orgType?: OrgType;
+ parentId?: number | null;
+ orgPath?: string;
+ leaderId?: number | null;
+ region?: string;
+ sortOrder?: number;
+ status?: boolean;
+ children?: OrgVO[];
+}
+
+/** GET /tenant/organizations/tree 请求参数。 */
+export interface TenantOrgTreeRequest {
+ /** 传入则返回以该节点为根的子树;不传返回所有根组织。 */
+ orgId?: number;
+}
+
+/** GET /tenant/organizations/tree 响应数据(ResultListOrgVO 的 data)。 */
+export type TenantOrgTreeResponse = OrgVO[];
+
+/** POST /tenant/organizations 请求体(OrgCreateDTO)。 */
+export interface TenantOrgCreateRequest {
+ orgName: string;
+ /** 组织类型,默认 DEPT。 */
+ orgType?: OrgType;
+ /** 上级组织 ID,根组织不传。 */
+ parentId?: number;
+ /** 负责人员工 ID。 */
+ leaderId?: number;
+ region?: string;
+ sortOrder?: number;
+}
+
+/** POST /tenant/organizations 响应数据(ResultOrgVO 的 data)。 */
+export type TenantOrgCreateResponse = OrgVO;
+
+/** PUT /tenant/organizations/{id} 请求参数(路径参数 + OrgCreateDTO)。 */
+export interface TenantOrgUpdateRequest extends TenantOrgCreateRequest {
+ /** 组织 ID(路径参数)。 */
+ id: number;
+}
+
+/** PUT /tenant/organizations/{id} 请求体。 */
+export type TenantOrgUpdateBody = Omit<TenantOrgUpdateRequest, 'id'>;
+
+/** PUT /tenant/organizations/{id} 响应数据(ResultOrgVO 的 data)。 */
+export type TenantOrgUpdateResponse = OrgVO;
+
+/** DELETE /tenant/organizations/{id} 请求参数。 */
+export interface TenantOrgDeleteRequest {
+ /** 组织 ID(路径参数)。 */
+ id: number;
+}
+
+/** DELETE /tenant/organizations/{id} 响应数据(ResultVoid 的 data)。 */
+export type TenantOrgDeleteResponse = void;
+
+/** PUT /tenant/organizations/{id}/toggle-status 请求参数。 */
+export interface TenantOrgToggleStatusRequest {
+ /** 组织 ID(路径参数)。 */
+ id: number;
+}
+
+/** PUT /tenant/organizations/{id}/toggle-status 响应数据(ResultVoid 的 data)。 */
+export type TenantOrgToggleStatusResponse = void;
+
+/** GET /tenant/employees 请求参数(仅用于负责人下拉)。 */
+export interface TenantEmployeeOptionListRequest {
+ page?: number;
+ size?: number;
+}
+
+/** 负责人选项(EmployeeVO 子集)。 */
+export interface DepartmentLeaderOption {
+ id: number;
+ name: string;
+ status?: string;
+}
+
+/** GET /tenant/employees 响应数据(ResultPageResultEmployeeVO 的 data)。 */
+export type TenantEmployeeOptionListResponse = PageResult<DepartmentLeaderOption>;
+
+/** ProTable 筛选参数:组织树接口没有查询参数,这些字段仅用于前端本地过滤。 */
+export interface DepartmentListQuery {
+ orgName?: string;
+ orgType?: OrgType;
+ status?: 'true' | 'false';
+}
+
+/** 新增 / 编辑部门表单值。 */
+export interface DepartmentFormValues {
+ orgName: string;
+ orgType: OrgType;
+ parentId?: number;
+ leaderId?: number;
+ region?: string;
+ sortOrder?: number;
+}
diff --git a/src/pages/system/departments/utils.ts b/src/pages/system/departments/utils.ts
new file mode 100644
index 0000000..48af3d3
--- /dev/null
+++ b/src/pages/system/departments/utils.ts
@@ -0,0 +1,52 @@
+import type { DepartmentListQuery, OrgVO } from './types';
+
+/** 收集组织树中所有节点 ID,用于默认展开全部节点。 */
+export const collectOrgIds = (nodes: OrgVO[]): number[] =>
+ nodes.flatMap((node) => [node.id, ...collectOrgIds(node.children ?? [])]);
+
+/**
+ * 按名称 / 类型 / 状态过滤组织树。
+ * 命中节点保留完整子树;节点自身不命中但存在命中的后代时,保留其祖先链路。
+ */
+export const filterOrgTree = (nodes: OrgVO[], query: DepartmentListQuery): OrgVO[] => {
+ const keyword = query.orgName?.trim().toLowerCase();
+
+ const filterNodes = (list: OrgVO[]): OrgVO[] =>
+ list.flatMap((node) => {
+ const matchesSelf =
+ (!keyword || node.orgName.toLowerCase().includes(keyword)) &&
+ (!query.orgType || node.orgType === query.orgType) &&
+ (!query.status || String(node.status) === query.status);
+
+ if (matchesSelf) return [node];
+
+ const children = filterNodes(node.children ?? []);
+ return children.length ? [{ ...node, children }] : [];
+ });
+
+ return filterNodes(nodes);
+};
+
+export interface OrgTreeSelectOption {
+ value: number;
+ title: string;
+ children?: OrgTreeSelectOption[];
+}
+
+/** 组织树转为 TreeSelect 数据;编辑时排除自身及其子树,避免把上级设为自身。 */
+export const toOrgTreeSelectData = (
+ nodes: OrgVO[],
+ excludeId?: number,
+): OrgTreeSelectOption[] =>
+ nodes.flatMap((node) => {
+ if (node.id === excludeId) return [];
+
+ const children = toOrgTreeSelectData(node.children ?? [], excludeId);
+ return [
+ {
+ value: node.id,
+ title: node.orgName,
+ children: children.length ? children : undefined,
+ },
+ ];
+ });
diff --git a/src/pages/system/users/components/UserFormDrawer.tsx b/src/pages/system/users/components/UserFormDrawer.tsx
index aac230f..b71a3f6 100644
--- a/src/pages/system/users/components/UserFormDrawer.tsx
+++ b/src/pages/system/users/components/UserFormDrawer.tsx
@@ -1,7 +1,8 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import {
App as AntdApp,
Button,
+ Cascader,
Drawer,
Form,
Input,
@@ -11,7 +12,38 @@
Spin,
} from 'antd';
import { createEmployee, getEmployeeDetail, updateEmployee } from '../server';
-import type { EmployeeFormValues, EmployeeVO, SelectOption } from '../types';
+import type {
+ EmployeeFormValues,
+ EmployeeVO,
+ OrgTreeNode,
+ SelectOption,
+} from '../types';
+
+interface OrgCascaderOption {
+ value: number;
+ label: string;
+ children?: OrgCascaderOption[];
+}
+
+/** 组织树转为级联选择选项,按层级展示。 */
+const toCascaderOptions = (nodes: OrgTreeNode[]): OrgCascaderOption[] =>
+ nodes.map((node) => ({
+ value: node.id,
+ label: node.orgName,
+ children: node.children?.length ? toCascaderOptions(node.children) : undefined,
+ }));
+
+/** 在组织树中查找某个组织 ID 的完整路径,用于编辑 / 详情时回填级联值。 */
+const findOrgPath = (nodes: OrgTreeNode[], targetId: number): number[] | null => {
+ for (const node of nodes) {
+ if (node.id === targetId) return [node.id];
+
+ const childPath = node.children ? findOrgPath(node.children, targetId) : null;
+ if (childPath) return [node.id, ...childPath];
+ }
+
+ return null;
+};
export type UserFormMode = 'create' | 'edit' | 'detail';
@@ -20,16 +52,20 @@
mode: UserFormMode;
/** 编辑 / 详情对象;新增时为空。 */
record: EmployeeVO | null;
- orgOptions: SelectOption[];
+ /** 组织树,用于所属组织的二级联动选择。 */
+ orgTree: OrgTreeNode[];
roleOptions: SelectOption[];
onClose: () => void;
onSuccess: () => void;
}
-const toFormValues = (employee: EmployeeVO): EmployeeFormValues => ({
+const toFormValues = (
+ employee: EmployeeVO,
+ orgTree: OrgTreeNode[],
+): EmployeeFormValues => ({
name: employee.name,
phone: employee.phone,
- orgId: employee.orgId,
+ orgPath: findOrgPath(orgTree, employee.orgId) ?? [],
roleId: employee.roleId,
position: employee.position,
email: employee.email,
@@ -41,7 +77,7 @@
open,
mode,
record,
- orgOptions,
+ orgTree,
roleOptions,
onClose,
onSuccess,
@@ -51,6 +87,7 @@
const [loading, setLoading] = useState(false);
const { message } = AntdApp.useApp();
const isDetail = mode === 'detail';
+ const orgCascaderOptions = useMemo(() => toCascaderOptions(orgTree), [orgTree]);
useEffect(() => {
if (!open) return;
@@ -66,7 +103,7 @@
setLoading(true);
try {
const data = await getEmployeeDetail({ id: record.id });
- if (active) form.setFieldsValue(toFormValues(data));
+ if (active) form.setFieldsValue(toFormValues(data, orgTree));
} catch {
// 错误已由 request 统一提示
if (active) onClose();
@@ -77,7 +114,7 @@
}
if (mode === 'edit' && record) {
- form.setFieldsValue(toFormValues(record));
+ form.setFieldsValue(toFormValues(record, orgTree));
}
};
@@ -86,7 +123,7 @@
return () => {
active = false;
};
- }, [form, mode, onClose, open, record]);
+ }, [form, mode, onClose, open, orgTree, record]);
const handleSubmit = useCallback(async () => {
if (isDetail) return;
@@ -99,12 +136,16 @@
}
setSubmitting(true);
+
+ const { orgPath, ...rest } = values;
+ const orgId = orgPath[orgPath.length - 1];
+
try {
if (mode === 'edit' && record) {
- await updateEmployee({ id: record.id, ...values });
+ await updateEmployee({ id: record.id, ...rest, orgId });
message.success('员工信息已更新');
} else {
- await createEmployee(values);
+ await createEmployee({ ...rest, orgId });
message.success('员工创建成功');
}
@@ -156,16 +197,17 @@
<Input placeholder="请输入手机号" maxLength={11} disabled={isDetail} />
</Form.Item>
<Form.Item
- name="orgId"
+ name="orgPath"
label="所属组织"
rules={[{ required: true, message: '请选择所属组织' }]}
>
- <Select
+ <Cascader
+ options={orgCascaderOptions}
+ changeOnSelect
showSearch
- optionFilterProp="label"
- options={orgOptions}
placeholder="请选择所属组织"
disabled={isDetail}
+ style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
diff --git a/src/pages/system/users/index.tsx b/src/pages/system/users/index.tsx
index 36d9d32..9bfd44f 100644
--- a/src/pages/system/users/index.tsx
+++ b/src/pages/system/users/index.tsx
@@ -39,7 +39,7 @@
const actionRef = useRef<ActionType>();
const lastQueryRef = useRef<TenantEmployeeListRequest>({});
const { message, modal } = AntdApp.useApp();
- const [orgOptions, setOrgOptions] = useState<SelectOption[]>([]);
+ const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]);
const [roleOptions, setRoleOptions] = useState<SelectOption[]>([]);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<UserFormMode>('create');
@@ -57,7 +57,7 @@
const [orgs, roles] = await Promise.all([getOrgTree(), getRoleOptions()]);
if (!active) return;
- setOrgOptions(flattenOrgOptions(orgs));
+ setOrgTree(orgs);
setRoleOptions(roles.map((role) => ({ label: role.roleName, value: role.id })));
} catch {
// 错误已由 request 统一提示
@@ -70,6 +70,8 @@
active = false;
};
}, []);
+
+ const orgOptions = useMemo(() => flattenOrgOptions(orgTree), [orgTree]);
const orgNameMap = useMemo(
() => new Map(orgOptions.map((option) => [option.value, option.label])),
@@ -188,7 +190,6 @@
rowKey="id"
actionRef={actionRef}
columns={columns}
- cardProps={{ className: 'table-card' }}
search={{ labelWidth: 'auto', defaultCollapsed: false }}
scroll={{ x: 1290 }}
options={false}
@@ -246,7 +247,7 @@
open={formOpen}
mode={formMode}
record={activeEmployee}
- orgOptions={orgOptions}
+ orgTree={orgTree}
roleOptions={roleOptions}
onClose={closeForm}
onSuccess={handleFormSuccess}
diff --git a/src/pages/system/users/types.ts b/src/pages/system/users/types.ts
index 1b91cdb..953124a 100644
--- a/src/pages/system/users/types.ts
+++ b/src/pages/system/users/types.ts
@@ -152,7 +152,8 @@
export interface EmployeeFormValues {
name: string;
phone: string;
- orgId: number;
+ /** 所属组织级联选择路径,最后一项为实际的 orgId。 */
+ orgPath: number[];
roleId: number;
position?: string;
email?: string;
diff --git a/src/router/routeConfig.tsx b/src/router/routeConfig.tsx
index 190fb86..27ce482 100644
--- a/src/router/routeConfig.tsx
+++ b/src/router/routeConfig.tsx
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react';
import {
+ ApartmentOutlined,
AppstoreOutlined,
ContactsOutlined,
DashboardOutlined,
@@ -12,6 +13,7 @@
import Orders from '@/pages/business/orders';
import CustomerList from '@/pages/customers/list';
import Dashboard from '@/pages/dashboard';
+import Departments from '@/pages/system/departments';
import Roles from '@/pages/system/roles';
import Users from '@/pages/system/users';
@@ -67,6 +69,12 @@
icon: <SafetyOutlined />,
element: <Roles />,
},
+ {
+ path: '/system/departments',
+ title: '部门管理',
+ icon: <ApartmentOutlined />,
+ element: <Departments />,
+ },
],
},
{
--
Gitblit v1.9.3