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