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