From b09eb09acd24d885bbd610fd96c2114b4711e99b Mon Sep 17 00:00:00 2001
From: wsj <593216709@qq.com>
Date: Mon, 26 Oct 2026 23:17:00 +0800
Subject: [PATCH] feat: 新增客户列表与角色管理页面
---
CLAUDE.md | 30 +
src/pages/dashboard/index.tsx | 2
.claude/skills/new-page/SKILL.md | 2
src/pages/system/roles/types.ts | 45 ++
README.md | 7
.claude/skills/new-page/templates/non-table-page/index.tsx | 7
src/pages/system/roles/server.ts | 34 +
/dev/null | 417 ---------------------
src/pages/system/roles/columns.tsx | 85 ++++
src/pages/system/roles/index.tsx | 126 ++++++
src/router/routeConfig.tsx | 36 +
src/pages/customers/list/index.tsx | 43 ++
src/pages/customers/list/columns.tsx | 158 ++++++++
src/pages/customers/list/server.ts | 12
src/api/authHeaders.ts | 7
.claude/skills/new-page/templates/table-page/index.tsx | 7
src/pages/customers/list/types.ts | 78 ++++
17 files changed, 649 insertions(+), 447 deletions(-)
diff --git a/.claude/skills/new-page/SKILL.md b/.claude/skills/new-page/SKILL.md
index 5991700..70fb558 100644
--- a/.claude/skills/new-page/SKILL.md
+++ b/.claude/skills/new-page/SKILL.md
@@ -90,6 +90,7 @@
- 提示与确认框使用 `AntdApp.useApp()` 提供的 `message` / `modal`,不要用静态方法。
- 使用 Ant Design 5 API,不使用已废弃属性(如 Card 用 `variant="borderless"`)。
- 页面只负责内容区域,外层布局由 `BasicLayout` 提供。
+- 不要在页面内添加标题 / 说明区块(如 `.page-header`),菜单和面包屑已标明页面名称,直接展示内容即可。
- 页面级样式与页面同目录,使用 BEM 类名。
- 不添加权限相关代码:本项目已移除权限体系。
@@ -136,4 +137,5 @@
- [ ] 全部使用箭头函数
- [ ] 提示与确认框通过 `AntdApp.useApp()` 获取
- [ ] 路由已注册,菜单与面包屑可正常显示
+- [ ] 页面没有重复的标题 / 说明区块(不使用 `.page-header`)
- [ ] `npm run typecheck` 通过
diff --git a/.claude/skills/new-page/templates/non-table-page/index.tsx b/.claude/skills/new-page/templates/non-table-page/index.tsx
index d8a991c..3830100 100644
--- a/.claude/skills/new-page/templates/non-table-page/index.tsx
+++ b/.claude/skills/new-page/templates/non-table-page/index.tsx
@@ -24,13 +24,6 @@
return (
<div className="xxx-page">
- <div className="page-header">
- <div>
- <h1 className="page-header__title">页面标题</h1>
- <p className="page-header__desc">页面说明。</p>
- </div>
- </div>
-
<Card variant="borderless">
<Form<XxxFormValues>
form={form}
diff --git a/.claude/skills/new-page/templates/table-page/index.tsx b/.claude/skills/new-page/templates/table-page/index.tsx
index 5bc99e8..7655325 100644
--- a/.claude/skills/new-page/templates/table-page/index.tsx
+++ b/.claude/skills/new-page/templates/table-page/index.tsx
@@ -37,13 +37,6 @@
return (
<div className="xxx-page">
- <div className="page-header">
- <div>
- <h1 className="page-header__title">页面标题</h1>
- <p className="page-header__desc">页面说明。</p>
- </div>
- </div>
-
<ProTable<XxxRecord, XxxQuery>
rowKey="key"
actionRef={actionRef}
diff --git a/CLAUDE.md b/CLAUDE.md
index ff38e16..17347af 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -38,7 +38,8 @@
│ ├── login/
│ ├── dashboard/
│ ├── system/users/
-│ ├── business/{orders,customers}/
+│ ├── business/orders/
+│ ├── customers/list/
│ └── error/NotFound.tsx
│ # 页面目录:index.tsx、columns.tsx、types.ts、server.ts、同名 .scss(按需)
├── router/
@@ -58,7 +59,7 @@
`src/router/routeConfig.tsx` 是**唯一数据源**。新增页面只需在这里配置 `path`、`title`、`icon`、`element`,侧边栏、面包屑会自动读取。
-- 支持 2-3 级嵌套,例如:`系统管理 → 用户管理`。
+- 支持 2-3 级嵌套,例如:`用户与权限 → 用户管理`。
- `src/router/index.tsx` 把菜单配置转换成 React Router 的嵌套路由。
- 分组路由(无 `element`)通过 `<Outlet />` 渲染子页面,默认跳转到第一个子页面。
- 未匹配路由显示 404,普通页面组件位于 `BasicLayout` 内。
@@ -202,6 +203,31 @@
## 七、接口与状态约定
+- **每个接口都必须为其请求(request)与响应(response)定义 `interface`**,并在 `server.ts` 中通过 `request<TReq, TRes>` 显式标注泛型;禁止使用 `any` 或省略类型。
+ - 类型统一定义在页面目录的 `types.ts` 中(跨页面复用的放在 `src/api/` 对应业务域文件)。
+ - 命名约定:`<业务>Request` / `<业务>Response`,例如 `TenantRoleListRequest`、`TenantRoleListResponse`。
+ - 请求体 / 查询参数建模为 `XxxRequest`;响应建模为 `XxxResponse`,对应接口返回 `data` 的结构(若后端是 `Result<T>`,`Response` 指 `data` 部分)。
+ - 无请求参数时也应显式定义,例如 `type TenantRoleListRequest = Record<string, never>`,不要留空或使用 `undefined`。
+
+```ts
+// types.ts
+export interface TenantRoleToggleStatusRequest {
+ id: number;
+}
+export interface TenantRoleToggleStatusResponse {
+ id: number;
+ status: RoleStatus;
+}
+
+// server.ts
+export const toggleRoleStatus = ({ id }: TenantRoleToggleStatusRequest) =>
+ request<TenantRoleToggleStatusRequest, TenantRoleToggleStatusResponse>({
+ url: `/tenant/roles/${id}/toggle-status`,
+ method: 'PUT',
+ headers: getAuthHeaders(),
+ });
+```
+
- 页面级接口调用统一放在页面目录下的 `server.ts`,页面组件通过它调用接口,不要在组件内直接写请求实现。
- 跨页面复用的接口可放在 `src/api/` 下,按业务域拆分文件。
- 网络请求统一使用 `src/api/request.ts` 的 `request`(基于 axios),不要在页面或 `server.ts` 中直接使用 axios。
diff --git a/README.md b/README.md
index efbbb9f..948fb19 100644
--- a/README.md
+++ b/README.md
@@ -51,8 +51,8 @@
```tsx
{
path: '/system',
- title: '系统管理',
- icon: <SettingOutlined />,
+ title: '用户与权限',
+ icon: <SafetyCertificateOutlined />,
children: [
{
path: '/system/users',
@@ -101,8 +101,9 @@
| -------- | ----------------------- | ---------------------------------- |
| 工作台 | `/dashboard` | 数据卡片、快捷入口、动态、最近订单 |
| 用户管理 | `/system/users` | 搜索、新增 / 编辑 / 删除用户 |
+| 角色管理 | `/system/roles` | 角色列表、详情、启用 / 停用 |
| 订单管理 | `/business/orders` | 筛选、处理订单、导出 |
-| 客户管理 | `/business/customers` | 筛选、新增 / 编辑客户 |
+| 客户列表 | `/customers/list` | 接口数据,按归属销售员筛选 |
| 404 | 任意未知地址 | 页面不存在提示 |
## 目录结构
diff --git a/src/api/authHeaders.ts b/src/api/authHeaders.ts
new file mode 100644
index 0000000..baad39e
--- /dev/null
+++ b/src/api/authHeaders.ts
@@ -0,0 +1,7 @@
+import { useAuthStore } from '@/store/auth';
+
+/** 接口要求 Bearer JWT,从登录态读取 token 生成请求头。 */
+export const getAuthHeaders = () => {
+ const token = useAuthStore.getState().token;
+ return token ? { Authorization: `Bearer ${token}` } : undefined;
+};
diff --git a/src/pages/business/customers/Customers.scss b/src/pages/business/customers/Customers.scss
deleted file mode 100644
index 30807c3..0000000
--- a/src/pages/business/customers/Customers.scss
+++ /dev/null
@@ -1,57 +0,0 @@
-.customer-cell {
- display: flex;
- align-items: center;
- gap: 11px;
-
- &__icon {
- width: 38px;
- height: 38px;
- flex-shrink: 0;
- display: grid;
- place-items: center;
- border-radius: 10px;
- background: #eef2ff;
- color: var(--app-primary);
- font-size: 17px;
- }
-
- &__meta {
- display: flex;
- flex-direction: column;
- line-height: 1.35;
- min-width: 0;
- }
-
- &__name {
- font-size: 13.5px;
- font-weight: 600;
- color: var(--app-text);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- &__industry {
- margin-top: 2px;
- font-size: 11.5px;
- color: var(--app-text-secondary);
- }
-}
-
-.customer-contact {
- display: flex;
- flex-direction: column;
- line-height: 1.35;
-
- span {
- font-size: 13px;
- color: var(--app-text);
- }
-
- small {
- margin-top: 2px;
- font-size: 11.5px;
- color: var(--app-text-secondary);
- font-family: 'SFMono-Regular', Consolas, monospace;
- }
-}
diff --git a/src/pages/business/customers/index.tsx b/src/pages/business/customers/index.tsx
deleted file mode 100644
index 7cb82a4..0000000
--- a/src/pages/business/customers/index.tsx
+++ /dev/null
@@ -1,417 +0,0 @@
-import { useMemo, useState } from 'react';
-import { BankOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
-import {
- App as AntdApp,
- Button,
- Card,
- Col,
- Form,
- Input,
- Modal,
- Row,
- Select,
- Table,
- Tag,
-} from 'antd';
-import type { TableProps } from 'antd';
-import dayjs from 'dayjs';
-import './Customers.scss';
-
-type CustomerLevel = 'VIP' | '重点' | '普通' | '潜在';
-type CustomerStatus = 'following' | 'won' | 'lost';
-
-interface CustomerRecord {
- key: number;
- name: string;
- industry: string;
- contact: string;
- phone: string;
- level: CustomerLevel;
- owner: string;
- status: CustomerStatus;
- lastContact: string;
-}
-
-interface CustomerFormValues {
- name: string;
- industry: string;
- contact: string;
- phone: string;
- level: CustomerLevel;
- owner: string;
-}
-
-const levelOptions: CustomerLevel[] = ['VIP', '重点', '普通', '潜在'];
-
-const levelColor: Record<CustomerLevel, string> = {
- VIP: 'purple',
- 重点: 'blue',
- 普通: 'geekblue',
- 潜在: 'default',
-};
-
-const statusMeta: Record<CustomerStatus, { label: string; color: string }> = {
- following: { label: '跟进中', color: 'processing' },
- won: { label: '已成交', color: 'success' },
- lost: { label: '已流失', color: 'default' },
-};
-
-const initialCustomers: CustomerRecord[] = [
- {
- key: 1,
- name: '杭州云启科技有限公司',
- industry: '企业服务',
- contact: '林夏',
- phone: '13800001234',
- level: 'VIP',
- owner: '周洋',
- status: 'won',
- lastContact: '2026-10-17 09:10',
- },
- {
- key: 2,
- name: '上海灵犀网络科技',
- industry: '互联网',
- contact: '周林',
- phone: '13900002345',
- level: '重点',
- owner: '苏雯',
- status: 'following',
- lastContact: '2026-10-16 17:45',
- },
- {
- key: 3,
- name: '深圳前海智造有限公司',
- industry: '智能制造',
- contact: '苏晴',
- phone: '13700003456',
- level: 'VIP',
- owner: '周洋',
- status: 'following',
- lastContact: '2026-10-16 14:20',
- },
- {
- key: 4,
- name: '北京星辰数据服务',
- industry: '大数据',
- contact: '何俊',
- phone: '13600004567',
- level: '普通',
- owner: '杨莉',
- status: 'lost',
- lastContact: '2026-09-28 10:05',
- },
- {
- key: 5,
- name: '成都锦江数字科技',
- industry: '数字营销',
- contact: '杨帆',
- phone: '13500005678',
- level: '重点',
- owner: '赵敏',
- status: 'following',
- lastContact: '2026-10-15 16:30',
- },
- {
- key: 6,
- name: '南京云帆软件有限公司',
- industry: 'SaaS',
- contact: '王悦',
- phone: '18800006789',
- level: 'VIP',
- owner: '苏雯',
- status: 'won',
- lastContact: '2026-10-14 11:12',
- },
- {
- key: 7,
- name: '武汉光谷智能科技',
- industry: '人工智能',
- contact: '赵敏',
- phone: '18900007890',
- level: '潜在',
- owner: '杨莉',
- status: 'following',
- lastContact: '2026-10-12 15:40',
- },
- {
- key: 8,
- name: '西安丝路信息技术',
- industry: '信息技术',
- contact: '陈晨',
- phone: '18600008901',
- level: '普通',
- owner: '赵敏',
- status: 'won',
- lastContact: '2026-10-10 09:55',
- },
-];
-
-const Customers = () => {
- const [customers, setCustomers] = useState<CustomerRecord[]>(initialCustomers);
- const [keyword, setKeyword] = useState('');
- const [level, setLevel] = useState<CustomerLevel | 'all'>('all');
- const [isModalOpen, setIsModalOpen] = useState(false);
- const [editingCustomer, setEditingCustomer] = useState<CustomerRecord | null>(null);
- const [form] = Form.useForm<CustomerFormValues>();
- const { message } = AntdApp.useApp();
-
- const filteredCustomers = useMemo(() => {
- const normalizedKeyword = keyword.trim().toLowerCase();
- return customers.filter((customer) => {
- const matchesKeyword =
- !normalizedKeyword ||
- customer.name.toLowerCase().includes(normalizedKeyword) ||
- customer.contact.toLowerCase().includes(normalizedKeyword) ||
- customer.owner.toLowerCase().includes(normalizedKeyword);
- const matchesLevel = level === 'all' || customer.level === level;
- return matchesKeyword && matchesLevel;
- });
- }, [customers, keyword, level]);
-
- const openCreateModal = () => {
- setEditingCustomer(null);
- form.resetFields();
- form.setFieldsValue({ level: '普通', owner: '林夏' });
- setIsModalOpen(true);
- };
-
- const openEditModal = (record: CustomerRecord) => {
- setEditingCustomer(record);
- form.setFieldsValue({
- name: record.name,
- industry: record.industry,
- contact: record.contact,
- phone: record.phone,
- level: record.level,
- owner: record.owner,
- });
- setIsModalOpen(true);
- };
-
- const closeModal = () => {
- setIsModalOpen(false);
- form.resetFields();
- };
-
- const handleSubmit = async () => {
- try {
- const values = await form.validateFields();
-
- if (editingCustomer) {
- setCustomers((prev) =>
- prev.map((customer) =>
- customer.key === editingCustomer.key ? { ...customer, ...values } : customer,
- ),
- );
- message.success('客户信息已更新');
- } else {
- setCustomers((prev) => [
- {
- key: Date.now(),
- ...values,
- status: 'following',
- lastContact: dayjs().format('YYYY-MM-DD HH:mm'),
- },
- ...prev,
- ]);
- message.success('客户创建成功');
- }
- closeModal();
- } catch {
- // 表单校验失败时由 antd 自动展示错误信息
- }
- };
-
- const columns: TableProps<CustomerRecord>['columns'] = [
- {
- title: '客户名称',
- dataIndex: 'name',
- width: 280,
- render: (_value, record) => (
- <div className="customer-cell">
- <span className="customer-cell__icon">
- <BankOutlined />
- </span>
- <div className="customer-cell__meta">
- <span className="customer-cell__name">{record.name}</span>
- <span className="customer-cell__industry">{record.industry}</span>
- </div>
- </div>
- ),
- },
- {
- title: '联系人',
- dataIndex: 'contact',
- width: 160,
- render: (_value, record) => (
- <div className="customer-contact">
- <span>{record.contact}</span>
- <small>{record.phone}</small>
- </div>
- ),
- },
- {
- title: '客户等级',
- dataIndex: 'level',
- width: 110,
- render: (value: CustomerLevel) => (
- <Tag color={levelColor[value]} bordered={false}>
- {value}
- </Tag>
- ),
- },
- { title: '负责人', dataIndex: 'owner', width: 100 },
- {
- title: '状态',
- dataIndex: 'status',
- width: 110,
- render: (value: CustomerStatus) => (
- <Tag color={statusMeta[value].color} bordered={false}>
- {statusMeta[value].label}
- </Tag>
- ),
- },
- { title: '最近联系', dataIndex: 'lastContact', width: 160 },
- {
- title: '操作',
- key: 'action',
- width: 90,
- render: (_value, record) => (
- <Button type="link" size="small" onClick={() => openEditModal(record)}>
- 编辑
- </Button>
- ),
- },
- ];
-
- return (
- <div className="customers-page">
- <div className="page-header">
- <div>
- <h1 className="page-header__title">客户管理</h1>
- <p className="page-header__desc">
- 统一管理客户资料、跟进状态与负责人,支持按名称、联系人或等级筛选。
- </p>
- </div>
- <div className="page-header__actions">
- <Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
- 新增客户
- </Button>
- </div>
- </div>
-
- <Card className="filter-card" variant="borderless">
- <div className="page-toolbar">
- <Input
- allowClear
- prefix={<SearchOutlined />}
- placeholder="搜索客户 / 联系人 / 负责人"
- value={keyword}
- onChange={(event) => setKeyword(event.target.value)}
- style={{ width: 280 }}
- />
- <Select<CustomerLevel | 'all'>
- value={level}
- onChange={setLevel}
- style={{ width: 140 }}
- options={[
- { value: 'all', label: '全部等级' },
- ...levelOptions.map((item) => ({ value: item, label: item })),
- ]}
- />
- <span className="text-muted">共 {filteredCustomers.length} 位客户</span>
- </div>
- </Card>
-
- <Card className="table-card" variant="borderless">
- <Table<CustomerRecord>
- rowKey="key"
- columns={columns}
- dataSource={filteredCustomers}
- scroll={{ x: 1000 }}
- pagination={{
- pageSize: 8,
- showSizeChanger: false,
- showTotal: (total) => `共 ${total} 条`,
- }}
- />
- </Card>
-
- <Modal
- title={editingCustomer ? '编辑客户' : '新增客户'}
- open={isModalOpen}
- onOk={handleSubmit}
- onCancel={closeModal}
- okText={editingCustomer ? '保存' : '创建'}
- cancelText="取消"
- width={560}
- forceRender
- >
- <Form form={form} layout="vertical" preserve={false}>
- <Form.Item
- name="name"
- label="客户名称"
- rules={[{ required: true, message: '请输入客户名称' }]}
- >
- <Input placeholder="请输入公司或客户名称" maxLength={50} />
- </Form.Item>
- <Row gutter={12}>
- <Col span={12}>
- <Form.Item
- name="industry"
- label="所属行业"
- rules={[{ required: true, message: '请输入所属行业' }]}
- >
- <Input placeholder="例如:企业服务" />
- </Form.Item>
- </Col>
- <Col span={12}>
- <Form.Item
- name="level"
- label="客户等级"
- rules={[{ required: true, message: '请选择客户等级' }]}
- >
- <Select
- options={levelOptions.map((item) => ({ value: item, label: item }))}
- />
- </Form.Item>
- </Col>
- </Row>
- <Row gutter={12}>
- <Col span={12}>
- <Form.Item
- name="contact"
- label="联系人"
- rules={[{ required: true, message: '请输入联系人' }]}
- >
- <Input placeholder="请输入联系人姓名" />
- </Form.Item>
- </Col>
- <Col span={12}>
- <Form.Item
- name="phone"
- label="联系电话"
- rules={[
- { required: true, message: '请输入联系电话' },
- { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
- ]}
- >
- <Input placeholder="请输入手机号" maxLength={11} />
- </Form.Item>
- </Col>
- </Row>
- <Form.Item
- name="owner"
- label="负责人"
- rules={[{ required: true, message: '请输入负责人' }]}
- >
- <Input placeholder="请输入负责人姓名" />
- </Form.Item>
- </Form>
- </Modal>
- </div>
- );
-};
-
-export default Customers;
diff --git a/src/pages/customers/list/columns.tsx b/src/pages/customers/list/columns.tsx
new file mode 100644
index 0000000..8184b97
--- /dev/null
+++ b/src/pages/customers/list/columns.tsx
@@ -0,0 +1,158 @@
+import { Tag } from 'antd';
+import type { ProColumns } from '@ant-design/pro-components';
+import type {
+ CustomerLevel,
+ CustomerStage,
+ CustomerType,
+ CustomerVO,
+} from './types';
+
+const customerTypeMeta: Record<CustomerType, { text: string; color: string }> = {
+ ENTERPRISE: { text: '企业', color: 'blue' },
+ PERSONAL: { text: '个人', color: 'default' },
+};
+
+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 }> = {
+ NEW_LEAD: { text: '新线索', color: 'default' },
+ PENDING_ASSIGN: { text: '待分配', color: 'orange' },
+ CONTACTING: { text: '沟通中', color: 'processing' },
+ NEEDS_COMM: { text: '需求沟通', color: 'cyan' },
+ QUOTING: { text: '报价中', color: 'blue' },
+ NEGOTIATING: { text: '谈判中', color: 'gold' },
+ DEAL: { text: '已成交', color: 'success' },
+ LOST: { text: '已流失', color: 'error' },
+};
+
+const renderTags = (tags?: string) => {
+ const items = tags?.split(/[,,]/).map((tag) => tag.trim()).filter(Boolean) ?? [];
+ if (!items.length) return '-';
+
+ return (
+ <>
+ {items.map((tag) => (
+ <Tag key={tag} bordered={false}>
+ {tag}
+ </Tag>
+ ))}
+ </>
+ );
+};
+
+export const customerColumns: ProColumns<CustomerVO>[] = [
+ {
+ title: '客户名称',
+ dataIndex: 'name',
+ width: 220,
+ ellipsis: true,
+ copyable: true,
+ search: false,
+ },
+ {
+ title: '客户类型',
+ dataIndex: 'customerType',
+ width: 100,
+ search: false,
+ render: (_value, record) => {
+ const meta = record.customerType ? customerTypeMeta[record.customerType] : null;
+ return meta ? (
+ <Tag color={meta.color} bordered={false}>
+ {meta.text}
+ </Tag>
+ ) : (
+ '-'
+ );
+ },
+ },
+ {
+ title: '所属行业',
+ dataIndex: 'industry',
+ width: 130,
+ ellipsis: true,
+ search: false,
+ render: (_value, record) => record.industry ?? '-',
+ },
+ {
+ title: '所在地区',
+ dataIndex: 'region',
+ width: 120,
+ ellipsis: true,
+ search: false,
+ render: (_value, record) => record.region ?? '-',
+ },
+ {
+ title: '联系人',
+ dataIndex: 'contactName',
+ width: 110,
+ search: false,
+ render: (_value, record) => record.contactName ?? '-',
+ },
+ {
+ title: '联系电话',
+ dataIndex: 'contactPhone',
+ width: 140,
+ search: false,
+ render: (_value, record) => record.contactPhone ?? '-',
+ },
+ {
+ title: '客户等级',
+ dataIndex: 'level',
+ width: 100,
+ search: false,
+ render: (_value, record) => {
+ const meta = record.level ? levelMeta[record.level] : null;
+ return meta ? (
+ <Tag color={meta.color} bordered={false}>
+ {meta.text}
+ </Tag>
+ ) : (
+ '-'
+ );
+ },
+ },
+ {
+ title: '客户阶段',
+ dataIndex: 'stage',
+ width: 110,
+ search: false,
+ render: (_value, record) => {
+ const meta = record.stage ? stageMeta[record.stage] : null;
+ return meta ? (
+ <Tag color={meta.color} bordered={false}>
+ {meta.text}
+ </Tag>
+ ) : (
+ '-'
+ );
+ },
+ },
+ {
+ title: '归属销售员',
+ dataIndex: 'ownerId',
+ width: 130,
+ valueType: 'digit',
+ tooltip: '按归属销售员 ID 筛选,不填表示全部',
+ fieldProps: { placeholder: '销售员 ID', min: 1, precision: 0 },
+ render: (_value, record) => (record.ownerId ? `#${record.ownerId}` : '-'),
+ },
+ {
+ title: '最近跟进',
+ dataIndex: 'lastFollowAt',
+ width: 170,
+ valueType: 'dateTime',
+ search: false,
+ },
+ {
+ title: '标签',
+ dataIndex: 'tags',
+ width: 180,
+ search: false,
+ render: (_value, record) => renderTags(record.tags),
+ },
+];
diff --git a/src/pages/customers/list/index.tsx b/src/pages/customers/list/index.tsx
new file mode 100644
index 0000000..c236f68
--- /dev/null
+++ b/src/pages/customers/list/index.tsx
@@ -0,0 +1,43 @@
+import { useMemo } from 'react';
+import { ProTable } from '@ant-design/pro-components';
+import { customerColumns } from './columns';
+import { getCustomerList } from './server';
+import type { CustomerListQuery, CustomerVO } from './types';
+
+const CustomerList = () => {
+ const columns = useMemo(() => customerColumns, []);
+
+ return (
+ <div className="customer-list-page">
+ <ProTable<CustomerVO, CustomerListQuery>
+ rowKey="id"
+ columns={columns}
+ cardProps={{ className: 'table-card' }}
+ search={{ labelWidth: 'auto', defaultCollapsed: false }}
+ scroll={{ x: 1510 }}
+ options={false}
+ pagination={{
+ defaultPageSize: 10,
+ showSizeChanger: true,
+ hideOnSinglePage: false,
+ showTotal: (total) => `共 ${total} 条`,
+ }}
+ request={async (params) => {
+ const result = await getCustomerList({
+ page: params.current,
+ size: params.pageSize,
+ ownerId: params.ownerId,
+ });
+
+ return {
+ data: result.records ?? [],
+ total: result.total ?? 0,
+ success: true,
+ };
+ }}
+ />
+ </div>
+ );
+};
+
+export default CustomerList;
diff --git a/src/pages/customers/list/server.ts b/src/pages/customers/list/server.ts
new file mode 100644
index 0000000..fe6a086
--- /dev/null
+++ b/src/pages/customers/list/server.ts
@@ -0,0 +1,12 @@
+import { getAuthHeaders } from '@/api/authHeaders';
+import { request } from '@/api/request';
+import type { TenantCustomerPageQuery, TenantCustomerPageResult } from './types';
+
+/** 查询本租户客户列表(只读)。 */
+export const getCustomerList = (params: TenantCustomerPageQuery) =>
+ request<TenantCustomerPageQuery, TenantCustomerPageResult>({
+ url: '/tenant/customers',
+ method: 'GET',
+ params,
+ headers: getAuthHeaders(),
+ });
diff --git a/src/pages/customers/list/types.ts b/src/pages/customers/list/types.ts
new file mode 100644
index 0000000..9b98499
--- /dev/null
+++ b/src/pages/customers/list/types.ts
@@ -0,0 +1,78 @@
+/** 客户类型。 */
+export type CustomerType = 'ENTERPRISE' | 'PERSONAL';
+
+/** 客户来源。 */
+export type CustomerSource =
+ | 'MINI_PROGRAM'
+ | 'SALES_INPUT'
+ | 'EXHIBITION'
+ | 'REFERRAL'
+ | 'WEBSITE';
+
+/** 客户等级。 */
+export type CustomerLevel = 'VIP' | 'A' | 'B' | 'C';
+
+/** 客户阶段。 */
+export type CustomerStage =
+ | 'NEW_LEAD'
+ | 'PENDING_ASSIGN'
+ | 'CONTACTING'
+ | 'NEEDS_COMM'
+ | 'QUOTING'
+ | 'NEGOTIATING'
+ | 'DEAL'
+ | 'LOST';
+
+/** 客户列表请求参数(TenantCustomerPageQuery)。 */
+export interface TenantCustomerPageQuery {
+ /** 页码。 */
+ page?: number;
+ /** 每页条数。 */
+ size?: number;
+ /** 归属销售员 ID,不填表示全部。 */
+ ownerId?: number;
+}
+
+/** 客户列表项(CustomerVO)。 */
+export interface CustomerVO {
+ id: number;
+ name: string;
+ customerType?: CustomerType;
+ industry?: string;
+ region?: string;
+ contactName?: string;
+ contactPhone?: string;
+ contactPosition?: string;
+ contactTel?: string;
+ email?: string;
+ address?: string;
+ demand?: string;
+ source?: CustomerSource;
+ level?: CustomerLevel;
+ tags?: string;
+ orgId?: number;
+ ownerId?: number;
+ stage?: CustomerStage;
+ lastFollowAt?: string;
+ nextFollowAt?: string;
+ dealAt?: string;
+ lostAt?: string;
+ lostReason?: string;
+}
+
+/** 通用分页响应(PageResult)。 */
+export interface PageResult<T> {
+ page: number;
+ size: number;
+ total: number;
+ totalPages: number;
+ records: T[];
+}
+
+/** 客户列表响应数据(ResultPageResultCustomerVO 的 data)。 */
+export type TenantCustomerPageResult = PageResult<CustomerVO>;
+
+/** ProTable 搜索表单参数,字段与请求参数中可筛选的字段保持一致。 */
+export interface CustomerListQuery {
+ ownerId?: number;
+}
diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx
index 41948f6..eebd63e 100644
--- a/src/pages/dashboard/index.tsx
+++ b/src/pages/dashboard/index.tsx
@@ -100,7 +100,7 @@
{
title: '客户管理',
description: '维护客户资料',
- path: '/business/customers',
+ path: '/customers',
icon: <ContactsOutlined />,
color: '#d97706',
background: '#fef3c7',
diff --git a/src/pages/system/roles/columns.tsx b/src/pages/system/roles/columns.tsx
new file mode 100644
index 0000000..5253c0b
--- /dev/null
+++ b/src/pages/system/roles/columns.tsx
@@ -0,0 +1,85 @@
+import { Button, Space, Tag } from 'antd';
+import type { ProColumns } from '@ant-design/pro-components';
+import type { RoleStatus, RoleType, RoleVO } from './types';
+
+export const roleTypeMeta: Record<RoleType, { text: string; color: string }> = {
+ PRESET: { text: '预置', color: 'blue' },
+ CUSTOM: { text: '自建', color: 'cyan' },
+};
+
+export const statusMeta: Record<RoleStatus, { text: string; color: string }> = {
+ ENABLED: { text: '启用', color: 'green' },
+ DISABLED: { text: '禁用', color: 'default' },
+ DELETED: { text: '已删除', color: 'red' },
+};
+
+export const createRoleColumns = (options: {
+ onDetail: (record: RoleVO) => void;
+ onToggle: (record: RoleVO) => void;
+}): ProColumns<RoleVO>[] => [
+ {
+ title: '角色名称',
+ dataIndex: 'roleName',
+ width: 180,
+ ellipsis: true,
+ fieldProps: { placeholder: '名称 / 编码' },
+ },
+ {
+ title: '角色编码',
+ dataIndex: 'roleCode',
+ width: 200,
+ ellipsis: true,
+ search: false,
+ render: (_value, record) => <Tag bordered={false}>{record.roleCode}</Tag>,
+ },
+ {
+ title: '描述',
+ dataIndex: 'description',
+ ellipsis: true,
+ search: false,
+ render: (_value, record) => record.description || '-',
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 110,
+ valueType: 'select',
+ valueEnum: {
+ ENABLED: { text: '启用' },
+ DISABLED: { text: '禁用' },
+ DELETED: { text: '已删除' },
+ },
+ render: (_value, record) => (
+ <Tag color={statusMeta[record.status].color} bordered={false}>
+ {statusMeta[record.status].text}
+ </Tag>
+ ),
+ },
+ {
+ title: '操作',
+ key: 'action',
+ valueType: 'option',
+ width: 140,
+ render: (_value, record) => {
+ const isEnabled = record.status === 'ENABLED';
+
+ return (
+ <Space size={0}>
+ <Button type="link" size="small" onClick={() => options.onDetail(record)}>
+ 详情
+ </Button>
+ {record.roleType === 'CUSTOM' && record.status !== 'DELETED' && (
+ <Button
+ type="link"
+ size="small"
+ danger={isEnabled}
+ onClick={() => options.onToggle(record)}
+ >
+ {isEnabled ? '禁用' : '启用'}
+ </Button>
+ )}
+ </Space>
+ );
+ },
+ },
+];
diff --git a/src/pages/system/roles/index.tsx b/src/pages/system/roles/index.tsx
new file mode 100644
index 0000000..694b1d6
--- /dev/null
+++ b/src/pages/system/roles/index.tsx
@@ -0,0 +1,126 @@
+import { useCallback, useMemo, useRef, useState } from 'react';
+import { ProTable } from '@ant-design/pro-components';
+import type { ActionType } from '@ant-design/pro-components';
+import { App as AntdApp, Descriptions, Drawer, Tag } from 'antd';
+import { createRoleColumns, roleTypeMeta, statusMeta } from './columns';
+import { getRoleDetail, getRoleList, toggleRoleStatus } from './server';
+import type { RoleListQuery, RoleVO } from './types';
+
+const Roles = () => {
+ const actionRef = useRef<ActionType>();
+ const { message, modal } = AntdApp.useApp();
+ const [detail, setDetail] = useState<RoleVO | null>(null);
+ const [detailOpen, setDetailOpen] = useState(false);
+ const [detailLoading, setDetailLoading] = useState(false);
+
+ const handleDetail = useCallback(async (record: RoleVO) => {
+ setDetailOpen(true);
+ setDetailLoading(true);
+
+ try {
+ const data = await getRoleDetail({ id: record.id });
+ setDetail(data);
+ } catch {
+ // 错误已由 request 统一提示
+ setDetailOpen(false);
+ } finally {
+ setDetailLoading(false);
+ }
+ }, []);
+
+ const handleToggle = useCallback(
+ (record: RoleVO) => {
+ const nextAction = record.status === 'ENABLED' ? '禁用' : '启用';
+
+ modal.confirm({
+ title: `${nextAction}角色`,
+ content: `确定要${nextAction}角色「${record.roleName}」吗?`,
+ okText: nextAction,
+ cancelText: '取消',
+ okButtonProps: { danger: record.status === 'ENABLED' },
+ onOk: async () => {
+ await toggleRoleStatus({ id: record.id });
+ message.success(`角色已${nextAction}`);
+ actionRef.current?.reload();
+ },
+ });
+ },
+ [message, modal],
+ );
+
+ const columns = useMemo(
+ () => createRoleColumns({ onDetail: handleDetail, onToggle: handleToggle }),
+ [handleDetail, handleToggle],
+ );
+
+ return (
+ <div className="role-page">
+ <ProTable<RoleVO, RoleListQuery>
+ rowKey="id"
+ actionRef={actionRef}
+ columns={columns}
+ cardProps={{ className: 'table-card' }}
+ search={{ labelWidth: 'auto', defaultCollapsed: false }}
+ scroll={{ x: 960 }}
+ options={false}
+ pagination={{
+ defaultPageSize: 10,
+ showSizeChanger: true,
+ hideOnSinglePage: false,
+ showTotal: (total) => `共 ${total} 条`,
+ }}
+ request={async (params) => {
+ const { current = 1, pageSize = 10, roleName, status } = params;
+ const keyword = roleName?.trim().toLowerCase();
+ const list = await getRoleList();
+ const filtered = list.filter((role) => {
+ const matchesKeyword =
+ !keyword ||
+ role.roleName.toLowerCase().includes(keyword) ||
+ role.roleCode.toLowerCase().includes(keyword);
+ const matchesStatus = !status || role.status === status;
+ return matchesKeyword && matchesStatus;
+ });
+ const start = (current - 1) * pageSize;
+
+ return {
+ data: filtered.slice(start, start + pageSize),
+ total: filtered.length,
+ success: true,
+ };
+ }}
+ />
+
+ <Drawer
+ title="角色详情"
+ width={460}
+ open={detailOpen}
+ loading={detailLoading}
+ onClose={() => {
+ setDetailOpen(false);
+ setDetail(null);
+ }}
+ >
+ {detail && (
+ <Descriptions column={1} bordered size="small">
+ <Descriptions.Item label="角色名称">{detail.roleName}</Descriptions.Item>
+ <Descriptions.Item label="角色编码">{detail.roleCode}</Descriptions.Item>
+ <Descriptions.Item label="角色类型">
+ <Tag color={roleTypeMeta[detail.roleType].color} bordered={false}>
+ {roleTypeMeta[detail.roleType].text}
+ </Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label="状态">
+ <Tag color={statusMeta[detail.status].color} bordered={false}>
+ {statusMeta[detail.status].text}
+ </Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label="描述">{detail.description || '-'}</Descriptions.Item>
+ </Descriptions>
+ )}
+ </Drawer>
+ </div>
+ );
+};
+
+export default Roles;
diff --git a/src/pages/system/roles/server.ts b/src/pages/system/roles/server.ts
new file mode 100644
index 0000000..e13cb97
--- /dev/null
+++ b/src/pages/system/roles/server.ts
@@ -0,0 +1,34 @@
+import { getAuthHeaders } from '@/api/authHeaders';
+import { request } from '@/api/request';
+import type {
+ TenantRoleDetailRequest,
+ TenantRoleDetailResponse,
+ TenantRoleListRequest,
+ TenantRoleListResponse,
+ TenantRoleToggleStatusRequest,
+ TenantRoleToggleStatusResponse,
+} from './types';
+
+/** 当前租户角色列表(含预置角色)。 */
+export const getRoleList = () =>
+ request<TenantRoleListRequest, TenantRoleListResponse>({
+ url: '/tenant/roles',
+ method: 'GET',
+ headers: getAuthHeaders(),
+ });
+
+/** 角色详情。 */
+export const getRoleDetail = ({ id }: TenantRoleDetailRequest) =>
+ request<TenantRoleDetailRequest, TenantRoleDetailResponse>({
+ url: `/tenant/roles/${id}`,
+ method: 'GET',
+ headers: getAuthHeaders(),
+ });
+
+/** 启用 / 停用自建角色。 */
+export const toggleRoleStatus = ({ id }: TenantRoleToggleStatusRequest) =>
+ request<TenantRoleToggleStatusRequest, TenantRoleToggleStatusResponse>({
+ url: `/tenant/roles/${id}/toggle-status`,
+ method: 'PUT',
+ headers: getAuthHeaders(),
+ });
diff --git a/src/pages/system/roles/types.ts b/src/pages/system/roles/types.ts
new file mode 100644
index 0000000..b8cebf1
--- /dev/null
+++ b/src/pages/system/roles/types.ts
@@ -0,0 +1,45 @@
+/** 角色类型。 */
+export type RoleType = 'PRESET' | 'CUSTOM';
+
+/** 角色状态。 */
+export type RoleStatus = 'ENABLED' | 'DISABLED' | 'DELETED';
+
+/** 角色响应体(RoleVO)。 */
+export interface RoleVO {
+ id: number;
+ roleName: string;
+ roleCode: string;
+ description?: string;
+ roleType: RoleType;
+ status: RoleStatus;
+}
+
+/** GET /tenant/roles 请求参数:该接口未定义查询参数。 */
+export type TenantRoleListRequest = Record<string, never>;
+
+/** GET /tenant/roles 响应数据(ResultListRoleVO 的 data)。 */
+export type TenantRoleListResponse = RoleVO[];
+
+/** GET /tenant/roles/{id} 请求参数。 */
+export interface TenantRoleDetailRequest {
+ /** 角色 ID(路径参数)。 */
+ id: number;
+}
+
+/** GET /tenant/roles/{id} 响应数据(ResultRoleVO 的 data)。 */
+export type TenantRoleDetailResponse = RoleVO;
+
+/** PUT /tenant/roles/{id}/toggle-status 请求参数。 */
+export interface TenantRoleToggleStatusRequest {
+ /** 角色 ID(路径参数)。 */
+ id: number;
+}
+
+/** PUT /tenant/roles/{id}/toggle-status 响应数据(ResultVoid 的 data)。 */
+export type TenantRoleToggleStatusResponse = void;
+
+/** ProTable 筛选参数。列表接口没有查询参数,这些字段仅用于前端本地筛选。 */
+export interface RoleListQuery {
+ roleName?: string;
+ status?: RoleStatus;
+}
diff --git a/src/router/routeConfig.tsx b/src/router/routeConfig.tsx
index 5a5fb6e..190fb86 100644
--- a/src/router/routeConfig.tsx
+++ b/src/router/routeConfig.tsx
@@ -4,12 +4,15 @@
ContactsOutlined,
DashboardOutlined,
ProfileOutlined,
- SettingOutlined,
+ SafetyCertificateOutlined,
+ SafetyOutlined,
TeamOutlined,
+ UnorderedListOutlined,
} from '@ant-design/icons';
-import Customers from '@/pages/business/customers';
import Orders from '@/pages/business/orders';
+import CustomerList from '@/pages/customers/list';
import Dashboard from '@/pages/dashboard';
+import Roles from '@/pages/system/roles';
import Users from '@/pages/system/users';
export interface AppRouteItem {
@@ -35,15 +38,34 @@
element: <Dashboard />,
},
{
+ path: '/customers',
+ title: '客户管理',
+ icon: <ContactsOutlined />,
+ children: [
+ {
+ path: '/customers/list',
+ title: '客户列表',
+ icon: <UnorderedListOutlined />,
+ element: <CustomerList />,
+ },
+ ],
+ },
+ {
path: '/system',
- title: '系统管理',
- icon: <SettingOutlined />,
+ title: '用户与权限',
+ icon: <SafetyCertificateOutlined />,
children: [
{
path: '/system/users',
title: '用户管理',
icon: <TeamOutlined />,
element: <Users />,
+ },
+ {
+ path: '/system/roles',
+ title: '角色管理',
+ icon: <SafetyOutlined />,
+ element: <Roles />,
},
],
},
@@ -57,12 +79,6 @@
title: '订单管理',
icon: <ProfileOutlined />,
element: <Orders />,
- },
- {
- path: '/business/customers',
- title: '客户管理',
- icon: <ContactsOutlined />,
- element: <Customers />,
},
],
},
--
Gitblit v1.9.3