12 files modified
10 files added
| | |
| | | - 使用 `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:非表格页 |
| | |
| | | |
| | | - [ ] 已选择正确分支并整体使用对应模板 |
| | | - [ ] 表格页使用 ProTable,columns 独立在 `columns.tsx` |
| | | - [ ] `request` 已提取为独立的 `useCallback` 方法,未在标签内内联 |
| | | - [ ] 表格页无 `toolBarRender` 时已加 `cardProps={{ bodyStyle: { padding: 20 } }}`,有 `toolBarRender` 时未加 |
| | | - [ ] 非表格页未创建 `columns.tsx`,使用合适的 Ant Design 组件 |
| | | - [ ] 两个分支均已创建 `server.ts`,组件内没有直接写请求 |
| | | - [ ] 类型使用 `ProColumns<T>` / 明确接口,没有 `any` |
| | |
| | | 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 = () => { |
| | |
| | | [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} |
| | |
| | | 新增 |
| | | </Button>, |
| | | ]} |
| | | request={(params) => getXxxList(params)} |
| | | request={fetchList} |
| | | /> |
| | | </div> |
| | | ); |
| | |
| | | | 工作台 | `/dashboard` | 数据卡片、快捷入口、动态、最近订单 | |
| | | | 用户管理 | `/system/users` | 员工列表、详情、增删改、导入 / 导出 | |
| | | | 角色管理 | `/system/roles` | 角色列表、详情、启用 / 停用 | |
| | | | 部门管理 | `/system/departments` | 组织树、增删改、启用 / 停用 | |
| | | | 订单管理 | `/business/orders` | 筛选、处理订单、导出 | |
| | | | 客户列表 | `/customers/list` | 接口数据,按归属销售员筛选 | |
| | | | 404 | 任意未知地址 | 页面不存在提示 | |
| | |
| | | itemSelectedColor: '#4338ca', |
| | | itemHoverBg: '#f5f6fa', |
| | | }, |
| | | Drawer: { |
| | | // 底部按钮区与内容区保持一致的留白,避免按钮贴边。 |
| | | footerPaddingBlock: 16, |
| | | footerPaddingInline: 24, |
| | | }, |
| | | }, |
| | | }} |
| | | > |
| | |
| | | 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' }, |
| | |
| | | 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 '-'; |
| | | |
| | |
| | | ); |
| | | }; |
| | | |
| | | export const customerColumns: ProColumns<CustomerVO>[] = [ |
| | | export const createCustomerColumns = (options: { |
| | | onDetail: (record: CustomerVO) => void; |
| | | onFollowUp: (record: CustomerVO) => void; |
| | | }): ProColumns<CustomerVO>[] => [ |
| | | { |
| | | title: '客户名称', |
| | | dataIndex: 'name', |
| | |
| | | valueType: 'digit', |
| | | tooltip: '按归属销售员 ID 筛选,不填表示全部', |
| | | fieldProps: { placeholder: '销售员 ID', min: 1, precision: 0 }, |
| | | render: (_value, record) => (record.ownerId ? `#${record.ownerId}` : '-'), |
| | | render: (_value, record) => record.ownerName ?? '-', |
| | | }, |
| | | { |
| | | title: '最近跟进', |
| | |
| | | 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> |
| | | ), |
| | | }, |
| | | ]; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | .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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | |
| | | 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, |
| | |
| | | }; |
| | | }} |
| | | /> |
| | | |
| | | <CustomerDetailDrawer open={detailId !== null} customerId={detailId} onClose={closeDetail} /> |
| | | |
| | | <FollowUpRecordsDrawer |
| | | open={followUpCustomer !== null} |
| | | customer={followUpCustomer} |
| | | onClose={closeFollowUps} |
| | | /> |
| | | </div> |
| | | ); |
| | | }; |
| | |
| | | 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) => |
| | |
| | | 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(), |
| | | }); |
| | |
| | | tags?: string; |
| | | orgId?: number; |
| | | ownerId?: number; |
| | | /** 归属销售员姓名。 */ |
| | | ownerName?: string; |
| | | stage?: CustomerStage; |
| | | lastFollowAt?: string; |
| | | nextFollowAt?: string; |
| | |
| | | /** 客户列表响应数据(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; |
| New file |
| | |
| | | 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; |
| | | }; |
| New file |
| | |
| | | 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> |
| | | ), |
| | | }, |
| | | ]; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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(), |
| | | }); |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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, |
| | | }, |
| | | ]; |
| | | }); |
| | |
| | | import { useCallback, useEffect, useState } from 'react'; |
| | | import { useCallback, useEffect, useMemo, useState } from 'react'; |
| | | import { |
| | | App as AntdApp, |
| | | Button, |
| | | Cascader, |
| | | Drawer, |
| | | Form, |
| | | Input, |
| | |
| | | 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'; |
| | | |
| | |
| | | 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, |
| | |
| | | open, |
| | | mode, |
| | | record, |
| | | orgOptions, |
| | | orgTree, |
| | | roleOptions, |
| | | onClose, |
| | | onSuccess, |
| | |
| | | const [loading, setLoading] = useState(false); |
| | | const { message } = AntdApp.useApp(); |
| | | const isDetail = mode === 'detail'; |
| | | const orgCascaderOptions = useMemo(() => toCascaderOptions(orgTree), [orgTree]); |
| | | |
| | | useEffect(() => { |
| | | if (!open) return; |
| | |
| | | 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(); |
| | |
| | | } |
| | | |
| | | if (mode === 'edit' && record) { |
| | | form.setFieldsValue(toFormValues(record)); |
| | | form.setFieldsValue(toFormValues(record, orgTree)); |
| | | } |
| | | }; |
| | | |
| | |
| | | return () => { |
| | | active = false; |
| | | }; |
| | | }, [form, mode, onClose, open, record]); |
| | | }, [form, mode, onClose, open, orgTree, record]); |
| | | |
| | | const handleSubmit = useCallback(async () => { |
| | | if (isDetail) return; |
| | |
| | | } |
| | | |
| | | 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('员工创建成功'); |
| | | } |
| | | |
| | |
| | | <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 |
| | |
| | | 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'); |
| | |
| | | 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 统一提示 |
| | |
| | | active = false; |
| | | }; |
| | | }, []); |
| | | |
| | | const orgOptions = useMemo(() => flattenOrgOptions(orgTree), [orgTree]); |
| | | |
| | | const orgNameMap = useMemo( |
| | | () => new Map(orgOptions.map((option) => [option.value, option.label])), |
| | |
| | | rowKey="id" |
| | | actionRef={actionRef} |
| | | columns={columns} |
| | | cardProps={{ className: 'table-card' }} |
| | | search={{ labelWidth: 'auto', defaultCollapsed: false }} |
| | | scroll={{ x: 1290 }} |
| | | options={false} |
| | |
| | | open={formOpen} |
| | | mode={formMode} |
| | | record={activeEmployee} |
| | | orgOptions={orgOptions} |
| | | orgTree={orgTree} |
| | | roleOptions={roleOptions} |
| | | onClose={closeForm} |
| | | onSuccess={handleFormSuccess} |
| | |
| | | export interface EmployeeFormValues { |
| | | name: string; |
| | | phone: string; |
| | | orgId: number; |
| | | /** 所属组织级联选择路径,最后一项为实际的 orgId。 */ |
| | | orgPath: number[]; |
| | | roleId: number; |
| | | position?: string; |
| | | email?: string; |
| | |
| | | import type { ReactNode } from 'react'; |
| | | import { |
| | | ApartmentOutlined, |
| | | AppstoreOutlined, |
| | | ContactsOutlined, |
| | | DashboardOutlined, |
| | |
| | | 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'; |
| | | |
| | |
| | | icon: <SafetyOutlined />, |
| | | element: <Roles />, |
| | | }, |
| | | { |
| | | path: '/system/departments', |
| | | title: '部门管理', |
| | | icon: <ApartmentOutlined />, |
| | | element: <Departments />, |
| | | }, |
| | | ], |
| | | }, |
| | | { |