e65a432f5c9fc11d15d53ae0e16e5f4b7d62400a..be8c526a454aa9081000d15f26bebdef8c183aea
5 days ago wsj
feat: 提交
be8c52 diff | tree
5 days ago wsj
feat: 用户管理接入员工管理接口
4a7848 diff | tree
5 days ago wsj
feat: 新增客户列表与角色管理页面
b09eb0 diff | tree
2 files deleted
9 files modified
14 files added
4412 ■■■■■ changed files
.claude/skills/new-page/SKILL.md 2 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/templates/non-table-page/index.tsx 7 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/templates/table-page/index.tsx 7 ●●●●● patch | view | raw | blame | history
CLAUDE.md 30 ●●●●● patch | view | raw | blame | history
README.md 9 ●●●●● patch | view | raw | blame | history
src/api/api-tenant.md 2013 ●●●●● patch | view | raw | blame | history
src/api/authHeaders.ts 7 ●●●●● patch | view | raw | blame | history
src/pages/business/customers/Customers.scss 57 ●●●●● patch | view | raw | blame | history
src/pages/business/customers/index.tsx 417 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/columns.tsx 158 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/index.tsx 43 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/server.ts 12 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/types.ts 71 ●●●●● patch | view | raw | blame | history
src/pages/dashboard/index.tsx 2 ●●●●● patch | view | raw | blame | history
src/pages/system/roles/columns.tsx 99 ●●●●● patch | view | raw | blame | history
src/pages/system/roles/index.tsx 125 ●●●●● patch | view | raw | blame | history
src/pages/system/roles/server.ts 34 ●●●●● patch | view | raw | blame | history
src/pages/system/roles/types.ts 45 ●●●●● patch | view | raw | blame | history
src/pages/system/users/columns.tsx 141 ●●●●● patch | view | raw | blame | history
src/pages/system/users/components/UserFormDrawer.tsx 211 ●●●●● patch | view | raw | blame | history
src/pages/system/users/index.tsx 599 ●●●●● patch | view | raw | blame | history
src/pages/system/users/server.ts 112 ●●●●● patch | view | raw | blame | history
src/pages/system/users/types.ts 167 ●●●●● patch | view | raw | blame | history
src/router/routeConfig.tsx 36 ●●●●● patch | view | raw | blame | history
src/types/api.ts 8 ●●●●● patch | view | raw | blame | history
.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` 通过
.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}
.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}
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。
README.md
@@ -51,8 +51,8 @@
```tsx
{
  path: '/system',
  title: '系统管理',
  icon: <SettingOutlined />,
  title: '用户与权限',
  icon: <SafetyCertificateOutlined />,
  children: [
    {
      path: '/system/users',
@@ -100,9 +100,10 @@
| 页面     | 路由                    | 说明                               |
| -------- | ----------------------- | ---------------------------------- |
| 工作台   | `/dashboard`            | 数据卡片、快捷入口、动态、最近订单 |
| 用户管理 | `/system/users`         | 搜索、新增 / 编辑 / 删除用户       |
| 用户管理 | `/system/users`         | 员工列表、详情、增删改、导入 / 导出 |
| 角色管理 | `/system/roles`         | 角色列表、详情、启用 / 停用        |
| 订单管理 | `/business/orders`      | 筛选、处理订单、导出               |
| 客户管理 | `/business/customers`   | 筛选、新增 / 编辑客户              |
| 客户列表 | `/customers/list`       | 接口数据,按归属销售员筛选         |
| 404      | 任意未知地址            | 页面不存在提示                     |
## 目录结构
src/api/api-tenant.md
@@ -1,1502 +1,599 @@
# SaaS 系统管理服务 API(租户版)
# SaaS 系统管理服务 API(租户管理服务)
> **版本**: 1.0.0  
> **描述**: 多租户 SaaS 平台 — 用户、角色、权限、租户管理接口
> **License**: Apache 2.0
> **Contact**: SaaS Platform Team
> **OpenAPI**: 3.0.1
> **Base Path**: `/`
> **认证方式**: Bearer JWT
> **许可证**: Apache 2.0
**描述**: 多租户 SaaS 平台 — 用户、角色、权限、租户管理接口
**Swagger 源地址**:
http://121.41.54.56:9090/webjars/swagger-ui/index.html?urls.primaryName=%E7%A7%9F%E6%88%B7%E7%AE%A1%E7%90%86%E6%9C%8D%E5%8A%A1
**OpenAPI JSON**:
http://121.41.54.56:9090/tenant/v3/api-docs
---
## 基本信息
## 通用说明
| 项目 | 说明 |
|------|------|
| Base URL | `http://121.41.54.56:9090` |
| OpenAPI | 3.0.1 |
| 认证方式 | Bearer JWT(Header: `Authorization: Bearer <token>`) |
| 文档源 | `/tenant/v3/api-docs` |
| Swagger UI | http://121.41.54.56:9090/webjars/swagger-ui/index.html?urls.primaryName=%E7%A7%9F%E6%88%B7%E7%AE%A1%E7%90%86%E6%9C%8D%E5%8A%A1 |
### 认证
---
所有接口默认需要在请求头中携带 JWT Token:
## 通用响应结构
几乎所有接口返回统一包装:
```json
{
  "code": 0,
  "message": "success",
  "data": { ... },
  "timestamp": 1690000000000,
  "success": true
}
```http
Authorization: Bearer <your-jwt-token>
```
### 统一响应结构
绝大多数接口返回统一的 `Result` 封装:
| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | integer | 业务状态码,通常 `0` 表示成功 |
| `code` | integer | 业务状态码 |
| `message` | string | 提示信息 |
| `data` | object | 业务数据(见各接口返回模型) |
| `timestamp` | long | 毫秒时间戳 |
| `data` | object / array / null | 业务数据 |
| `timestamp` | long | 时间戳 |
| `success` | boolean | 是否成功 |
### 分页结构 `PageResult`
| 字段 | 类型 | 说明 |
|------|------|------|
| `page` | long | 当前页 |
| `size` | long | 每页条数 |
| `total` | long | 总记录数 |
| `totalPages` | long | 总页数 |
| `records` | array | 数据列表 |
### 通用错误码
| HTTP Status | 说明 |
|-------------|------|
| 200 | 成功 |
| 400 | 请求参数错误 |
| 405 | 方法不允许 |
| 500 | 服务器内部错误 |
---
## 接口列表
### 租户角色管理
### 1. 员工管理
#### `GET` `/tenant/roles`
#### 1.1 员工分页列表
**当前租户角色列表(含预设角色)**
```http
GET /tenant/employees
```
- OperationId: `list`
**说明**: 按条件分页查询本租户员工。需 `TENANT_ADMIN` 或平台管理员权限。
**响应 (Response)**
**Query 参数** (`EmployeePageQuery`):
**`200` OK**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | integer | 否 | 页码 |
| size | integer | 否 | 每页条数 |
| sortBy | string | 否 | 排序字段 |
| sortDir | string | 否 | 排序方向 |
| orgId | long | 否 | 所属组织 ID |
| roleId | long | 否 | 角色 ID |
| status | string | 否 | 状态:`ACTIVE` / `DISABLED` |
| keyword | string | 否 | 关键词(姓名/手机号模糊匹配) |
返回包装类型: [`ResultListRoleVO`](#model-resultlistrolevo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: `array\<RoleVO\>`
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `GET` `/tenant/roles/{id}`
**获取角色详情**
- OperationId: `getById`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `id` | path | integer (int64) | 是 |  | 角色 id |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultRoleVO`](#model-resultrolevo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: [`RoleVO`](#model-rolevo)
> 角色响应体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `id` | integer (int64) | 否 | 主键 id |  |
| `createdAt` | string (date-time) | 否 | 创建时间 |  |
| `updatedAt` | string (date-time) | 否 | 更新时间 |  |
| `createdBy` | string | 否 | 创建人(操作人标识,如 Sa-Token loginId 字符串或 system) |  |
| `updatedBy` | string | 否 | 更新人 |  |
| `tenantId` | string | 否 | 所属租户 id(平台管理员等跨租户场景为 null) |  |
| `deleted` | integer (int32) | 否 | 软删除标记(0=未删除,1=已删除) |  |
| `roleName` | string | 否 | 角色名称 | 租户管理员 |
| `roleCode` | string | 否 | 角色编码(预置角色常量如 TENANT_ADMIN/PLATFORM_ADMIN;自建角色为自定义) | TENANT_ADMIN |
| `description` | string | 否 | 角色描述 | 企业管理者 |
| `roleType` | string<br>枚举: PRESET / CUSTOM | 否 | 角色类型(PRESET 预置,全租户共享 / CUSTOM 自建,本租户) | PRESET |
| `status` | string<br>枚举: ENABLED / DISABLED / DELETED | 否 | 状态(ENABLED 启用 / DISABLED 停用 / DELETED 已删除) | ENABLED |
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `PUT` `/tenant/roles/{id}/toggle-status`
**启用/停用角色**
- OperationId: `toggleStatus`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `id` | path | integer (int64) | 是 |  | 角色 id |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultVoid`](#model-resultvoid)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: `object`
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
### 组织管理
#### `POST` `/tenant/organizations`
**创建组织**
- OperationId: `create`
**请求体 (Request Body)**
Content-Type: `application/json`
类型: [`OrgCreateDTO`](#model-orgcreatedto)
> 创建组织请求体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `orgName` | string | 是 | 组织名称 | 华东分公司 |
| `orgType` | string<br>枚举: HQ / BRANCH / DEALER / AGENT / DEPT / TEAM | 否 | 组织类型(HQ 公司本部 / BRANCH 分公司 / DEALER 经销商 / AGENT 代理商 / DEPT 部门 / TEAM 团队);默认 DEPT | BRANCH |
| `parentId` | integer (int64) | 否 | 上级组织 id;根组织留空 |  |
| `leaderId` | integer (int64) | 否 | 负责人员工 id;可空 |  |
| `region` | string | 否 | 所在地区;可空 | 上海 |
| `sortOrder` | integer (int32) | 否 | 同级内显示排序(数字越小越靠前);可空 | 1 |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultOrgVO`](#model-resultorgvo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: [`OrgVO`](#model-orgvo)
> 组织响应体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `id` | integer (int64) | 否 | 主键 id |  |
| `createdAt` | string (date-time) | 否 | 创建时间 |  |
| `updatedAt` | string (date-time) | 否 | 更新时间 |  |
| `createdBy` | string | 否 | 创建人(操作人标识,如 Sa-Token loginId 字符串或 system) |  |
| `updatedBy` | string | 否 | 更新人 |  |
| `tenantId` | string | 否 | 所属租户 id(平台管理员等跨租户场景为 null) |  |
| `deleted` | integer (int32) | 否 | 软删除标记(0=未删除,1=已删除) |  |
| `orgName` | string | 否 | 组织名称 | 华东分公司 |
| `orgType` | string<br>枚举: HQ / BRANCH / DEALER / AGENT / DEPT / TEAM | 否 | 组织类型(HQ 公司本部 / BRANCH 分公司 / DEALER 经销商 / AGENT 代理商 / DEPT 部门 / TEAM 团队) | BRANCH |
| `parentId` | integer (int64) | 否 | 上级组织 id(根组织为 null) |  |
| `orgPath` | string | 否 | 组织路径(从根到本节点的全路径,如 /默认租户/华东分公司) |  |
| `leaderId` | integer (int64) | 否 | 负责人员工 id(可空) |  |
| `region` | string | 否 | 所在地区 | 上海 |
| `sortOrder` | integer (int32) | 否 | 同级内显示排序(数字越小越靠前) |  |
| `status` | boolean | 否 | 启用状态(true 启用 / false 停用) |  |
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `GET` `/tenant/organizations/tree`
**获取组织树**
- OperationId: `getTree`
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultListOrgVO`](#model-resultlistorgvo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: `array\<OrgVO\>`
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `PUT` `/tenant/organizations/{id}`
**更新组织**
- OperationId: `update`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `id` | path | integer (int64) | 是 |  | 组织 id |
**请求体 (Request Body)**
Content-Type: `application/json`
类型: [`OrgCreateDTO`](#model-orgcreatedto)
> 创建组织请求体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `orgName` | string | 是 | 组织名称 | 华东分公司 |
| `orgType` | string<br>枚举: HQ / BRANCH / DEALER / AGENT / DEPT / TEAM | 否 | 组织类型(HQ 公司本部 / BRANCH 分公司 / DEALER 经销商 / AGENT 代理商 / DEPT 部门 / TEAM 团队);默认 DEPT | BRANCH |
| `parentId` | integer (int64) | 否 | 上级组织 id;根组织留空 |  |
| `leaderId` | integer (int64) | 否 | 负责人员工 id;可空 |  |
| `region` | string | 否 | 所在地区;可空 | 上海 |
| `sortOrder` | integer (int32) | 否 | 同级内显示排序(数字越小越靠前);可空 | 1 |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultOrgVO`](#model-resultorgvo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: [`OrgVO`](#model-orgvo)
> 组织响应体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `id` | integer (int64) | 否 | 主键 id |  |
| `createdAt` | string (date-time) | 否 | 创建时间 |  |
| `updatedAt` | string (date-time) | 否 | 更新时间 |  |
| `createdBy` | string | 否 | 创建人(操作人标识,如 Sa-Token loginId 字符串或 system) |  |
| `updatedBy` | string | 否 | 更新人 |  |
| `tenantId` | string | 否 | 所属租户 id(平台管理员等跨租户场景为 null) |  |
| `deleted` | integer (int32) | 否 | 软删除标记(0=未删除,1=已删除) |  |
| `orgName` | string | 否 | 组织名称 | 华东分公司 |
| `orgType` | string<br>枚举: HQ / BRANCH / DEALER / AGENT / DEPT / TEAM | 否 | 组织类型(HQ 公司本部 / BRANCH 分公司 / DEALER 经销商 / AGENT 代理商 / DEPT 部门 / TEAM 团队) | BRANCH |
| `parentId` | integer (int64) | 否 | 上级组织 id(根组织为 null) |  |
| `orgPath` | string | 否 | 组织路径(从根到本节点的全路径,如 /默认租户/华东分公司) |  |
| `leaderId` | integer (int64) | 否 | 负责人员工 id(可空) |  |
| `region` | string | 否 | 所在地区 | 上海 |
| `sortOrder` | integer (int32) | 否 | 同级内显示排序(数字越小越靠前) |  |
| `status` | boolean | 否 | 启用状态(true 启用 / false 停用) |  |
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `DELETE` `/tenant/organizations/{id}`
**删除组织**
- OperationId: `delete`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `id` | path | integer (int64) | 是 |  | 组织 id |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultVoid`](#model-resultvoid)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: `object`
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `PUT` `/tenant/organizations/{id}/toggle-status`
**启用/停用组织**
- OperationId: `toggleStatus_1`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `id` | path | integer (int64) | 是 |  | 组织 id |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultVoid`](#model-resultvoid)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: `object`
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
### 员工管理
#### `GET` `/tenant/employees`
**员工分页列表**
- OperationId: `list_1`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `query` | query | EmployeePageQuery | 是 |  |  |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultPageResultEmployeeVO`](#model-resultpageresultemployeevo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: [`PageResultEmployeeVO`](#model-pageresultemployeevo)
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `page` | integer (int64) | 否 |  |  |
| `size` | integer (int64) | 否 |  |  |
| `total` | integer (int64) | 否 |  |  |
| `totalPages` | integer (int64) | 否 |  |  |
| `records` | array\<EmployeeVO\> | 否 |  |  |
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `POST` `/tenant/employees`
**创建员工**
- OperationId: `create_1`
**请求体 (Request Body)**
Content-Type: `application/json`
类型: [`EmployeeCreateDTO`](#model-employeecreatedto)
> 创建员工请求体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `name` | string | 是 | 员工姓名 | 张三 |
| `phone` | string | 是 | 登录/联系手机号(同租户内唯一建议) | 13800138000 |
| `orgId` | integer (int64) | 是 | 所属组织 id | 1 |
| `roleId` | integer (int64) | 是 | 角色 id(关联 crm_role.id;2 为 TENANT_ADMIN 预置角色) | 2 |
| `position` | string | 否 | 职位 | 销售主管 |
| `email` | string | 否 | 邮箱 | zhangsan@example.com |
| `avatarUrl` | string | 否 | 头像 URL |  |
| `holdLimit` | integer (int32) | 否 | 客户持有上限(可负责客户数) | 50 |
| `region` | string | 否 | 负责区域 | 华东 |
| `productTags` | string | 否 | 擅长产品标签(逗号分隔自由文本) | 汽车,零售 |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultEmployeeVO`](#model-resultemployeevo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: [`EmployeeVO`](#model-employeevo)
> 员工响应体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `id` | integer (int64) | 否 | 主键 id |  |
| `createdAt` | string (date-time) | 否 | 创建时间 |  |
| `updatedAt` | string (date-time) | 否 | 更新时间 |  |
| `createdBy` | string | 否 | 创建人(操作人标识,如 Sa-Token loginId 字符串或 system) |  |
| `updatedBy` | string | 否 | 更新人 |  |
| `tenantId` | string | 否 | 所属租户 id(平台管理员等跨租户场景为 null) |  |
| `deleted` | integer (int32) | 否 | 软删除标记(0=未删除,1=已删除) |  |
| `name` | string | 否 | 员工姓名 | 张三 |
| `phone` | string | 否 | 登录/联系手机号 | 13800138000 |
| `orgId` | integer (int64) | 否 | 所属组织 id |  |
| `roleId` | integer (int64) | 否 | 角色 id(关联 crm_role.id;为 2 即 TENANT_ADMIN 预置角色) |  |
| `position` | string | 否 | 职位 | 销售主管 |
| `email` | string | 否 | 邮箱 | zhangsan@example.com |
| `avatarUrl` | string | 否 | 头像 URL |  |
| `status` | string<br>枚举: ACTIVE / DISABLED | 否 | 状态(ACTIVE 在职 / DISABLED 停用) | ACTIVE |
| `holdLimit` | integer (int32) | 否 | 客户持有上限(可负责的客户数) |  |
| `region` | string | 否 | 负责区域 | 华东 |
| `productTags` | string | 否 | 擅长产品标签(逗号分隔自由文本) | 汽车,零售 |
| `hireDate` | string (date) | 否 | 入职日期 |  |
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `GET` `/tenant/employees/by-org/{orgId}`
**按组织查询员工列表**
- OperationId: `getByOrgId`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `orgId` | path | integer (int64) | 是 |  | 组织 id |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultListEmployeeVO`](#model-resultlistemployeevo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: `array\<EmployeeVO\>`
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `GET` `/tenant/employees/by-role/{roleId}`
**按角色查询员工列表**
- OperationId: `getByRoleId`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `roleId` | path | integer (int64) | 是 |  | 角色 id |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultListEmployeeVO`](#model-resultlistemployeevo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: `array\<EmployeeVO\>`
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `GET` `/tenant/employees/count-by-role/{roleId}`
**统计角色关联的员工数**
- OperationId: `countByRoleId`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `roleId` | path | integer (int64) | 是 |  | 角色 id |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultLong`](#model-resultlong)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: `integer (int64)`
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `GET` `/tenant/employees/export`
**导出员工(Excel)**
- OperationId: `exportEmployees`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `orgId` | query | integer (int64) | 否 |  | 按组织 id 过滤;留空=全部 |
**响应 (Response)**
- `200` OK
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `POST` `/tenant/employees/import`
**批量导入员工(Excel)**
- OperationId: `importEmployees`
**请求体 (Request Body)**
> ⚠️ 规范中可能标注为 `application/json` + binary,实际调用请使用 `multipart/form-data` 上传 Excel,字段名 `file`。
Content-Type: `multipart/form-data`
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `file` | string (binary) | 是 | Excel 文件(multipart 字段名 file) |  |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultInteger`](#model-resultinteger)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: `integer (int32)`
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `GET` `/tenant/employees/{id}`
**获取员工详情**
- OperationId: `getById_1`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `id` | path | integer (int64) | 是 |  | 员工 id |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultEmployeeVO`](#model-resultemployeevo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: [`EmployeeVO`](#model-employeevo)
> 员工响应体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `id` | integer (int64) | 否 | 主键 id |  |
| `createdAt` | string (date-time) | 否 | 创建时间 |  |
| `updatedAt` | string (date-time) | 否 | 更新时间 |  |
| `createdBy` | string | 否 | 创建人(操作人标识,如 Sa-Token loginId 字符串或 system) |  |
| `updatedBy` | string | 否 | 更新人 |  |
| `tenantId` | string | 否 | 所属租户 id(平台管理员等跨租户场景为 null) |  |
| `deleted` | integer (int32) | 否 | 软删除标记(0=未删除,1=已删除) |  |
| `name` | string | 否 | 员工姓名 | 张三 |
| `phone` | string | 否 | 登录/联系手机号 | 13800138000 |
| `orgId` | integer (int64) | 否 | 所属组织 id |  |
| `roleId` | integer (int64) | 否 | 角色 id(关联 crm_role.id;为 2 即 TENANT_ADMIN 预置角色) |  |
| `position` | string | 否 | 职位 | 销售主管 |
| `email` | string | 否 | 邮箱 | zhangsan@example.com |
| `avatarUrl` | string | 否 | 头像 URL |  |
| `status` | string<br>枚举: ACTIVE / DISABLED | 否 | 状态(ACTIVE 在职 / DISABLED 停用) | ACTIVE |
| `holdLimit` | integer (int32) | 否 | 客户持有上限(可负责的客户数) |  |
| `region` | string | 否 | 负责区域 | 华东 |
| `productTags` | string | 否 | 擅长产品标签(逗号分隔自由文本) | 汽车,零售 |
| `hireDate` | string (date) | 否 | 入职日期 |  |
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `PUT` `/tenant/employees/{id}`
**更新员工**
- OperationId: `update_1`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `id` | path | integer (int64) | 是 |  | 员工 id |
**请求体 (Request Body)**
Content-Type: `application/json`
类型: [`EmployeeUpdateDTO`](#model-employeeupdatedto)
> 编辑员工请求体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `name` | string | 否 | 员工姓名 | 张三 |
| `phone` | string | 否 | 登录/联系手机号 | 13800138000 |
| `orgId` | integer (int64) | 否 | 所属组织 id |  |
| `roleId` | integer (int64) | 否 | 角色 id(关联 crm_role.id) |  |
| `position` | string | 否 | 职位 | 销售主管 |
| `email` | string | 否 | 邮箱 | zhangsan@example.com |
| `holdLimit` | integer (int32) | 否 | 客户持有上限 | 50 |
| `region` | string | 否 | 负责区域 | 华东 |
| `productTags` | string | 否 | 擅长产品标签(逗号分隔自由文本) | 汽车,零售 |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultEmployeeVO`](#model-resultemployeevo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: [`EmployeeVO`](#model-employeevo)
> 员工响应体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `id` | integer (int64) | 否 | 主键 id |  |
| `createdAt` | string (date-time) | 否 | 创建时间 |  |
| `updatedAt` | string (date-time) | 否 | 更新时间 |  |
| `createdBy` | string | 否 | 创建人(操作人标识,如 Sa-Token loginId 字符串或 system) |  |
| `updatedBy` | string | 否 | 更新人 |  |
| `tenantId` | string | 否 | 所属租户 id(平台管理员等跨租户场景为 null) |  |
| `deleted` | integer (int32) | 否 | 软删除标记(0=未删除,1=已删除) |  |
| `name` | string | 否 | 员工姓名 | 张三 |
| `phone` | string | 否 | 登录/联系手机号 | 13800138000 |
| `orgId` | integer (int64) | 否 | 所属组织 id |  |
| `roleId` | integer (int64) | 否 | 角色 id(关联 crm_role.id;为 2 即 TENANT_ADMIN 预置角色) |  |
| `position` | string | 否 | 职位 | 销售主管 |
| `email` | string | 否 | 邮箱 | zhangsan@example.com |
| `avatarUrl` | string | 否 | 头像 URL |  |
| `status` | string<br>枚举: ACTIVE / DISABLED | 否 | 状态(ACTIVE 在职 / DISABLED 停用) | ACTIVE |
| `holdLimit` | integer (int32) | 否 | 客户持有上限(可负责的客户数) |  |
| `region` | string | 否 | 负责区域 | 华东 |
| `productTags` | string | 否 | 擅长产品标签(逗号分隔自由文本) | 汽车,零售 |
| `hireDate` | string (date) | 否 | 入职日期 |  |
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `DELETE` `/tenant/employees/{id}`
**删除员工**
- OperationId: `delete_1`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `id` | path | integer (int64) | 是 |  | 员工 id |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultVoid`](#model-resultvoid)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: `object`
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
### 客户管理(后台 · 只读)
#### `GET` `/tenant/customers`
**本租户客户列表(只读)**
- OperationId: `list_2`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `query` | query | TenantCustomerPageQuery | 是 |  |  |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultPageResultCustomerVO`](#model-resultpageresultcustomervo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: [`PageResultCustomerVO`](#model-pageresultcustomervo)
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `page` | integer (int64) | 否 |  |  |
| `size` | integer (int64) | 否 |  |  |
| `total` | integer (int64) | 否 |  |  |
| `totalPages` | integer (int64) | 否 |  |  |
| `records` | array\<CustomerVO\> | 否 |  |  |
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `GET` `/tenant/customers/{id}`
**客户详情(只读)**
- OperationId: `detail`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `id` | path | integer (int64) | 是 |  | 客户 id |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultCustomerVO`](#model-resultcustomervo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: [`CustomerVO`](#model-customervo)
> 客户响应体(后台 · 只读)
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `id` | integer (int64) | 否 | 主键 id |  |
| `createdAt` | string (date-time) | 否 | 创建时间 |  |
| `updatedAt` | string (date-time) | 否 | 更新时间 |  |
| `createdBy` | string | 否 | 创建人(操作人标识,如 Sa-Token loginId 字符串或 system) |  |
| `updatedBy` | string | 否 | 更新人 |  |
| `tenantId` | string | 否 | 所属租户 id(平台管理员等跨租户场景为 null) |  |
| `deleted` | integer (int32) | 否 | 软删除标记(0=未删除,1=已删除) |  |
| `name` | string | 否 | 客户名称(企业名或个人名) | 上海特斯拉 |
| `customerType` | string<br>枚举: ENTERPRISE / PERSONAL | 否 | 客户类型(ENTERPRISE 企业 / PERSONAL 个人) | ENTERPRISE |
| `industry` | string | 否 | 所属行业 | 汽车行业 |
| `region` | string | 否 | 所在地区 | 上海 |
| `contactName` | string | 否 | 主要联系人姓名 | 张经理 |
| `contactPhone` | string | 否 | 主要联系电话(手机) | 13800138000 |
| `contactPosition` | string | 否 | 主要联系人职位 | 采购经理 |
| `contactTel` | string | 否 | 备用/座机电话 | 021-12345678 |
| `email` | string | 否 | 客户邮箱 | contact@example.com |
| `address` | string | 否 | 客户地址 | 上海市浦东新区 |
| `source` | string<br>枚举: MINI_PROGRAM / SALES_INPUT / EXHIBITION / REFERRAL | 否 | 客户来源(小程序注册/销售录入/展会/转介绍) | SALES_INPUT |
| `level` | string<br>枚举: VIP / A / B / C | 否 | 客户级别(VIP/A/B/C) | VIP |
| `tags` | string | 否 | 标签(逗号分隔的自由文本) | 重点,高意向 |
| `orgId` | integer (int64) | 否 | 所属组织 id(可空) |  |
| `ownerId` | integer (int64) | 否 | 客户归属人(销售员)员工 id |  |
| `stage` | string<br>枚举: NEW_LEAD / PENDING_ASSIGN / CONTACTING / NEEDS_COMM / QUOTING / NEGOTIATING / DEAL / LOST | 否 | 当前客户阶段(NEW_LEAD新线索 / PENDING_ASSIGN待分配 / CONTACTING接洽中 / NEEDS_COMM需求沟通 / QUOTING报价中 / NEGOTIATING议价中 / DEAL成交 / LOST流失) | NEW_LEAD |
| `lastFollowAt` | string (date-time) | 否 | 最近一次跟进时间 |  |
| `nextFollowAt` | string (date-time) | 否 | 下次计划跟进时间 |  |
| `dealAt` | string (date-time) | 否 | 成交时间(阶段到 DEAL 时写入) |  |
| `lostAt` | string (date-time) | 否 | 流失时间(阶段到 LOST 时写入) |  |
| `lostReason` | string | 否 | 流失原因 |  |
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
#### `GET` `/tenant/customers/{id}/follow-ups`
**客户跟进历史(只读)**
- OperationId: `followUps`
**请求参数**
| 名称 | 位置 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|------|--------|------|
| `id` | path | integer (int64) | 是 |  | 客户 id |
| `query` | query | FollowUpPageQuery | 是 |  |  |
**响应 (Response)**
**`200` OK**
返回包装类型: [`ResultPageResultFollowUpVO`](#model-resultpageresultfollowupvo)
**统一包装字段:**
> 统一响应包装字段
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**`data` 业务字段:**
类型: [`PageResultFollowUpVO`](#model-pageresultfollowupvo)
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `page` | integer (int64) | 否 |  |  |
| `size` | integer (int64) | 否 |  |  |
| `total` | integer (int64) | 否 |  |  |
| `totalPages` | integer (int64) | 否 |  |  |
| `records` | array\<FollowUpVO\> | 否 |  |  |
- `400` Bad Request → [`ResultVoid`](#model-resultvoid)
- `405` Method Not Allowed → [`ResultVoid`](#model-resultvoid)
- `500` Internal Server Error → [`ResultVoid`](#model-resultvoid)
**响应**: `ResultPageResultEmployeeVO`
---
## 数据模型完整参考
#### 1.2 创建员工
> 接口章节已内联展示 Request / Response 字段说明,此处便于全局查阅。
```http
POST /tenant/employees
```
### EmployeeCreateDTO
**说明**: 在当前租户下创建员工,`tenant_id` 由服务端自动填充。
<a id="model-employeecreatedto"></a>
**请求体** (`EmployeeCreateDTO`):
> 创建员工请求体
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| name | string | ✅ | 员工姓名 |
| phone | string | ✅ | 登录/联系手机号(同租户建议唯一) |
| orgId | long | ✅ | 所属组织 ID |
| roleId | long | ✅ | 角色 ID(预置 `TENANT_ADMIN` = 2) |
| position | string | 否 | 职位 |
| email | string | 否 | 邮箱 |
| avatarUrl | string | 否 | 头像 URL |
| holdLimit | integer | 否 | 客户持有上限 |
| region | string | 否 | 负责区域 |
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `name` | string | 是 | 员工姓名 | 张三 |
| `phone` | string | 是 | 登录/联系手机号(同租户内唯一建议) | 13800138000 |
| `orgId` | integer (int64) | 是 | 所属组织 id | 1 |
| `roleId` | integer (int64) | 是 | 角色 id(关联 crm_role.id;2 为 TENANT_ADMIN 预置角色) | 2 |
| `position` | string | 否 | 职位 | 销售主管 |
| `email` | string | 否 | 邮箱 | zhangsan@example.com |
| `avatarUrl` | string | 否 | 头像 URL |  |
| `holdLimit` | integer (int32) | 否 | 客户持有上限(可负责客户数) | 50 |
| `region` | string | 否 | 负责区域 | 华东 |
| `productTags` | string | 否 | 擅长产品标签(逗号分隔自由文本) | 汽车,零售 |
### EmployeePageQuery
<a id="model-employeepagequery"></a>
> 员工分页查询条件
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `page` | integer (int32) | 否 |  |  |
| `size` | integer (int32) | 否 |  |  |
| `sortBy` | string | 否 |  |  |
| `sortDir` | string | 否 |  |  |
| `orgId` | integer (int64) | 否 | 按所属组织 id 过滤;留空=不按组织过滤 |  |
| `roleId` | integer (int64) | 否 | 按角色 id 过滤;留空=不按角色过滤 |  |
| `status` | string<br>枚举: ACTIVE / DISABLED | 否 | 按员工状态过滤(ACTIVE 在职 / DISABLED 停用);留空=全部 |  |
| `keyword` | string | 否 | 关键词(模糊匹配员工姓名/手机号等);可空 | 张三 |
| `offset` | integer (int32) | 否 |  |  |
### EmployeeUpdateDTO
<a id="model-employeeupdatedto"></a>
> 编辑员工请求体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `name` | string | 否 | 员工姓名 | 张三 |
| `phone` | string | 否 | 登录/联系手机号 | 13800138000 |
| `orgId` | integer (int64) | 否 | 所属组织 id |  |
| `roleId` | integer (int64) | 否 | 角色 id(关联 crm_role.id) |  |
| `position` | string | 否 | 职位 | 销售主管 |
| `email` | string | 否 | 邮箱 | zhangsan@example.com |
| `holdLimit` | integer (int32) | 否 | 客户持有上限 | 50 |
| `region` | string | 否 | 负责区域 | 华东 |
| `productTags` | string | 否 | 擅长产品标签(逗号分隔自由文本) | 汽车,零售 |
### FollowUpPageQuery
<a id="model-followuppagequery"></a>
> 客户跟进历史分页查询条件(后台只读)
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `page` | integer (int32) | 否 |  |  |
| `size` | integer (int32) | 否 |  |  |
| `sortBy` | string | 否 |  |  |
| `sortDir` | string | 否 |  |  |
| `offset` | integer (int32) | 否 |  |  |
### OrgCreateDTO
<a id="model-orgcreatedto"></a>
> 创建组织请求体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `orgName` | string | 是 | 组织名称 | 华东分公司 |
| `orgType` | string<br>枚举: HQ / BRANCH / DEALER / AGENT / DEPT / TEAM | 否 | 组织类型(HQ 公司本部 / BRANCH 分公司 / DEALER 经销商 / AGENT 代理商 / DEPT 部门 / TEAM 团队);默认 DEPT | BRANCH |
| `parentId` | integer (int64) | 否 | 上级组织 id;根组织留空 |  |
| `leaderId` | integer (int64) | 否 | 负责人员工 id;可空 |  |
| `region` | string | 否 | 所在地区;可空 | 上海 |
| `sortOrder` | integer (int32) | 否 | 同级内显示排序(数字越小越靠前);可空 | 1 |
### TenantCustomerPageQuery
<a id="model-tenantcustomerpagequery"></a>
> 本租户客户分页查询条件(后台只读)
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `page` | integer (int32) | 否 |  |  |
| `size` | integer (int32) | 否 |  |  |
| `sortBy` | string | 否 |  |  |
| `sortDir` | string | 否 |  |  |
| `ownerId` | integer (int64) | 否 | 按客户归属人(销售员)员工 id 过滤;留空=全部 |  |
| `offset` | integer (int32) | 否 |  |  |
### CustomerVO
<a id="model-customervo"></a>
> 客户响应体(后台 · 只读)
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `id` | integer (int64) | 否 | 主键 id |  |
| `createdAt` | string (date-time) | 否 | 创建时间 |  |
| `updatedAt` | string (date-time) | 否 | 更新时间 |  |
| `createdBy` | string | 否 | 创建人(操作人标识,如 Sa-Token loginId 字符串或 system) |  |
| `updatedBy` | string | 否 | 更新人 |  |
| `tenantId` | string | 否 | 所属租户 id(平台管理员等跨租户场景为 null) |  |
| `deleted` | integer (int32) | 否 | 软删除标记(0=未删除,1=已删除) |  |
| `name` | string | 否 | 客户名称(企业名或个人名) | 上海特斯拉 |
| `customerType` | string<br>枚举: ENTERPRISE / PERSONAL | 否 | 客户类型(ENTERPRISE 企业 / PERSONAL 个人) | ENTERPRISE |
| `industry` | string | 否 | 所属行业 | 汽车行业 |
| `region` | string | 否 | 所在地区 | 上海 |
| `contactName` | string | 否 | 主要联系人姓名 | 张经理 |
| `contactPhone` | string | 否 | 主要联系电话(手机) | 13800138000 |
| `contactPosition` | string | 否 | 主要联系人职位 | 采购经理 |
| `contactTel` | string | 否 | 备用/座机电话 | 021-12345678 |
| `email` | string | 否 | 客户邮箱 | contact@example.com |
| `address` | string | 否 | 客户地址 | 上海市浦东新区 |
| `source` | string<br>枚举: MINI_PROGRAM / SALES_INPUT / EXHIBITION / REFERRAL | 否 | 客户来源(小程序注册/销售录入/展会/转介绍) | SALES_INPUT |
| `level` | string<br>枚举: VIP / A / B / C | 否 | 客户级别(VIP/A/B/C) | VIP |
| `tags` | string | 否 | 标签(逗号分隔的自由文本) | 重点,高意向 |
| `orgId` | integer (int64) | 否 | 所属组织 id(可空) |  |
| `ownerId` | integer (int64) | 否 | 客户归属人(销售员)员工 id |  |
| `stage` | string<br>枚举: NEW_LEAD / PENDING_ASSIGN / CONTACTING / NEEDS_COMM / QUOTING / NEGOTIATING / DEAL / LOST | 否 | 当前客户阶段(NEW_LEAD新线索 / PENDING_ASSIGN待分配 / CONTACTING接洽中 / NEEDS_COMM需求沟通 / QUOTING报价中 / NEGOTIATING议价中 / DEAL成交 / LOST流失) | NEW_LEAD |
| `lastFollowAt` | string (date-time) | 否 | 最近一次跟进时间 |  |
| `nextFollowAt` | string (date-time) | 否 | 下次计划跟进时间 |  |
| `dealAt` | string (date-time) | 否 | 成交时间(阶段到 DEAL 时写入) |  |
| `lostAt` | string (date-time) | 否 | 流失时间(阶段到 LOST 时写入) |  |
| `lostReason` | string | 否 | 流失原因 |  |
### EmployeeVO
<a id="model-employeevo"></a>
> 员工响应体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `id` | integer (int64) | 否 | 主键 id |  |
| `createdAt` | string (date-time) | 否 | 创建时间 |  |
| `updatedAt` | string (date-time) | 否 | 更新时间 |  |
| `createdBy` | string | 否 | 创建人(操作人标识,如 Sa-Token loginId 字符串或 system) |  |
| `updatedBy` | string | 否 | 更新人 |  |
| `tenantId` | string | 否 | 所属租户 id(平台管理员等跨租户场景为 null) |  |
| `deleted` | integer (int32) | 否 | 软删除标记(0=未删除,1=已删除) |  |
| `name` | string | 否 | 员工姓名 | 张三 |
| `phone` | string | 否 | 登录/联系手机号 | 13800138000 |
| `orgId` | integer (int64) | 否 | 所属组织 id |  |
| `roleId` | integer (int64) | 否 | 角色 id(关联 crm_role.id;为 2 即 TENANT_ADMIN 预置角色) |  |
| `position` | string | 否 | 职位 | 销售主管 |
| `email` | string | 否 | 邮箱 | zhangsan@example.com |
| `avatarUrl` | string | 否 | 头像 URL |  |
| `status` | string<br>枚举: ACTIVE / DISABLED | 否 | 状态(ACTIVE 在职 / DISABLED 停用) | ACTIVE |
| `holdLimit` | integer (int32) | 否 | 客户持有上限(可负责的客户数) |  |
| `region` | string | 否 | 负责区域 | 华东 |
| `productTags` | string | 否 | 擅长产品标签(逗号分隔自由文本) | 汽车,零售 |
| `hireDate` | string (date) | 否 | 入职日期 |  |
### FollowUpVO
<a id="model-followupvo"></a>
> 跟进记录响应体(后台 · 只读)
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `id` | integer (int64) | 否 | 主键 id |  |
| `createdAt` | string (date-time) | 否 | 创建时间 |  |
| `updatedAt` | string (date-time) | 否 | 更新时间 |  |
| `createdBy` | string | 否 | 创建人(操作人标识,如 Sa-Token loginId 字符串或 system) |  |
| `updatedBy` | string | 否 | 更新人 |  |
| `tenantId` | string | 否 | 所属租户 id(平台管理员等跨租户场景为 null) |  |
| `deleted` | integer (int32) | 否 | 软删除标记(0=未删除,1=已删除) |  |
| `customerId` | integer (int64) | 否 | 关联客户 id |  |
| `followerId` | integer (int64) | 否 | 跟进人(销售员)员工 id |  |
| `followAt` | string (date-time) | 否 | 本次跟进实际发生时间 |  |
| `followMethod` | string<br>枚举: PHONE / WECHAT / VISIT / EMAIL / MEETING | 否 | 跟进方式(PHONE 电话 / WECHAT 微信 / VISIT 上门拜访 / EMAIL 邮件 / MEETING 会议) | PHONE |
| `content` | string | 否 | 跟进内容/沟通纪要 |  |
| `nextFollowAt` | string (date-time) | 否 | 下次计划跟进时间(可空) |  |
| `nextFollowPlan` | string | 否 | 下次跟进计划/备注(可空) |  |
| `followStatus` | string<br>枚举: DONE / PENDING / OVERDUE | 否 | 跟进状态(DONE 已完成 / PENDING 待跟进 / OVERDUE 已超时) | DONE |
### OrgVO
<a id="model-orgvo"></a>
> 组织响应体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `id` | integer (int64) | 否 | 主键 id |  |
| `createdAt` | string (date-time) | 否 | 创建时间 |  |
| `updatedAt` | string (date-time) | 否 | 更新时间 |  |
| `createdBy` | string | 否 | 创建人(操作人标识,如 Sa-Token loginId 字符串或 system) |  |
| `updatedBy` | string | 否 | 更新人 |  |
| `tenantId` | string | 否 | 所属租户 id(平台管理员等跨租户场景为 null) |  |
| `deleted` | integer (int32) | 否 | 软删除标记(0=未删除,1=已删除) |  |
| `orgName` | string | 否 | 组织名称 | 华东分公司 |
| `orgType` | string<br>枚举: HQ / BRANCH / DEALER / AGENT / DEPT / TEAM | 否 | 组织类型(HQ 公司本部 / BRANCH 分公司 / DEALER 经销商 / AGENT 代理商 / DEPT 部门 / TEAM 团队) | BRANCH |
| `parentId` | integer (int64) | 否 | 上级组织 id(根组织为 null) |  |
| `orgPath` | string | 否 | 组织路径(从根到本节点的全路径,如 /默认租户/华东分公司) |  |
| `leaderId` | integer (int64) | 否 | 负责人员工 id(可空) |  |
| `region` | string | 否 | 所在地区 | 上海 |
| `sortOrder` | integer (int32) | 否 | 同级内显示排序(数字越小越靠前) |  |
| `status` | boolean | 否 | 启用状态(true 启用 / false 停用) |  |
### ResultListRoleVO
<a id="model-resultlistrolevo"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `data` | array\<RoleVO\> | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
### ResultRoleVO
<a id="model-resultrolevo"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `data` | RoleVO | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
### RoleVO
<a id="model-rolevo"></a>
> 角色响应体
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `id` | integer (int64) | 否 | 主键 id |  |
| `createdAt` | string (date-time) | 否 | 创建时间 |  |
| `updatedAt` | string (date-time) | 否 | 更新时间 |  |
| `createdBy` | string | 否 | 创建人(操作人标识,如 Sa-Token loginId 字符串或 system) |  |
| `updatedBy` | string | 否 | 更新人 |  |
| `tenantId` | string | 否 | 所属租户 id(平台管理员等跨租户场景为 null) |  |
| `deleted` | integer (int32) | 否 | 软删除标记(0=未删除,1=已删除) |  |
| `roleName` | string | 否 | 角色名称 | 租户管理员 |
| `roleCode` | string | 否 | 角色编码(预置角色常量如 TENANT_ADMIN/PLATFORM_ADMIN;自建角色为自定义) | TENANT_ADMIN |
| `description` | string | 否 | 角色描述 | 企业管理者 |
| `roleType` | string<br>枚举: PRESET / CUSTOM | 否 | 角色类型(PRESET 预置,全租户共享 / CUSTOM 自建,本租户) | PRESET |
| `status` | string<br>枚举: ENABLED / DISABLED / DELETED | 否 | 状态(ENABLED 启用 / DISABLED 停用 / DELETED 已删除) | ENABLED |
### PageResultCustomerVO
<a id="model-pageresultcustomervo"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `page` | integer (int64) | 否 |  |  |
| `size` | integer (int64) | 否 |  |  |
| `total` | integer (int64) | 否 |  |  |
| `totalPages` | integer (int64) | 否 |  |  |
| `records` | array\<CustomerVO\> | 否 |  |  |
### PageResultEmployeeVO
<a id="model-pageresultemployeevo"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `page` | integer (int64) | 否 |  |  |
| `size` | integer (int64) | 否 |  |  |
| `total` | integer (int64) | 否 |  |  |
| `totalPages` | integer (int64) | 否 |  |  |
| `records` | array\<EmployeeVO\> | 否 |  |  |
### PageResultFollowUpVO
<a id="model-pageresultfollowupvo"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `page` | integer (int64) | 否 |  |  |
| `size` | integer (int64) | 否 |  |  |
| `total` | integer (int64) | 否 |  |  |
| `totalPages` | integer (int64) | 否 |  |  |
| `records` | array\<FollowUpVO\> | 否 |  |  |
### ResultCustomerVO
<a id="model-resultcustomervo"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `data` | CustomerVO | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
### ResultEmployeeVO
<a id="model-resultemployeevo"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `data` | EmployeeVO | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
### ResultInteger
<a id="model-resultinteger"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `data` | integer (int32) | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
### ResultListEmployeeVO
<a id="model-resultlistemployeevo"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `data` | array\<EmployeeVO\> | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
### ResultListOrgVO
<a id="model-resultlistorgvo"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `data` | array\<OrgVO\> | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
### ResultLong
<a id="model-resultlong"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `data` | integer (int64) | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
### ResultOrgVO
<a id="model-resultorgvo"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `data` | OrgVO | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
### ResultPageResultCustomerVO
<a id="model-resultpageresultcustomervo"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `data` | PageResultCustomerVO | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
### ResultPageResultEmployeeVO
<a id="model-resultpageresultemployeevo"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `data` | PageResultEmployeeVO | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
### ResultPageResultFollowUpVO
<a id="model-resultpageresultfollowupvo"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `data` | PageResultFollowUpVO | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
### ResultVoid
<a id="model-resultvoid"></a>
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `code` | integer (int32) | 否 |  |  |
| `message` | string | 否 |  |  |
| `data` | object | 否 |  |  |
| `timestamp` | integer (int64) | 否 |  |  |
| `success` | boolean | 否 |  |  |
**响应**: `ResultEmployeeVO`
---
## 枚举值汇总
#### 1.3 获取员工详情
| 模型.字段 | 枚举值 | 说明 |
|-----------|--------|------|
| `CustomerVO.customerType` | `ENTERPRISE` · `PERSONAL` | 客户类型(ENTERPRISE 企业 / PERSONAL 个人) |
| `CustomerVO.level` | `VIP` · `A` · `B` · `C` | 客户级别(VIP/A/B/C) |
| `CustomerVO.source` | `MINI_PROGRAM` · `SALES_INPUT` · `EXHIBITION` · `REFERRAL` | 客户来源(小程序注册/销售录入/展会/转介绍) |
| `CustomerVO.stage` | `NEW_LEAD` · `PENDING_ASSIGN` · `CONTACTING` · `NEEDS_COMM` · `QUOTING` · `NEGOTIATING` · `DEAL` · `LOST` | 当前客户阶段(NEW_LEAD新线索 / PENDING_ASSIGN待分配 / CONTACTING接洽中 / NEEDS_COMM需求沟通 / QUOTING报价中 / NEGOTIATING议价中 / DEAL成交 / LOST流失) |
| `EmployeePageQuery.status` | `ACTIVE` · `DISABLED` | 按员工状态过滤(ACTIVE 在职 / DISABLED 停用);留空=全部 |
| `EmployeeVO.status` | `ACTIVE` · `DISABLED` | 状态(ACTIVE 在职 / DISABLED 停用) |
| `FollowUpVO.followMethod` | `PHONE` · `WECHAT` · `VISIT` · `EMAIL` · `MEETING` | 跟进方式(PHONE 电话 / WECHAT 微信 / VISIT 上门拜访 / EMAIL 邮件 / MEETING 会议) |
| `FollowUpVO.followStatus` | `DONE` · `PENDING` · `OVERDUE` | 跟进状态(DONE 已完成 / PENDING 待跟进 / OVERDUE 已超时) |
| `OrgCreateDTO.orgType` | `HQ` · `BRANCH` · `DEALER` · `AGENT` · `DEPT` · `TEAM` | 组织类型(HQ 公司本部 / BRANCH 分公司 / DEALER 经销商 / AGENT 代理商 / DEPT 部门 / TEAM 团队);默认 DEPT |
| `OrgVO.orgType` | `HQ` · `BRANCH` · `DEALER` · `AGENT` · `DEPT` · `TEAM` | 组织类型(HQ 公司本部 / BRANCH 分公司 / DEALER 经销商 / AGENT 代理商 / DEPT 部门 / TEAM 团队) |
| `RoleVO.roleType` | `PRESET` · `CUSTOM` | 角色类型(PRESET 预置,全租户共享 / CUSTOM 自建,本租户) |
| `RoleVO.status` | `ENABLED` · `DISABLED` · `DELETED` | 状态(ENABLED 启用 / DISABLED 停用 / DELETED 已删除) |
```http
GET /tenant/employees/{id}
```
**路径参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| id | long | 员工 ID |
**响应**: `ResultEmployeeVO`
---
## 注意事项
#### 1.4 更新员工
1. **认证**:所有接口需在 Header 中携带 `Authorization: Bearer <JWT>`。
2. **多租户**:路径统一以 `/tenant/` 开头,租户上下文一般从 JWT 解析。
3. **统一响应**:请以 `success` / `code` 判断业务结果,不要只依赖 HTTP 状态码。
4. **分页**:分页参数默认 `page=1`,`size=20`。
5. **员工导入**:实际请使用 `multipart/form-data` 上传 Excel,字段名 `file`。
6. **员工导出**:返回 Excel 文件流。
7. **字段说明**:Request / Response 字段的「说明」「示例」均来自 OpenAPI `description` 与 `example`。
```http
PUT /tenant/employees/{id}
```
**说明**: 更新员工基本信息(不包含 password / avatarUrl / hireDate)。
**请求体** (`EmployeeUpdateDTO`):
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| name | string | 否 | 员工姓名 |
| phone | string | 否 | 登录/联系手机号 |
| orgId | long | 否 | 所属组织 ID |
| roleId | long | 否 | 角色 ID |
| position | string | 否 | 职位 |
| email | string | 否 | 邮箱 |
| holdLimit | integer | 否 | 客户持有上限 |
| region | string | 否 | 负责区域 |
**响应**: `ResultEmployeeVO`
---
*文档由 OpenAPI 规范自动生成(租户版)
源地址: `http://121.41.54.56:9090/tenant/v3/api-docs`*
#### 1.5 删除员工
```http
DELETE /tenant/employees/{id}
```
**路径参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| id | long | 员工 ID |
**响应**: `ResultVoid`
---
#### 1.6 按组织查员工分页
```http
GET /tenant/employees/by-org/{orgId}
```
**说明**: 分页返回指定组织下的**直接**员工(不含子孙组织)。
**路径参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| orgId | long | 组织 ID |
**Query 参数** (`EmployeeSubListQuery`): page / size / sortBy / sortDir
**响应**: `ResultPageResultEmployeeVO`
---
#### 1.7 按角色查员工分页
```http
GET /tenant/employees/by-role/{roleId}
```
**路径参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| roleId | long | 角色 ID |
**Query 参数** (`EmployeeSubListQuery`): page / size / sortBy / sortDir
**响应**: `ResultPageResultEmployeeVO`
---
#### 1.8 统计角色关联的员工数
```http
GET /tenant/employees/count-by-role/{roleId}
```
**说明**: 返回该角色下本租户员工数,可用于删除角色前校验。
**响应**: `ResultLong`
---
#### 1.9 导出员工(Excel)
```http
GET /tenant/employees/export
```
**说明**: 导出员工为 Excel 文件流(非标准 Result 封装)。
**Query 参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| orgId | long | 否 | 不填导出全部,填则仅导出指定组织 |
**响应**: Excel 文件流
---
#### 1.10 批量导入员工(Excel)
```http
POST /tenant/employees/import
```
**说明**: 上传 Excel 文件批量创建员工。
**请求**: `multipart/form-data`,字段名 `file`
**响应**: `ResultInteger`(成功导入行数)
---
### 2. 组织管理
#### 2.1 创建组织
```http
POST /tenant/organizations
```
**说明**: 创建组织节点。`orgPath` 由服务端根据 `parentId` 自动生成,`status` 初始为 `true`。
**请求体** (`OrgCreateDTO`):
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| orgName | string | ✅ | 组织名称 |
| orgType | string | 否 | 类型:`HQ` / `BRANCH` / `DEALER` / `AGENT` / `DEPT` / `TEAM`(默认 `DEPT`) |
| parentId | long | 否 | 上级组织 ID(根组织留空) |
| leaderId | long | 否 | 负责人员工 ID |
| region | string | 否 | 所在地区 |
| sortOrder | integer | 否 | 同级排序(数字越小越靠前) |
**响应**: `ResultOrgVO`
---
#### 2.2 获取组织树
```http
GET /tenant/organizations/tree
```
**说明**: 返回当前租户组织嵌套树。需 `TENANT_ADMIN` 或平台管理员。
**Query 参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| orgId | long | 否 | 传入则返回以该节点为根的子树;不传返回所有根组织 |
**响应**: `ResultListOrgVO`
---
#### 2.3 更新组织
```http
PUT /tenant/organizations/{id}
```
**说明**: 更新组织基本信息。`parentId` 变更后 `orgPath` 由服务端重算。
**请求体**: `OrgCreateDTO`(同创建)
**响应**: `ResultOrgVO`
---
#### 2.4 删除组织
```http
DELETE /tenant/organizations/{id}
```
**说明**: 删除组织。若存在子组织可能被拒绝。
**响应**: `ResultVoid`
---
#### 2.5 启用/停用组织
```http
PUT /tenant/organizations/{id}/toggle-status
```
**说明**: 在启用/停用之间切换。
**响应**: `ResultVoid`
---
### 3. 租户角色管理
#### 3.1 当前租户角色列表(含预设角色)
```http
GET /tenant/roles
```
**说明**: 返回本租户自建角色 + SYSTEM 全租户共享的预置角色(如 `TENANT_ADMIN` / `PLATFORM_ADMIN`)。预置角色置前。需 `TENANT_ADMIN` 登录。
**响应**: `ResultListRoleVO`
---
#### 3.2 获取角色详情
```http
GET /tenant/roles/{id}
```
**路径参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| id | long | 角色 ID |
**响应**: `ResultRoleVO`
---
#### 3.3 启用/停用角色
```http
PUT /tenant/roles/{id}/toggle-status
```
**说明**:
- 仅允许租户管理员操作本租户的**自建角色**
- 预置角色(`roleType=PRESET`)及跨租户角色会被拒绝(403)
**响应**: `ResultVoid`
---
### 4. 客户管理(后台 · 只读)
#### 4.1 本租户客户列表(只读)
```http
GET /tenant/customers
```
**说明**: 分页返回本租户所有客户(只读)。`ownerId` 不填=全部。
**Query 参数** (`TenantCustomerPageQuery`):
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | integer | 否 | 页码 |
| size | integer | 否 | 每页条数 |
| ownerId | long | 否 | 按归属销售员过滤 |
**响应**: `ResultPageResultCustomerVO`
---
#### 4.2 客户详情(只读)
```http
GET /tenant/customers/{id}
```
**说明**: 租户管理员可见本租户任意客户(未做归属校验)。
**响应**: `ResultCustomerVO`
---
#### 4.3 客户跟进历史(只读)
```http
GET /tenant/customers/{id}/follow-ups
```
**说明**: 分页返回该客户的跟进历史,按 `followAt` 倒序。
**Query 参数** (`FollowUpPageQuery`): page / size / sortBy / sortDir
**响应**: `ResultPageResultFollowUpVO`
---
### 5. 审计日志(租户端)
#### 5.1 本租户审计日志分页查询
```http
GET /tenant/audit-logs
```
**说明**: 强制按当前登录租户查询。需 `TENANT_ADMIN` 或平台管理员。
**Query 参数** (`AuditLogPageQuery`):
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | integer | 否 | 页码 |
| size | integer | 否 | 每页条数 |
| module | string | 否 | 模块过滤(EMPLOYEE / ORG / ROLE / CUSTOMER / FOLLOW_UP / TENANT / SYSTEM 等) |
| action | string | 否 | 操作动作(CREATE / UPDATE / DELETE / IMPORT 等) |
| entityType | string | 否 | 实体类型 |
| resultStatus | string | 否 | 结果状态(SUCCESS / FAILURE) |
| operatorId | long | 否 | 操作人 ID |
| start | date-time | 否 | 开始时间(yyyy-MM-dd HH:mm) |
| end | date-time | 否 | 结束时间(yyyy-MM-dd HH:mm) |
**响应**: `ResultPageResultAuditLogVO`
---
## 数据模型(Schemas)
### EmployeeVO(员工响应体)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | long | 主键 |
| createdAt | date-time | 创建时间 |
| updatedAt | date-time | 更新时间 |
| createdBy | string | 创建人 |
| updatedBy | string | 更新人 |
| tenantId | string | 所属租户 ID |
| deleted | integer | 软删除标记(0=未删除,1=已删除) |
| name | string | 员工姓名 |
| phone | string | 手机号 |
| orgId | long | 所属组织 ID |
| roleId | long | 角色 ID |
| position | string | 职位 |
| email | string | 邮箱 |
| avatarUrl | string | 头像 URL |
| status | string | `ACTIVE` / `DISABLED` |
| holdLimit | integer | 客户持有上限 |
| region | string | 负责区域 |
| hireDate | date | 入职日期 |
---
### OrgVO(组织响应体)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | long | 主键 |
| orgName | string | 组织名称 |
| orgType | string | `HQ` / `BRANCH` / `DEALER` / `AGENT` / `DEPT` / `TEAM` |
| parentId | long | 上级组织 ID |
| orgPath | string | 组织路径(如 `/默认租户/华东分公司`) |
| leaderId | long | 负责人 ID |
| region | string | 所在地区 |
| sortOrder | integer | 排序 |
| status | boolean | 启用状态 |
| children | array\<OrgVO\> | 直接子节点列表 |
---
### RoleVO(角色响应体)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | long | 主键 |
| roleName | string | 角色名称 |
| roleCode | string | 角色编码(预置如 `TENANT_ADMIN`) |
| description | string | 描述 |
| roleType | string | `PRESET`(预置) / `CUSTOM`(自建) |
| status | string | `ENABLED` / `DISABLED` / `DELETED` |
---
### CustomerVO(客户响应体)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | long | 主键 |
| name | string | 客户名称 |
| customerType | string | `ENTERPRISE` / `PERSONAL` |
| industry | string | 所属行业 |
| region | string | 所在地区 |
| contactName | string | 主要联系人 |
| contactPhone | string | 主要联系电话 |
| contactPosition | string | 职位 |
| contactTel | string | 备用电话 |
| email | string | 邮箱 |
| address | string | 地址 |
| demand | string | 需求描述 |
| source | string | `MINI_PROGRAM` / `SALES_INPUT` / `EXHIBITION` / `REFERRAL` / `WEBSITE` |
| level | string | `VIP` / `A` / `B` / `C` |
| tags | string | 标签(逗号分隔) |
| orgId | long | 所属组织 |
| ownerId | long | 归属销售员 ID |
| stage | string | `NEW_LEAD` / `PENDING_ASSIGN` / `CONTACTING` / `NEEDS_COMM` / `QUOTING` / `NEGOTIATING` / `DEAL` / `LOST` |
| lastFollowAt | date-time | 最近跟进时间 |
| nextFollowAt | date-time | 下次计划跟进时间 |
| dealAt | date-time | 成交时间 |
| lostAt | date-time | 流失时间 |
| lostReason | string | 流失原因 |
---
### FollowUpVO(跟进记录响应体)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | long | 主键 |
| customerId | long | 关联客户 ID |
| followerId | long | 跟进人 ID |
| followAt | date-time | 跟进实际时间 |
| followMethod | string | `PHONE` / `WECHAT` / `VISIT` / `EMAIL` / `MEETING` |
| content | string | 跟进内容 |
| nextFollowAt | date-time | 下次计划时间 |
| nextFollowPlan | string | 下次计划备注 |
| followStatus | string | `DONE` / `PENDING` / `OVERDUE` |
| recordType | string | `FOLLOW` / `PLAN` / `STAGE_CHANGE` |
| stageFrom | string | 阶段变化来源 |
| stageTo | string | 阶段变化目标 |
---
### AuditLogVO(审计日志响应体)
| 字段 | 类型 | 说明 |
|------|------|------|
| logId | long | 日志主键 |
| tenantId | string | 租户 ID |
| operatorId | long | 操作人 ID |
| operatorName | string | 操作人姓名 |
| operatorRole | string | 操作人角色 |
| module | string | 业务模块 |
| action | string | 操作动作 |
| operationDesc | string | 操作描述(中文摘要) |
| entityType | string | 实体类型 |
| entityId | string | 实体 ID |
| entityName | string | 实体名称 |
| changeDetail | string | 变更明细 |
| requestIp | string | 请求 IP |
| userAgent | string | User-Agent |
| requestPath | string | 请求路径 |
| requestMethod | string | 请求方法 |
| resultStatus | string | `SUCCESS` / `FAILURE` |
| errorMessage | string | 失败错误消息 |
| createdAt | date-time | 创建时间 |
---
## 权限说明
| 角色/权限 | 可访问接口 |
|-----------|------------|
| TENANT_ADMIN | 本租户员工、组织、角色、客户(只读)、审计日志 |
| 平台管理员 | 以上所有 + 跨租户能力 |
| 普通员工 | 根据业务接口权限控制 |
---
*文档根据 OpenAPI 规范自动生成,内容以实际接口返回为准。*
src/api/authHeaders.ts
New file
@@ -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;
};
src/pages/business/customers/Customers.scss
File was deleted
src/pages/business/customers/index.tsx
File was deleted
src/pages/customers/list/columns.tsx
New file
@@ -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),
  },
];
src/pages/customers/list/index.tsx
New file
@@ -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;
src/pages/customers/list/server.ts
New file
@@ -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(),
  });
src/pages/customers/list/types.ts
New file
@@ -0,0 +1,71 @@
import type { PageResult } from '@/types/api';
/** 客户类型。 */
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;
}
/** 客户列表响应数据(ResultPageResultCustomerVO 的 data)。 */
export type TenantCustomerPageResult = PageResult<CustomerVO>;
/** ProTable 搜索表单参数,字段与请求参数中可筛选的字段保持一致。 */
export interface CustomerListQuery {
  ownerId?: number;
}
src/pages/dashboard/index.tsx
@@ -100,7 +100,7 @@
  {
    title: '客户管理',
    description: '维护客户资料',
    path: '/business/customers',
    path: '/customers',
    icon: <ContactsOutlined />,
    color: '#d97706',
    background: '#fef3c7',
src/pages/system/roles/columns.tsx
New file
@@ -0,0 +1,99 @@
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' },
};
/** 兼容后端返回未知或小写枚举,避免直接索引得到 undefined。 */
export const getRoleTypeMeta = (roleType?: string) => {
  const key = String(roleType ?? '').toUpperCase() as RoleType;
  return roleTypeMeta[key] ?? { text: roleType || '-', color: 'default' };
};
export const getRoleStatusMeta = (status?: string) => {
  const key = String(status ?? '').toUpperCase() as RoleStatus;
  return statusMeta[key] ?? { text: status || '-', color: 'default' };
};
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) => {
      const meta = getRoleStatusMeta(record.status);
      return (
        <Tag color={meta.color} bordered={false}>
          {meta.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>
      );
    },
  },
];
src/pages/system/roles/index.tsx
New file
@@ -0,0 +1,125 @@
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, getRoleStatusMeta, getRoleTypeMeta } 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={getRoleTypeMeta(detail.roleType).color} bordered={false}>
                {getRoleTypeMeta(detail.roleType).text}
              </Tag>
            </Descriptions.Item>
            <Descriptions.Item label="状态">
              <Tag color={getRoleStatusMeta(detail.status).color} bordered={false}>
                {getRoleStatusMeta(detail.status).text}
              </Tag>
            </Descriptions.Item>
            <Descriptions.Item label="描述">{detail.description || '-'}</Descriptions.Item>
          </Descriptions>
        )}
      </Drawer>
    </div>
  );
};
export default Roles;
src/pages/system/roles/server.ts
New file
@@ -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(),
  });
src/pages/system/roles/types.ts
New file
@@ -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;
}
src/pages/system/users/columns.tsx
New file
@@ -0,0 +1,141 @@
import { Avatar, Button, Space, Tag } from 'antd';
import type { ProColumns } from '@ant-design/pro-components';
import type { EmployeeStatus, EmployeeVO, SelectOption } from './types';
export const employeeStatusMeta: Record<EmployeeStatus, { text: string; color: string }> = {
  ACTIVE: { text: '启用', color: 'green' },
  DISABLED: { text: '禁用', color: 'default' },
};
/** 兼容后端返回未知或小写状态,避免直接索引得到 undefined。 */
export const getEmployeeStatusMeta = (status?: string) => {
  const key = String(status ?? '').toUpperCase() as EmployeeStatus;
  return employeeStatusMeta[key] ?? { text: status || '-', color: 'default' };
};
export const createUserColumns = (options: {
  orgOptions: SelectOption[];
  roleOptions: SelectOption[];
  orgNameMap: Map<number, string>;
  roleNameMap: Map<number, string>;
  onDetail: (record: EmployeeVO) => void;
  onEdit: (record: EmployeeVO) => void;
  onDelete: (record: EmployeeVO) => void;
}): ProColumns<EmployeeVO>[] => [
  {
    title: '员工',
    dataIndex: 'name',
    width: 220,
    ellipsis: true,
    fieldProps: { placeholder: '姓名 / 手机号' },
    search: {
      transform: (value) => ({ keyword: value }),
    },
    render: (_value, record) => (
      <div className="user-cell">
        <Avatar className="user-cell__avatar" src={record.avatarUrl} size={36}>
          {record.name?.charAt(0)}
        </Avatar>
        <div className="user-cell__meta">
          <span className="user-cell__name">{record.name}</span>
          <span className="user-cell__username">{record.phone}</span>
        </div>
      </div>
    ),
  },
  {
    title: '所属组织',
    dataIndex: 'orgId',
    width: 170,
    valueType: 'select',
    fieldProps: {
      options: options.orgOptions,
      showSearch: true,
      optionFilterProp: 'label',
      allowClear: true,
      placeholder: '请选择组织',
    },
    render: (_value, record) =>
      options.orgNameMap.get(record.orgId) ?? (record.orgId ? `#${record.orgId}` : '-'),
  },
  {
    title: '角色',
    dataIndex: 'roleId',
    width: 150,
    valueType: 'select',
    fieldProps: {
      options: options.roleOptions,
      showSearch: true,
      optionFilterProp: 'label',
      allowClear: true,
      placeholder: '请选择角色',
    },
    render: (_value, record) =>
      options.roleNameMap.get(record.roleId) ?? (record.roleId ? `#${record.roleId}` : '-'),
  },
  {
    title: '职位',
    dataIndex: 'position',
    width: 120,
    search: false,
    render: (_value, record) => record.position || '-',
  },
  {
    title: '负责区域',
    dataIndex: 'region',
    width: 120,
    search: false,
    render: (_value, record) => record.region || '-',
  },
  {
    title: '客户上限',
    dataIndex: 'holdLimit',
    width: 100,
    search: false,
    render: (_value, record) => record.holdLimit ?? '-',
  },
  {
    title: '状态',
    dataIndex: 'status',
    width: 100,
    valueType: 'select',
    valueEnum: {
      ACTIVE: { text: '启用' },
      DISABLED: { text: '禁用' },
    },
    render: (_value, record) => {
      const meta = getEmployeeStatusMeta(record.status);
      return (
        <Tag color={meta.color} bordered={false}>
          {meta.text}
        </Tag>
      );
    },
  },
  {
    title: '入职日期',
    dataIndex: 'hireDate',
    width: 130,
    search: false,
    render: (_value, record) => record.hireDate || '-',
  },
  {
    title: '操作',
    key: 'action',
    valueType: 'option',
    width: 180,
    render: (_value, record) => (
      <Space size={0}>
        <Button type="link" size="small" onClick={() => options.onDetail(record)}>
          详情
        </Button>
        <Button type="link" size="small" onClick={() => options.onEdit(record)}>
          编辑
        </Button>
        <Button type="link" size="small" danger onClick={() => options.onDelete(record)}>
          删除
        </Button>
      </Space>
    ),
  },
];
src/pages/system/users/components/UserFormDrawer.tsx
New file
@@ -0,0 +1,211 @@
import { useCallback, useEffect, useState } from 'react';
import {
  App as AntdApp,
  Button,
  Drawer,
  Form,
  Input,
  InputNumber,
  Select,
  Space,
  Spin,
} from 'antd';
import { createEmployee, getEmployeeDetail, updateEmployee } from '../server';
import type { EmployeeFormValues, EmployeeVO, SelectOption } from '../types';
export type UserFormMode = 'create' | 'edit' | 'detail';
interface UserFormDrawerProps {
  open: boolean;
  mode: UserFormMode;
  /** 编辑 / 详情对象;新增时为空。 */
  record: EmployeeVO | null;
  orgOptions: SelectOption[];
  roleOptions: SelectOption[];
  onClose: () => void;
  onSuccess: () => void;
}
const toFormValues = (employee: EmployeeVO): EmployeeFormValues => ({
  name: employee.name,
  phone: employee.phone,
  orgId: employee.orgId,
  roleId: employee.roleId,
  position: employee.position,
  email: employee.email,
  holdLimit: employee.holdLimit,
  region: employee.region,
});
const UserFormDrawer = ({
  open,
  mode,
  record,
  orgOptions,
  roleOptions,
  onClose,
  onSuccess,
}: UserFormDrawerProps) => {
  const [form] = Form.useForm<EmployeeFormValues>();
  const [submitting, setSubmitting] = useState(false);
  const [loading, setLoading] = useState(false);
  const { message } = AntdApp.useApp();
  const isDetail = mode === 'detail';
  useEffect(() => {
    if (!open) return;
    let active = true;
    const initForm = async () => {
      form.resetFields();
      if (mode === 'detail') {
        if (!record) return;
        setLoading(true);
        try {
          const data = await getEmployeeDetail({ id: record.id });
          if (active) form.setFieldsValue(toFormValues(data));
        } catch {
          // 错误已由 request 统一提示
          if (active) onClose();
        } finally {
          if (active) setLoading(false);
        }
        return;
      }
      if (mode === 'edit' && record) {
        form.setFieldsValue(toFormValues(record));
      }
    };
    void initForm();
    return () => {
      active = false;
    };
  }, [form, mode, onClose, open, record]);
  const handleSubmit = useCallback(async () => {
    if (isDetail) return;
    let values: EmployeeFormValues;
    try {
      values = await form.validateFields();
    } catch {
      return;
    }
    setSubmitting(true);
    try {
      if (mode === 'edit' && record) {
        await updateEmployee({ id: record.id, ...values });
        message.success('员工信息已更新');
      } else {
        await createEmployee(values);
        message.success('员工创建成功');
      }
      onSuccess();
    } catch {
      // 错误已由 request 统一提示
    } finally {
      setSubmitting(false);
    }
  }, [form, isDetail, message, mode, onSuccess, record]);
  const title = mode === 'create' ? '新增员工' : mode === 'edit' ? '编辑员工' : '员工详情';
  return (
    <Drawer
      title={title}
      width={420}
      open={open}
      forceRender
      onClose={onClose}
      footer={
        <Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
          <Button onClick={onClose}>{isDetail ? '关闭' : '取消'}</Button>
          {!isDetail && (
            <Button type="primary" loading={submitting} onClick={handleSubmit}>
              {mode === 'edit' ? '保存' : '创建'}
            </Button>
          )}
        </Space>
      }
    >
      <Spin spinning={loading}>
        <Form form={form} layout="vertical" preserve={false}>
          <Form.Item
            name="name"
            label="姓名"
            rules={[{ required: true, message: '请输入姓名' }]}
          >
            <Input placeholder="请输入员工姓名" disabled={isDetail} />
          </Form.Item>
          <Form.Item
            name="phone"
            label="手机号"
            rules={[
              { required: true, message: '请输入手机号' },
              { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
            ]}
          >
            <Input placeholder="请输入手机号" maxLength={11} disabled={isDetail} />
          </Form.Item>
          <Form.Item
            name="orgId"
            label="所属组织"
            rules={[{ required: true, message: '请选择所属组织' }]}
          >
            <Select
              showSearch
              optionFilterProp="label"
              options={orgOptions}
              placeholder="请选择所属组织"
              disabled={isDetail}
            />
          </Form.Item>
          <Form.Item
            name="roleId"
            label="角色"
            rules={[{ required: true, message: '请选择角色' }]}
          >
            <Select
              showSearch
              optionFilterProp="label"
              options={roleOptions}
              placeholder="请选择角色"
              disabled={isDetail}
            />
          </Form.Item>
          <Form.Item name="position" label="职位">
            <Input placeholder="请输入职位" disabled={isDetail} />
          </Form.Item>
          <Form.Item name="region" label="负责区域">
            <Input placeholder="请输入负责区域" disabled={isDetail} />
          </Form.Item>
          <Form.Item name="holdLimit" label="客户持有上限">
            <InputNumber
              min={0}
              precision={0}
              style={{ width: '100%' }}
              disabled={isDetail}
            />
          </Form.Item>
          <Form.Item
            name="email"
            label="邮箱"
            rules={[{ type: 'email', message: '请输入正确的邮箱' }]}
          >
            <Input placeholder="请输入邮箱" disabled={isDetail} />
          </Form.Item>
        </Form>
      </Spin>
    </Drawer>
  );
};
export default UserFormDrawer;
src/pages/system/users/index.tsx
@@ -1,373 +1,282 @@
import { useMemo, useState } from 'react';
import { PlusOutlined, SearchOutlined } from '@ant-design/icons';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
  App as AntdApp,
  Avatar,
  Button,
  Card,
  Form,
  Input,
  Modal,
  Select,
  Space,
  Table,
  Tag,
} from 'antd';
import type { TableProps } from 'antd';
import dayjs from 'dayjs';
  DownloadOutlined,
  InboxOutlined,
  PlusOutlined,
  UploadOutlined,
} from '@ant-design/icons';
import { ProTable } from '@ant-design/pro-components';
import type { ActionType } from '@ant-design/pro-components';
import { App as AntdApp, Button, Modal, Upload } from 'antd';
import type { UploadFile } from 'antd';
import { createUserColumns } from './columns';
import UserFormDrawer from './components/UserFormDrawer';
import type { UserFormMode } from './components/UserFormDrawer';
import {
  deleteEmployee,
  exportEmployees,
  getEmployeeList,
  getOrgTree,
  getRoleOptions,
  importEmployees,
} from './server';
import type {
  EmployeeListQuery,
  EmployeeVO,
  OrgTreeNode,
  SelectOption,
  TenantEmployeeListRequest,
} from './types';
import './Users.scss';
type UserStatus = 'active' | 'disabled';
interface UserRecord {
  key: number;
  username: string;
  nickname: string;
  role: string;
  status: UserStatus;
  createdAt: string;
}
interface UserFormValues {
  username: string;
  nickname: string;
  role: string;
  status: UserStatus;
}
const roleOptions = [
  { value: '超级管理员', label: '超级管理员' },
  { value: '运营管理员', label: '运营管理员' },
  { value: '销售专员', label: '销售专员' },
  { value: '访客', label: '访客' },
];
const roleColor: Record<string, string> = {
  超级管理员: 'purple',
  运营管理员: 'blue',
  销售专员: 'cyan',
  访客: 'default',
};
const statusMeta: Record<UserStatus, { label: string; color: string }> = {
  active: { label: '启用', color: 'green' },
  disabled: { label: '禁用', color: 'default' },
};
const initialUsers: UserRecord[] = [
  {
    key: 1,
    username: 'admin',
    nickname: '陈一鸣',
    role: '超级管理员',
    status: 'active',
    createdAt: '2026-01-08 09:30',
  },
  {
    key: 2,
    username: 'lin.xia',
    nickname: '林夏',
    role: '运营管理员',
    status: 'active',
    createdAt: '2026-03-12 14:20',
  },
  {
    key: 3,
    username: 'zhou.yang',
    nickname: '周洋',
    role: '销售专员',
    status: 'active',
    createdAt: '2026-04-02 10:05',
  },
  {
    key: 4,
    username: 'su.wen',
    nickname: '苏雯',
    role: '销售专员',
    status: 'active',
    createdAt: '2026-05-18 16:42',
  },
  {
    key: 5,
    username: 'he.jun',
    nickname: '何俊',
    role: '运营管理员',
    status: 'disabled',
    createdAt: '2026-06-09 11:18',
  },
  {
    key: 6,
    username: 'yang.li',
    nickname: '杨莉',
    role: '销售专员',
    status: 'active',
    createdAt: '2026-07-21 15:54',
  },
  {
    key: 7,
    username: 'wang.qi',
    nickname: '王琪',
    role: '访客',
    status: 'disabled',
    createdAt: '2026-08-03 09:12',
  },
  {
    key: 8,
    username: 'zhao.min',
    nickname: '赵敏',
    role: '销售专员',
    status: 'active',
    createdAt: '2026-09-15 13:26',
  },
];
const flattenOrgOptions = (nodes: OrgTreeNode[]): SelectOption[] =>
  nodes.flatMap((node) => [
    { label: node.orgName, value: node.id },
    ...flattenOrgOptions(node.children ?? []),
  ]);
const Users = () => {
  const [users, setUsers] = useState<UserRecord[]>(initialUsers);
  const [keyword, setKeyword] = useState('');
  const [status, setStatus] = useState<UserStatus | 'all'>('all');
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [editingUser, setEditingUser] = useState<UserRecord | null>(null);
  const [form] = Form.useForm<UserFormValues>();
  const actionRef = useRef<ActionType>();
  const lastQueryRef = useRef<TenantEmployeeListRequest>({});
  const { message, modal } = AntdApp.useApp();
  const [orgOptions, setOrgOptions] = useState<SelectOption[]>([]);
  const [roleOptions, setRoleOptions] = useState<SelectOption[]>([]);
  const [formOpen, setFormOpen] = useState(false);
  const [formMode, setFormMode] = useState<UserFormMode>('create');
  const [activeEmployee, setActiveEmployee] = useState<EmployeeVO | null>(null);
  const [importOpen, setImportOpen] = useState(false);
  const [importFileList, setImportFileList] = useState<UploadFile[]>([]);
  const [importing, setImporting] = useState(false);
  const [exporting, setExporting] = useState(false);
  const filteredUsers = useMemo(() => {
    const normalizedKeyword = keyword.trim().toLowerCase();
    return users.filter((user) => {
      const matchesKeyword =
        !normalizedKeyword ||
        user.username.toLowerCase().includes(normalizedKeyword) ||
        user.nickname.toLowerCase().includes(normalizedKeyword);
      const matchesStatus = status === 'all' || user.status === status;
      return matchesKeyword && matchesStatus;
    });
  }, [users, keyword, status]);
  useEffect(() => {
    let active = true;
  const openCreateModal = () => {
    setEditingUser(null);
    form.resetFields();
    form.setFieldsValue({ status: 'active', role: '销售专员' });
    setIsModalOpen(true);
  };
    const loadOptions = async () => {
      try {
        const [orgs, roles] = await Promise.all([getOrgTree(), getRoleOptions()]);
        if (!active) return;
  const openEditModal = (record: UserRecord) => {
    setEditingUser(record);
    form.setFieldsValue({
      username: record.username,
      nickname: record.nickname,
      role: record.role,
      status: record.status,
    });
    setIsModalOpen(true);
  };
        setOrgOptions(flattenOrgOptions(orgs));
        setRoleOptions(roles.map((role) => ({ label: role.roleName, value: role.id })));
      } catch {
        // 错误已由 request 统一提示
      }
    };
  const closeModal = () => {
    setIsModalOpen(false);
    form.resetFields();
  };
    void loadOptions();
  const handleSubmit = async () => {
    return () => {
      active = false;
    };
  }, []);
  const orgNameMap = useMemo(
    () => new Map(orgOptions.map((option) => [option.value, option.label])),
    [orgOptions],
  );
  const roleNameMap = useMemo(
    () => new Map(roleOptions.map((option) => [option.value, option.label])),
    [roleOptions],
  );
  const openCreate = useCallback(() => {
    setActiveEmployee(null);
    setFormMode('create');
    setFormOpen(true);
  }, []);
  const closeForm = useCallback(() => {
    setFormOpen(false);
    setActiveEmployee(null);
  }, []);
  const handleFormSuccess = useCallback(() => {
    closeForm();
    actionRef.current?.reload();
  }, [closeForm]);
  const openEdit = useCallback((record: EmployeeVO) => {
    setActiveEmployee(record);
    setFormMode('edit');
    setFormOpen(true);
  }, []);
  const openDetail = useCallback((record: EmployeeVO) => {
    setActiveEmployee(record);
    setFormMode('detail');
    setFormOpen(true);
  }, []);
  const handleDelete = useCallback(
    (record: EmployeeVO) => {
      modal.confirm({
        title: '删除员工',
        content: `确定要删除员工「${record.name}」吗?删除后不可恢复。`,
        okText: '删除',
        cancelText: '取消',
        okButtonProps: { danger: true },
        onOk: async () => {
          await deleteEmployee({ id: record.id });
          message.success('员工已删除');
          actionRef.current?.reload();
        },
      });
    },
    [message, modal],
  );
  const handleExport = useCallback(async () => {
    setExporting(true);
    try {
      const values = await form.validateFields();
      const duplicate = users.some(
        (user) => user.username === values.username && user.key !== editingUser?.key,
      );
      if (duplicate) {
        message.error('用户名已存在,请更换一个');
        return;
      }
      if (editingUser) {
        setUsers((prev) =>
          prev.map((user) =>
            user.key === editingUser.key ? { ...user, ...values } : user,
          ),
        );
        message.success('用户信息已更新');
      } else {
        setUsers((prev) => [
          {
            key: Date.now(),
            ...values,
            createdAt: dayjs().format('YYYY-MM-DD HH:mm'),
          },
          ...prev,
        ]);
        message.success('用户创建成功');
      }
      closeModal();
      const blob = await exportEmployees({ orgId: lastQueryRef.current.orgId });
      const url = URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;
      link.download = 'employees.xlsx';
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      window.setTimeout(() => URL.revokeObjectURL(url), 0);
      message.success('导出成功');
    } catch {
      // 表单校验失败时由 antd 自动展示错误信息
      // 错误已由 request 统一提示
    } finally {
      setExporting(false);
    }
  };
  }, [message]);
  const handleDelete = (record: UserRecord) => {
    modal.confirm({
      title: '删除用户',
      content: `确定要删除用户「${record.nickname}」吗?删除后不可恢复。`,
      okText: '删除',
      cancelText: '取消',
      okButtonProps: { danger: true },
      onOk: () => {
        setUsers((prev) => prev.filter((user) => user.key !== record.key));
        message.success('用户已删除');
      },
    });
  };
  const handleImport = useCallback(async () => {
    const file = importFileList[0]?.originFileObj;
    if (!file) {
      message.warning('请先选择要导入的 Excel 文件');
      return;
    }
  const columns: TableProps<UserRecord>['columns'] = [
    {
      title: '用户',
      dataIndex: 'nickname',
      width: 220,
      render: (_value, record) => (
        <div className="user-cell">
          <Avatar className="user-cell__avatar">{record.nickname.charAt(0)}</Avatar>
          <div className="user-cell__meta">
            <span className="user-cell__name">{record.nickname}</span>
            <span className="user-cell__username">@{record.username}</span>
          </div>
        </div>
      ),
    },
    {
      title: '角色',
      dataIndex: 'role',
      width: 140,
      render: (value: string) => (
        <Tag color={roleColor[value] ?? 'blue'} bordered={false}>
          {value}
        </Tag>
      ),
    },
    {
      title: '状态',
      dataIndex: 'status',
      width: 100,
      render: (value: UserStatus) => (
        <Tag color={statusMeta[value].color} bordered={false}>
          {statusMeta[value].label}
        </Tag>
      ),
    },
    { title: '创建时间', dataIndex: 'createdAt', width: 170 },
    {
      title: '操作',
      key: 'action',
      width: 150,
      render: (_value, record) => (
        <Space size={0}>
          <Button type="link" size="small" onClick={() => openEditModal(record)}>
            编辑
          </Button>
          <Button type="link" size="small" danger onClick={() => handleDelete(record)}>
            删除
          </Button>
        </Space>
      ),
    },
  ];
    setImporting(true);
    try {
      const count = await importEmployees({ file });
      message.success(`成功导入 ${count} 条员工`);
      setImportOpen(false);
      setImportFileList([]);
      actionRef.current?.reload();
    } catch {
      // 错误已由 request 统一提示
    } finally {
      setImporting(false);
    }
  }, [importFileList, message]);
  const columns = useMemo(
    () =>
      createUserColumns({
        orgOptions,
        roleOptions,
        orgNameMap,
        roleNameMap,
        onDetail: openDetail,
        onEdit: openEdit,
        onDelete: handleDelete,
      }),
    [orgOptions, roleOptions, orgNameMap, roleNameMap, openDetail, openEdit, handleDelete],
  );
  return (
    <div className="users-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>
      <ProTable<EmployeeVO, EmployeeListQuery>
        rowKey="id"
        actionRef={actionRef}
        columns={columns}
        cardProps={{ className: 'table-card' }}
        search={{ labelWidth: 'auto', defaultCollapsed: false }}
        scroll={{ x: 1290 }}
        options={false}
        pagination={{
          defaultPageSize: 10,
          showSizeChanger: true,
          hideOnSinglePage: false,
          showTotal: (total) => `共 ${total} 条`,
        }}
        toolBarRender={() => [
          <Button
            key="import"
            icon={<UploadOutlined />}
            onClick={() => {
              setImportFileList([]);
              setImportOpen(true);
            }}
          >
            导入
          </Button>,
          <Button
            key="export"
            icon={<DownloadOutlined />}
            loading={exporting}
            onClick={handleExport}
          >
            导出
          </Button>,
          <Button key="create" type="primary" icon={<PlusOutlined />} onClick={openCreate}>
            新增员工
          </Button>,
        ]}
        request={async (params) => {
          const query: TenantEmployeeListRequest = {
            page: params.current,
            size: params.pageSize,
            keyword: params.keyword?.trim() || undefined,
            status: params.status,
            orgId: params.orgId,
            roleId: params.roleId,
          };
          lastQueryRef.current = query;
      <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: 260 }}
          />
          <Select<UserStatus | 'all'>
            value={status}
            onChange={setStatus}
            style={{ width: 150 }}
            options={[
              { value: 'all', label: '全部状态' },
              { value: 'active', label: '启用' },
              { value: 'disabled', label: '禁用' },
            ]}
          />
          <span className="text-muted">共 {filteredUsers.length} 条记录</span>
        </div>
      </Card>
          const result = await getEmployeeList(query);
      <Card className="table-card" variant="borderless">
        <Table<UserRecord>
          rowKey="key"
          columns={columns}
          dataSource={filteredUsers}
          scroll={{ x: 820 }}
          pagination={{
            pageSize: 8,
            showSizeChanger: false,
            showTotal: (total) => `共 ${total} 条`,
          }}
        />
      </Card>
          return {
            data: result.records ?? [],
            total: result.total ?? 0,
            success: true,
          };
        }}
      />
      <UserFormDrawer
        open={formOpen}
        mode={formMode}
        record={activeEmployee}
        orgOptions={orgOptions}
        roleOptions={roleOptions}
        onClose={closeForm}
        onSuccess={handleFormSuccess}
      />
      <Modal
        title={editingUser ? '编辑用户' : '新增用户'}
        open={isModalOpen}
        onOk={handleSubmit}
        onCancel={closeModal}
        okText={editingUser ? '保存' : '创建'}
        title="导入员工"
        open={importOpen}
        onOk={handleImport}
        onCancel={() => {
          setImportOpen(false);
          setImportFileList([]);
        }}
        confirmLoading={importing}
        okText="导入"
        cancelText="取消"
        forceRender
      >
        <Form form={form} layout="vertical" preserve={false}>
          <Form.Item
            name="username"
            label="用户名"
            rules={[
              { required: true, message: '请输入用户名' },
              { pattern: /^[a-zA-Z][a-zA-Z0-9._-]{2,19}$/, message: '3-20 位,字母开头' },
            ]}
          >
            <Input placeholder="请输入登录用户名" disabled={Boolean(editingUser)} />
          </Form.Item>
          <Form.Item
            name="nickname"
            label="姓名"
            rules={[{ required: true, message: '请输入姓名' }]}
          >
            <Input placeholder="请输入用户姓名" />
          </Form.Item>
          <Form.Item
            name="role"
            label="角色"
            rules={[{ required: true, message: '请选择角色' }]}
          >
            <Select placeholder="请选择角色" options={roleOptions} />
          </Form.Item>
          <Form.Item
            name="status"
            label="状态"
            rules={[{ required: true, message: '请选择状态' }]}
          >
            <Select
              options={[
                { value: 'active', label: '启用' },
                { value: 'disabled', label: '禁用' },
              ]}
            />
          </Form.Item>
        </Form>
        <Upload.Dragger
          accept=".xlsx,.xls"
          maxCount={1}
          fileList={importFileList}
          beforeUpload={() => false}
          onChange={({ fileList }) => setImportFileList(fileList)}
        >
          <p className="ant-upload-drag-icon">
            <InboxOutlined />
          </p>
          <p className="ant-upload-text">点击或拖拽 Excel 文件到此处</p>
          <p className="ant-upload-hint">仅支持 .xlsx / .xls 格式</p>
        </Upload.Dragger>
      </Modal>
    </div>
  );
src/pages/system/users/server.ts
New file
@@ -0,0 +1,112 @@
import { getAuthHeaders } from '@/api/authHeaders';
import { request } from '@/api/request';
import type {
  TenantEmployeeCreateRequest,
  TenantEmployeeCreateResponse,
  TenantEmployeeDeleteRequest,
  TenantEmployeeDeleteResponse,
  TenantEmployeeDetailRequest,
  TenantEmployeeDetailResponse,
  TenantEmployeeExportRequest,
  TenantEmployeeExportResponse,
  TenantEmployeeImportRequest,
  TenantEmployeeImportResponse,
  TenantEmployeeListRequest,
  TenantEmployeeListResponse,
  TenantEmployeeUpdateBody,
  TenantEmployeeUpdateRequest,
  TenantEmployeeUpdateResponse,
  TenantOrgTreeRequest,
  TenantOrgTreeResponse,
  TenantRoleOptionRequest,
  TenantRoleOptionResponse,
} from './types';
/** 员工分页列表。 */
export const getEmployeeList = (params: TenantEmployeeListRequest) =>
  request<TenantEmployeeListRequest, TenantEmployeeListResponse>({
    url: '/tenant/employees',
    method: 'GET',
    params,
    headers: getAuthHeaders(),
  });
/** 创建员工。 */
export const createEmployee = (data: TenantEmployeeCreateRequest) =>
  request<TenantEmployeeCreateRequest, TenantEmployeeCreateResponse>({
    url: '/tenant/employees',
    method: 'POST',
    data,
    headers: getAuthHeaders(),
  });
/** 员工详情。 */
export const getEmployeeDetail = ({ id }: TenantEmployeeDetailRequest) =>
  request<TenantEmployeeDetailRequest, TenantEmployeeDetailResponse>({
    url: `/tenant/employees/${id}`,
    method: 'GET',
    headers: getAuthHeaders(),
  });
/** 更新员工基本信息。 */
export const updateEmployee = ({ id, ...data }: TenantEmployeeUpdateRequest) =>
  request<TenantEmployeeUpdateBody, TenantEmployeeUpdateResponse>({
    url: `/tenant/employees/${id}`,
    method: 'PUT',
    data,
    headers: getAuthHeaders(),
  });
/** 删除员工。 */
export const deleteEmployee = ({ id }: TenantEmployeeDeleteRequest) =>
  request<TenantEmployeeDeleteRequest, TenantEmployeeDeleteResponse>({
    url: `/tenant/employees/${id}`,
    method: 'DELETE',
    headers: getAuthHeaders(),
  });
/** 导出员工 Excel 文件流。 */
export const exportEmployees = (params: TenantEmployeeExportRequest = {}) =>
  request<TenantEmployeeExportRequest, TenantEmployeeExportResponse>({
    url: '/tenant/employees/export',
    method: 'GET',
    params,
    responseType: 'blob',
    headers: getAuthHeaders(),
  });
/** 批量导入员工 Excel。 */
export const importEmployees = ({ file }: TenantEmployeeImportRequest) => {
  const formData = new FormData();
  formData.append('file', file);
  return request<FormData, TenantEmployeeImportResponse>(
    {
      url: '/tenant/employees/import',
      method: 'POST',
      data: formData,
      headers: getAuthHeaders(),
    },
    {
      // 不同文件请求体无法通过 FormData 区分,关闭合并。
      merge: false,
    },
  );
};
/** 组织树,用于组织下拉选项。 */
export const getOrgTree = (params: TenantOrgTreeRequest = {}) =>
  request<TenantOrgTreeRequest, TenantOrgTreeResponse>({
    url: '/tenant/organizations/tree',
    method: 'GET',
    params,
    headers: getAuthHeaders(),
  });
/** 角色列表,用于角色下拉选项。 */
export const getRoleOptions = () =>
  request<TenantRoleOptionRequest, TenantRoleOptionResponse>({
    url: '/tenant/roles',
    method: 'GET',
    headers: getAuthHeaders(),
  });
src/pages/system/users/types.ts
New file
@@ -0,0 +1,167 @@
import type { PageResult } from '@/types/api';
/** 员工状态。 */
export type EmployeeStatus = 'ACTIVE' | 'DISABLED';
/** 员工响应体(EmployeeVO)。 */
export interface EmployeeVO {
  id: number;
  createdAt?: string;
  updatedAt?: string;
  createdBy?: string;
  updatedBy?: string;
  tenantId?: string;
  deleted?: number;
  name: string;
  phone: string;
  orgId: number;
  roleId: number;
  position?: string;
  email?: string;
  avatarUrl?: string;
  status: EmployeeStatus;
  holdLimit?: number;
  region?: string;
  hireDate?: string;
}
/** GET /tenant/employees 请求参数(EmployeePageQuery)。 */
export interface TenantEmployeeListRequest {
  page?: number;
  size?: number;
  sortBy?: string;
  sortDir?: string;
  orgId?: number;
  roleId?: number;
  status?: EmployeeStatus;
  keyword?: string;
}
/** GET /tenant/employees 响应数据(ResultPageResultEmployeeVO 的 data)。 */
export type TenantEmployeeListResponse = PageResult<EmployeeVO>;
/** POST /tenant/employees 请求体(EmployeeCreateDTO)。 */
export interface TenantEmployeeCreateRequest {
  name: string;
  phone: string;
  orgId: number;
  roleId: number;
  position?: string;
  email?: string;
  avatarUrl?: string;
  holdLimit?: number;
  region?: string;
}
/** POST /tenant/employees 响应数据(ResultEmployeeVO 的 data)。 */
export type TenantEmployeeCreateResponse = EmployeeVO;
/** GET /tenant/employees/{id} 请求参数。 */
export interface TenantEmployeeDetailRequest {
  /** 员工 ID(路径参数)。 */
  id: number;
}
/** GET /tenant/employees/{id} 响应数据(ResultEmployeeVO 的 data)。 */
export type TenantEmployeeDetailResponse = EmployeeVO;
/** PUT /tenant/employees/{id} 请求参数(路径参数 + EmployeeUpdateDTO)。 */
export interface TenantEmployeeUpdateRequest {
  /** 员工 ID(路径参数)。 */
  id: number;
  name?: string;
  phone?: string;
  orgId?: number;
  roleId?: number;
  position?: string;
  email?: string;
  holdLimit?: number;
  region?: string;
}
/** PUT /tenant/employees/{id} 请求体。 */
export type TenantEmployeeUpdateBody = Omit<TenantEmployeeUpdateRequest, 'id'>;
/** PUT /tenant/employees/{id} 响应数据(ResultEmployeeVO 的 data)。 */
export type TenantEmployeeUpdateResponse = EmployeeVO;
/** DELETE /tenant/employees/{id} 请求参数。 */
export interface TenantEmployeeDeleteRequest {
  /** 员工 ID(路径参数)。 */
  id: number;
}
/** DELETE /tenant/employees/{id} 响应数据(ResultVoid 的 data)。 */
export type TenantEmployeeDeleteResponse = void;
/** GET /tenant/employees/export 请求参数。 */
export interface TenantEmployeeExportRequest {
  /** 不填导出全部,填则仅导出指定组织。 */
  orgId?: number;
}
/** GET /tenant/employees/export 响应数据(Excel 文件流)。 */
export type TenantEmployeeExportResponse = Blob;
/** POST /tenant/employees/import 请求参数(multipart/form-data,字段名 file)。 */
export interface TenantEmployeeImportRequest {
  file: File;
}
/** POST /tenant/employees/import 响应数据(成功导入行数)。 */
export type TenantEmployeeImportResponse = number;
/** GET /tenant/organizations/tree 请求参数。 */
export interface TenantOrgTreeRequest {
  orgId?: number;
}
/** 组织树节点(OrgVO 子集)。 */
export interface OrgTreeNode {
  id: number;
  orgName: string;
  status?: boolean;
  children?: OrgTreeNode[];
}
/** GET /tenant/organizations/tree 响应数据(ResultListOrgVO 的 data)。 */
export type TenantOrgTreeResponse = OrgTreeNode[];
/** GET /tenant/roles 请求参数:该接口未定义查询参数。 */
export type TenantRoleOptionRequest = Record<string, never>;
/** 角色选项(RoleVO 子集)。 */
export interface RoleOption {
  id: number;
  roleName: string;
  status?: string;
}
/** GET /tenant/roles 响应数据(ResultListRoleVO 的 data)。 */
export type TenantRoleOptionResponse = RoleOption[];
/** ProTable 搜索参数,与 EmployeePageQuery 中可筛选字段一致。 */
export interface EmployeeListQuery {
  keyword?: string;
  status?: EmployeeStatus;
  orgId?: number;
  roleId?: number;
}
/** 新增 / 编辑 / 详情表单值。 */
export interface EmployeeFormValues {
  name: string;
  phone: string;
  orgId: number;
  roleId: number;
  position?: string;
  email?: string;
  holdLimit?: number;
  region?: string;
}
/** 下拉选项。 */
export interface SelectOption {
  label: string;
  value: number;
}
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 />,
      },
    ],
  },
src/types/api.ts
New file
@@ -0,0 +1,8 @@
/** 后端通用分页响应结构(PageResult)。 */
export interface PageResult<T> {
  page: number;
  size: number;
  total: number;
  totalPages: number;
  records: T[];
}