wsj
4 days ago fe1692fd7f4f307fea8054da6c6c50d5df2bf2d8
feat: 提交
12 files modified
10 files added
1358 ■■■■■ changed files
.claude/skills/new-page/SKILL.md 4 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/templates/table-page/index.tsx 10 ●●●●● patch | view | raw | blame | history
README.md 1 ●●●●● patch | view | raw | blame | history
src/main.tsx 5 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/columns.tsx 66 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/components/CustomerDetailDrawer.tsx 120 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/components/FollowUpRecordsDrawer.scss 40 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/components/FollowUpRecordsDrawer.tsx 186 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/index.tsx 44 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/server.ts 26 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/types.ts 53 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/utils.ts 9 ●●●●● patch | view | raw | blame | history
src/pages/system/departments/columns.tsx 116 ●●●●● patch | view | raw | blame | history
src/pages/system/departments/components/DepartmentFormDrawer.tsx 179 ●●●●● patch | view | raw | blame | history
src/pages/system/departments/index.tsx 180 ●●●●● patch | view | raw | blame | history
src/pages/system/departments/server.ts 69 ●●●●● patch | view | raw | blame | history
src/pages/system/departments/types.ts 106 ●●●●● patch | view | raw | blame | history
src/pages/system/departments/utils.ts 52 ●●●●● patch | view | raw | blame | history
src/pages/system/users/components/UserFormDrawer.tsx 72 ●●●●● patch | view | raw | blame | history
src/pages/system/users/index.tsx 9 ●●●●● patch | view | raw | blame | history
src/pages/system/users/types.ts 3 ●●●●● patch | view | raw | blame | history
src/router/routeConfig.tsx 8 ●●●●● patch | view | raw | blame | history
.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`
.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>
  );
README.md
@@ -102,6 +102,7 @@
| 工作台   | `/dashboard`            | 数据卡片、快捷入口、动态、最近订单 |
| 用户管理 | `/system/users`         | 员工列表、详情、增删改、导入 / 导出 |
| 角色管理 | `/system/roles`         | 角色列表、详情、启用 / 停用        |
| 部门管理 | `/system/departments`   | 组织树、增删改、启用 / 停用        |
| 订单管理 | `/business/orders`      | 筛选、处理订单、导出               |
| 客户列表 | `/customers/list`       | 接口数据,按归属销售员筛选         |
| 404      | 任意未知地址            | 页面不存在提示                     |
src/main.tsx
@@ -35,6 +35,11 @@
            itemSelectedColor: '#4338ca',
            itemHoverBg: '#f5f6fa',
          },
          Drawer: {
            // 底部按钮区与内容区保持一致的留白,避免按钮贴边。
            footerPaddingBlock: 16,
            footerPaddingInline: 24,
          },
        },
      }}
    >
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>
    ),
  },
];
src/pages/customers/list/components/CustomerDetailDrawer.tsx
New file
@@ -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;
src/pages/customers/list/components/FollowUpRecordsDrawer.scss
New file
@@ -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);
  }
}
src/pages/customers/list/components/FollowUpRecordsDrawer.tsx
New file
@@ -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;
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>
  );
};
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(),
  });
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;
src/pages/customers/list/utils.ts
New file
@@ -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;
};
src/pages/system/departments/columns.tsx
New file
@@ -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>
    ),
  },
];
src/pages/system/departments/components/DepartmentFormDrawer.tsx
New file
@@ -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;
src/pages/system/departments/index.tsx
New file
@@ -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;
src/pages/system/departments/server.ts
New file
@@ -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(),
  });
src/pages/system/departments/types.ts
New file
@@ -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;
}
src/pages/system/departments/utils.ts
New file
@@ -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,
      },
    ];
  });
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
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}
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;
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 />,
      },
    ],
  },
  {