fe1692fd7f4f307fea8054da6c6c50d5df2bf2d8..d26274d89d377067c1e2648192cffbf89464b361
3 days ago wsj
feat: 拆包
d26274 diff | tree
3 days ago wsj
feat: claude
0d9778 diff | tree
3 days ago wsj
feat: 域名
d4610c diff | tree
3 days ago wsj
feat: claude 修改
71719f diff | tree
3 days ago wsj
feat: 日志页面
cd2b7d diff | tree
21 files modified
6 files renamed
8 files added
1180 ■■■■■ changed files
.claude/skills/new-page/SKILL.md 32 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/templates/non-table-page/index.tsx 24 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/templates/non-table-page/service.ts patch | view | raw | blame | history
.claude/skills/new-page/templates/table-page/index.tsx 23 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/templates/table-page/service.ts patch | view | raw | blame | history
.env 2 ●●●●● patch | view | raw | blame | history
CLAUDE.md 305 ●●●●● patch | view | raw | blame | history
README.md 3 ●●●●● patch | view | raw | blame | history
src/api/request.ts 1 ●●●●● patch | view | raw | blame | history
src/components/PageLoading.scss 14 ●●●●● patch | view | raw | blame | history
src/components/PageLoading.tsx 12 ●●●●● patch | view | raw | blame | history
src/layouts/BasicLayout.tsx 8 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/components/CustomerDetailDrawer.tsx 2 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/components/FollowUpRecordsDrawer.tsx 2 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/index.tsx 2 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/service.ts patch | view | raw | blame | history
src/pages/platform/audit-logs/AuditLogs.scss 14 ●●●●● patch | view | raw | blame | history
src/pages/platform/audit-logs/columns.tsx 172 ●●●●● patch | view | raw | blame | history
src/pages/platform/audit-logs/components/AuditLogDetailDrawer.tsx 82 ●●●●● patch | view | raw | blame | history
src/pages/platform/audit-logs/index.tsx 64 ●●●●● patch | view | raw | blame | history
src/pages/platform/audit-logs/service.ts 12 ●●●●● patch | view | raw | blame | history
src/pages/platform/audit-logs/types.ts 70 ●●●●● patch | view | raw | blame | history
src/pages/system/departments/columns.tsx 8 ●●●●● patch | view | raw | blame | history
src/pages/system/departments/components/DepartmentFormDrawer.tsx 90 ●●●●● patch | view | raw | blame | history
src/pages/system/departments/index.tsx 20 ●●●●● patch | view | raw | blame | history
src/pages/system/departments/service.ts 14 ●●●●● patch | view | raw | blame | history
src/pages/system/departments/types.ts 13 ●●●●● patch | view | raw | blame | history
src/pages/system/roles/index.tsx 2 ●●●●● patch | view | raw | blame | history
src/pages/system/roles/service.ts patch | view | raw | blame | history
src/pages/system/users/columns.tsx 11 ●●●●● patch | view | raw | blame | history
src/pages/system/users/components/UserFormDrawer.tsx 67 ●●●●● patch | view | raw | blame | history
src/pages/system/users/index.tsx 2 ●●●●● patch | view | raw | blame | history
src/pages/system/users/service.ts patch | view | raw | blame | history
src/router/routeConfig.tsx 35 ●●●●● patch | view | raw | blame | history
vite.config.ts 74 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/SKILL.md
@@ -1,6 +1,6 @@
---
name: new-page
description: 当用户要创建新页面、新建新页面、新建页面、新增页面、加一个页面或搭建页面脚手架时使用。先选择分支:表格/列表页使用 ProTable 模板,表单/详情/仪表盘等非表格页使用 Ant Design 模板;两个分支都会生成 server.ts 维护接口调用,并统一执行代码规范、路由注册、文档更新与类型检查。
description: 当用户要创建新页面、新建新页面、新建页面、新增页面、加一个页面或搭建页面脚手架时使用。先选择分支:表格/列表页使用 ProTable 模板,表单/详情/仪表盘等非表格页使用 Ant Design 模板;两个分支都会生成 service.ts 维护接口调用,并统一执行代码规范、路由注册、文档更新与类型检查。
---
# 新增页面
@@ -24,7 +24,7 @@
| 页面名 | 目录名与组件名 | `invoices` / `Invoices` |
| 路由 | 完整路径,同时作为菜单 key | `/business/invoices` |
| 菜单标题 / 图标 | 中文标题 + `@ant-design/icons` | 发票管理 / `FileTextOutlined` |
| 数据来源 | 接口调用放在同目录 `server.ts`;演示阶段可先用本地 mock | `server.ts` |
| 数据来源 | 接口调用放在同目录 `service.ts`;演示阶段可先用本地 mock | `service.ts` |
| 是否分组 | 是否放入现有的「系统管理 / 业务管理」分组 | `children` |
表格页额外确认:字段、列展示方式、可搜索字段、分页大小。
@@ -39,7 +39,7 @@
├── index.tsx       # 页面组件(默认导出)
├── columns.tsx     # ProColumns<T>[] 列定义
├── types.ts        # 记录类型与查询参数
├── server.ts       # 接口调用(列表查询、删除等)
├── service.ts       # 接口调用(列表查询、删除等)
└── <Page>.scss     # 页面级样式(按需新增)
```
@@ -50,9 +50,9 @@
- 使用 `ProTable`,不要手写「筛选表单 + Table」。
- `columns` 必须放在独立 `columns.tsx`,类型为 `ProColumns<T>[]`;需要回调时导出 `createXxxColumns(options)` 工厂函数。
- 页面中用 `useMemo` 缓存 columns,用 `useCallback` 包裹事件处理函数。
- `request` 单独定义:在组件内声明 `const fetchList = useCallback((params: XxxListParams) => getXxxList(params), [])`,再传给 `request={fetchList}`;不要在 `<ProTable />` 标签里内联箭头函数。
- 页面组件不直接写请求:列表查询、删除等接口调用统一放在同目录的 `server.ts`,组件只调用导出的方法。
- `server.ts` 使用 `@/api/request` 提供的 `request` 发送请求,不要直接使用 axios;泛型顺序为 `request<请求参数, 响应数据>`。
- `request` 单独定义:在组件内声明 `const fetchList = useCallback((params: XxxListParams) => getXxxList(params).then((result) => ({ data: result.data, total: result.total, success: result.success })), [])`,再传给 `request={fetchList}`;不要在 `<ProTable />` 标签里内联箭头函数,也不要用 `await` + `try/catch`。
- 页面组件不直接写请求:列表查询、删除等接口调用统一放在同目录的 `service.ts`,组件只调用导出的方法。
- `service.ts` 使用 `@/api/request` 提供的 `request` 发送请求,不要直接使用 axios;泛型顺序为 `request<请求参数, 响应数据>`。
- 分页使用 `defaultPageSize`(不要用受控的 `pageSize`);需要切换每页条数时设置 `showSizeChanger: true`。
- 不需要右侧工具栏图标时用 `options={false}`。
- 表格样式:有 `toolBarRender` 时不加 `cardProps`;没有 `toolBarRender` 时补充 `cardProps={{ bodyStyle: { padding: 20 } }}`。
@@ -66,7 +66,7 @@
src/pages/<domain>/<page>/
├── index.tsx       # 页面组件(默认导出)
├── types.ts        # 表单 / 详情类型
├── server.ts       # 接口调用(保存、详情查询等)
├── service.ts       # 接口调用(保存、详情查询等)
└── <Page>.scss     # 页面级样式(按需新增)
```
@@ -78,15 +78,16 @@
- 表单使用 Ant Design `Form` + `Form.Item` + `rules` 校验。
- 数据展示优先使用 `Card`、`Descriptions`、`Statistic`、`Tag` 等 Ant Design 组件。
- 页面局部状态使用 `useState`,派生数据使用 `useMemo`。
- 保存、详情查询等接口调用统一放在同目录的 `server.ts`,组件只调用导出的方法。
- `server.ts` 使用 `@/api/request` 提供的 `request` 发送请求,不要直接使用 axios。
- 保存、详情查询等接口调用统一放在同目录的 `service.ts`,组件只调用导出的方法。
- `service.ts` 使用 `@/api/request` 提供的 `request` 发送请求,不要直接使用 axios。
## 4. 公共步骤(两个分支都执行)
### 4.1 代码规范
- 页面级接口调用统一放在页面目录的 `server.ts`,不要写在组件内部。
- 页面级接口调用统一放在页面目录的 `service.ts`,不要写在组件内部。
- 统一使用 `src/api/request.ts` 的 `request`,不要直接使用 axios。默认会合并相同并发请求、自动提示错误,并只返回响应 `data`。
- 接口请求使用 `.then` / `.catch` / `.finally` 链式处理,不要使用 `await` + `try/catch`。
- 请求选项:`merge` 控制是否合并、`showError` 控制是否提示错误、`parse: false` 返回完整 `AxiosResponse`、`signal` 用于取消。
- 全部使用箭头函数,不新增 `function` 声明。
- 提示与确认框使用 `AntdApp.useApp()` 提供的 `message` / `modal`,不要用静态方法。
@@ -98,9 +99,13 @@
### 4.2 注册路由
在 `src/router/routeConfig.tsx` 中增加配置,并补充组件与图标的 import:
在 `src/router/routeConfig.tsx` 中增加配置,并补充图标 import 与懒加载页面组件:
```tsx
// 顶部:import { lazy } from 'react';
const Invoices = lazy(() => import('@/pages/business/invoices'));
// appRoutes 中
{
  path: '/business/invoices',
  title: '发票管理',
@@ -109,7 +114,7 @@
},
```
放入现有分组时写在其 `children` 中;新增一级分组时保持与现有分组相同的结构。
页面组件必须用 `lazy(() => import(...))` 注册,不要静态 import,否则会把页面打进首屏 chunk。放入现有分组时写在其 `children` 中;新增一级分组时保持与现有分组相同的结构。
### 4.3 更新文档
@@ -134,9 +139,10 @@
- [ ] 已选择正确分支并整体使用对应模板
- [ ] 表格页使用 ProTable,columns 独立在 `columns.tsx`
- [ ] `request` 已提取为独立的 `useCallback` 方法,未在标签内内联
- [ ] 接口请求使用 `.then` 链式处理,没有 `await` + `try/catch`
- [ ] 表格页无 `toolBarRender` 时已加 `cardProps={{ bodyStyle: { padding: 20 } }}`,有 `toolBarRender` 时未加
- [ ] 非表格页未创建 `columns.tsx`,使用合适的 Ant Design 组件
- [ ] 两个分支均已创建 `server.ts`,组件内没有直接写请求
- [ ] 两个分支均已创建 `service.ts`,组件内没有直接写请求
- [ ] 类型使用 `ProColumns<T>` / 明确接口,没有 `any`
- [ ] 全部使用箭头函数
- [ ] 提示与确认框通过 `AntdApp.useApp()` 获取
.claude/skills/new-page/templates/non-table-page/index.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { App as AntdApp, Button, Card, Form, Input, Space } from 'antd';
import { saveXxx } from './server';
import { saveXxx } from './service';
import type { XxxFormValues } from './types';
import './Xxx.scss';
@@ -9,17 +9,19 @@
  const [form] = Form.useForm<XxxFormValues>();
  const { message } = AntdApp.useApp();
  const handleFinish = async (values: XxxFormValues) => {
  const handleFinish = (values: XxxFormValues) => {
    setSubmitting(true);
    try {
      await saveXxx(values);
      message.success('保存成功');
      form.resetFields();
    } catch (error) {
      message.error(error instanceof Error ? error.message : '保存失败,请稍后重试');
    } finally {
      setSubmitting(false);
    }
    saveXxx(values)
      .then(() => {
        message.success('保存成功');
        form.resetFields();
      })
      .catch((error) => {
        message.error(error instanceof Error ? error.message : '保存失败,请稍后重试');
      })
      .finally(() => {
        setSubmitting(false);
      });
  };
  return (
.claude/skills/new-page/templates/non-table-page/service.ts
.claude/skills/new-page/templates/table-page/index.tsx
@@ -4,7 +4,7 @@
import type { ActionType } from '@ant-design/pro-components';
import { App as AntdApp, Button } from 'antd';
import { createXxxColumns } from './columns';
import { deleteXxx, getXxxList } from './server';
import { deleteXxx, getXxxList } from './service';
import type { XxxListParams, XxxQuery, XxxRecord } from './types';
import './Xxx.scss';
@@ -20,11 +20,15 @@
        okText: '删除',
        cancelText: '取消',
        okButtonProps: { danger: true },
        onOk: async () => {
          await deleteXxx(record.key);
          message.success('已删除');
          actionRef.current?.reload();
        },
        onOk: () =>
          deleteXxx(record.key)
            .then(() => {
              message.success('已删除');
              actionRef.current?.reload();
            })
            .catch(() => {
              // 错误已由 request 统一提示
            }),
      });
    },
    [message, modal],
@@ -36,7 +40,12 @@
  );
  const fetchList = useCallback(
    (params: XxxListParams) => getXxxList(params),
    (params: XxxListParams) =>
      getXxxList(params).then((result) => ({
        data: result.data,
        total: result.total,
        success: result.success,
      })),
    [],
  );
.claude/skills/new-page/templates/table-page/service.ts
.env
@@ -1,3 +1,3 @@
VITE_API_BASE_URL=http://121.41.54.56:9090/
VITE_API_BASE_URL=https://api.tersail.cn
# 开发环境代理目标(Vite 将 /proxy-api 转发到该地址)
VITE_API_PROXY_TARGET=http://121.41.54.56:9090
CLAUDE.md
@@ -1,7 +1,7 @@
# CRM Admin 项目开发指南(CLAUDE.md)
> Vite 6 + React 18 + TypeScript + Ant Design 5 + SCSS 的后台管理平台。
> 源码位于 `src/`,使用 BrowserRouter 的 SPA 架构,包含多级路由菜单、登录 / 退出与登录态校验。
> Vite 6 + React 18 + TypeScript(strict)+ Ant Design 5 + `@ant-design/pro-components` + Zustand 5 + Axios + SCSS 的后台管理平台。
> BrowserRouter SPA,包含多级路由菜单、登录 / 退出与登录态校验;日期库为 Day.js(已配置中文 locale)。
## 一、常用命令
@@ -15,78 +15,34 @@
改动代码后至少执行 `npm run typecheck`;涉及构建配置或样式时再执行 `npm run build`。
## 二、技术栈
- **构建**:Vite 6
- **框架**:React 18 + TypeScript(strict)
- **路由**:react-router-dom 6
- **UI**:Ant Design 5 + `@ant-design/icons`
- **高级组件**:`@ant-design/pro-components`(表格优先使用其 `ProTable`)
- **状态**:Zustand 5(登录态持久化到 localStorage)
- **请求**:Axios(统一封装在 `src/api/request.ts`)
- **样式**:SCSS + Ant Design 主题 token + CSS 变量
- **日期**:Day.js(已配置中文 locale)
## 三、目录结构
## 二、目录与核心架构
```text
src/
├── api/                 # 接口层:request.ts(axios 封装)与登录 auth.ts
├── components/          # 通用组件:AntdAppBridge(message 注入)
├── layouts/             # BasicLayout:侧边栏、头部、内容区、面包屑
├── pages/               # 页面,按业务域分目录
│   ├── login/
│   ├── dashboard/
│   ├── system/users/
│   ├── business/orders/
│   ├── customers/list/
│   └── error/NotFound.tsx
│   # 页面目录:index.tsx、columns.tsx、types.ts、server.ts、同名 .scss(按需)
├── router/
│   ├── routeConfig.tsx  # 路由 + 菜单唯一数据源(含页面组件)
│   ├── menu.tsx         # 菜单过滤与 antd Menu 结构生成
│   └── index.tsx        # 配置转 React Router 嵌套路由
├── store/               # Zustand 登录态
├── styles/              # 全局 SCSS、通用页面样式
├── types/               # 全局类型
├── utils/               # 工具:feedback.ts(全局错误提示)
└── main.tsx             # ConfigProvider + AntdApp + BrowserRouter
├── api/         # request.ts(axios 封装)、auth.ts、authHeaders.ts
├── components/  # 通用组件(AntdAppBridge 等)
├── layouts/     # BasicLayout:侧边栏、头部、内容区、面包屑
├── pages/       # 页面,按业务域分目录(页面目录结构见「三、3」)
├── router/      # routeConfig.tsx(路由 + 菜单唯一数据源)、menu.tsx、index.tsx
├── store/       # Zustand 登录态
├── styles/      # 全局 SCSS、通用页面样式
├── types/       # 全局类型(PageResult 等)
├── utils/       # 工具(feedback.ts 全局错误提示)
└── main.tsx     # ConfigProvider + AntdApp + BrowserRouter
```
## 四、核心架构
- **路由与菜单**:`routeConfig.tsx` 是唯一数据源,配置 `path`、`title`、`icon`、`element` 后侧边栏与面包屑自动生效。支持 2-3 级嵌套;分组路由(无 `element`)通过 `<Outlet />` 渲染并默认跳转到第一个子页面;未匹配路由显示 404。
- **路由懒加载**:页面组件统一通过 `lazy(() => import('@/pages/...'))` 注册,`BasicLayout` 的内容区已提供 `<Suspense>` 边界;新增页面不要用静态 import,避免所有页面打进入口 chunk。
- **登录态**:`src/store/auth.ts` 持久化 `token` 与 `user`;`src/api/auth.ts` 调用 `POST /auth/tenant-login`(`{ phone, password, tenantCode }`)。未登录访问受保护页面跳转 `/login`,登录成功后返回原地址。
- **布局与样式**:页面只负责内容区。全局 CSS 变量 `--app-primary`、`--app-bg`、`--app-border`、`--app-text`、`--app-text-secondary`;通用类 `.page-header`、`.page-toolbar`、`.filter-card`、`.table-card`、`.text-muted`;Ant Design 主题在 `main.tsx` 的 `ConfigProvider` 统一配置。
### 1. 路由与菜单
`src/router/routeConfig.tsx` 是**唯一数据源**。新增页面只需在这里配置 `path`、`title`、`icon`、`element`,侧边栏、面包屑会自动读取。
- 支持 2-3 级嵌套,例如:`用户与权限 → 用户管理`。
- `src/router/index.tsx` 把菜单配置转换成 React Router 的嵌套路由。
- 分组路由(无 `element`)通过 `<Outlet />` 渲染子页面,默认跳转到第一个子页面。
- 未匹配路由显示 404,普通页面组件位于 `BasicLayout` 内。
### 2. 登录态
- `src/store/auth.ts`:Zustand store,持久化 `token` 与 `user`。
- 登录 / 退出:`src/api/auth.ts` 调用 `POST /auth/tenant-login`,参数为 `{ phone, password, tenantCode }`。
- 接口基础地址由 `.env` 中的 `VITE_API_BASE_URL` 配置。
- 未登录访问受保护页面会跳转 `/login`,登录成功后返回原地址。
### 3. 布局与样式
- 统一使用 `BasicLayout`,页面只负责内容区域。
- 全局 CSS 变量:`--app-primary`、`--app-bg`、`--app-border`、`--app-text`、`--app-text-secondary` 等。
- 通用页面类:`.page-header`、`.page-toolbar`、`.filter-card`、`.table-card`、`.text-muted`。
- 页面级样式与页面同目录,使用 BEM 风格类名(如 `.user-cell__name`)。
- Ant Design 主题在 `src/main.tsx` 的 `ConfigProvider` 中统一配置。
## 五、代码风格(必遵守)
## 三、代码风格(必遵守)
### 1. 统一使用箭头函数
组件、工具函数、Hook、事件处理函数都使用箭头函数,不新增 `function` 声明。
```tsx
// ✅ 组件
const UserList = () => {
  const handleSubmit = async () => {
    // ...
@@ -96,161 +52,138 @@
};
export default UserList;
// ✅ 工具 / Hook
export const useDebounce = () => {
  // ...
};
export const formatAmount = (value: number) => `¥${value.toLocaleString()}`;
// ❌ 不要这样写
export default function UserList() {}
export function formatAmount(value: number) {}
```
### 2. 优先使用现成组件
写功能前先找现成能力,不要重复造轮子:
1. **优先使用 Ant Design 组件**:Table、Form、Modal、Select、DatePicker、Card、Tabs、Drawer 等。
2. **优先使用项目已有封装**:
   - 布局:`src/layouts/BasicLayout.tsx`
   - 登录校验:`src/router/index.tsx` 中的 `RequireAuth`
   - 全局提示 / 确认框:使用 `<App>` 提供的 `AntdApp.useApp()` 中的 `message`、`modal`,**不要**直接使用 `message.success()` / `Modal.confirm()` 静态方法。
3. **表格优先使用 ProTable**:新增列表页时优先使用 `@ant-design/pro-components` 提供的 `ProTable`,其搜索、分页、工具栏等能力开箱即用,不要手写筛选表单 + Table 的组合。
4. **不要手写**弹窗、Toast、下拉菜单、分页、表格、表单校验等基础交互。
5. 不引入新的 UI 组件库;确需新组件时,先检查是否已有可复用实现,再考虑在 `src/components/` 下新增。
6. 使用 Ant Design 5 的 API,不使用已废弃属性。例如 Card 使用 `variant="borderless"`,不要使用 `bordered={false}`。
- **优先使用 Ant Design 组件**与已有封装:布局 `BasicLayout`、登录校验 `RequireAuth`、提示与确认框使用 `AntdApp.useApp()` 的 `message` / `modal`(**不要**用 `message.success()` / `Modal.confirm()` 静态方法)。
- **表格优先使用 ProTable**:搜索、分页、工具栏开箱即用,不要手写「筛选表单 + Table」。
- **表单内的接口选项必须用 ProForm 组件**:Select、TreeSelect、Cascader 等只要选项来自接口(含父组件传入的接口数据),使用 `ProFormSelect` / `ProFormTreeSelect` / `ProFormCascader`,不要用原生控件包 `Form.Item`;静态枚举不受此限制。远程搜索用 `request`(配合 `debounceTime`),已有数据通过 `options` / `fieldProps` 传入,校验规则直接写在组件的 `rules` 上。
### 3. Table columns 必须单独提取
```tsx
// ✅ 选项来自接口
<ProFormSelect
  name="leaderId"
  label="负责人"
  showSearch
  debounceTime={300}
  request={async ({ keyWords }) => getLeaderOptions({ keyword: keyWords })}
/>
所有表格的 `columns` 不得内联在 `<Table />` 的 JSX 中,也不写在页面组件内部。统一提取到页面目录单独维护。
// ❌ 原生控件包在 Form.Item 中
<Form.Item name="leaderId" label="负责人">
  <Select options={leaderOptions} />
</Form.Item>
```
推荐结构:
- 不要手写弹窗、Toast、下拉菜单、分页、表格、表单校验等基础交互。不引入新的 UI 组件库;确需新组件时,先检查是否已有可复用实现,再考虑在 `src/components/` 下新增。
- 使用 Ant Design 5 的 API,不使用已废弃属性(如 Card 用 `variant="borderless"`,不用 `bordered={false}`)。
### 3. 独立组件放 `components/`,超长页面拆分
- Drawer、Modal 等弹层组件,以及详情卡片、表单、局部业务块等可独立成文件的组件,放到页面目录下的 `components/`,文件名使用大驼峰;组件私有 `.scss` 与组件同目录,由组件自身引入。
- 组件只接收 `props`;接口调用仍放在页面目录的 `service.ts`,由页面或组件按需调用;不要跨页面引用其他页面目录下的组件。
- 单个页面文件(含 `index.tsx`)超过 300 行必须拆分,按功能块抽取到 `components/`;超过 300 行的复杂组件同样按功能拆分,不要在同一个文件里堆叠多个组件。
```text
src/pages/system/users/
├── index.tsx       # 页面组件
├── columns.tsx     # 表格列定义
└── Users.scss      # 页面样式
├── index.tsx        # 页面组件(尽量精简)
├── columns.tsx      # 表格列定义
├── types.ts         # 页面类型
├── service.ts       # 接口调用
├── utils.ts         # 按需
├── Users.scss       # 页面样式
└── components/
    ├── UserFormDrawer.tsx
    └── UserDetailDrawer.tsx
```
```tsx
// columns.tsx
import type { TableProps } from 'antd';
## 四、新增页面流程
export const userColumns: TableProps<UserRecord>['columns'] = [
  {
    title: '用户名',
    dataIndex: 'username',
  },
  // ...
];
**新增页面统一使用 `/new-page` skill**(`.claude/skills/new-page/`):它会选择表格页 / 非表格页分支并整体套用模板,再完成代码规范、路由注册、文档更新与类型检查。不要手工复制模板或另起方式搭脚手架。
// 需要事件回调时,导出工厂函数
export const createUserColumns = (options: {
  onEdit: (record: UserRecord) => void;
  onDelete: (record: UserRecord) => void;
}): TableProps<UserRecord>['columns'] => [
  // ...
  {
    title: '操作',
    key: 'action',
    render: (_value, record) => (
      <Button type="link" size="small" onClick={() => options.onEdit(record)}>
        编辑
      </Button>
    ),
  },
];
```bash
/new-page 根据 <接口文档路径>,添加一个【页面名】页面,放到【分组名】目录下
```
```tsx
// index.tsx
const columns = useMemo(() => createUserColumns({ onEdit, onDelete }), []);
仅改动现有页面(如调整某一列)等不涉及脚手架的场景才直接改代码。新页面必须在 `src/router/routeConfig.tsx` 用完整路径注册(`path`、`title`、`icon`、`element`,需要分组时使用 `children`),页面组件使用 `lazy(() => import(...))` 懒加载,完成后执行 `npm run typecheck`。
<Table<UserRecord> rowKey="key" columns={columns} dataSource={dataSource} />;
```
## 五、接口与状态约定
约定:
以下四条为强制要求:
- columns 只负责展示,数据与业务逻辑留在页面组件。
- 列类型使用 `TableProps<T>['columns']`;使用 `ProTable` 时使用 `ProColumns<T>[]`,不要使用 `any`。
- 若列配置依赖页面状态或回调,使用 `createColumns(options)` 工厂 + `useMemo`,不要每次渲染重新创建。
1. **所有接口必须使用 `src/api/request.ts`**:统一通过其导出的 `request` 发起请求,不要在页面、组件或 `service.ts` 中直接使用 axios、`fetch` 或其他请求库。
2. **每个接口的 request 与 response 都必须定义 `interface`**:在 `service.ts` 中通过 `request<TReq, TRes>` 显式标注泛型,禁止使用 `any` 或省略类型。
   - 类型定义在页面目录的 `types.ts`(跨页面复用的放到 `src/api/` 对应业务域文件)。
   - 命名:请求体 / 查询参数为 `<业务>Request`,响应为 `<业务>Response`,响应对应接口返回 `data` 的结构(后端为 `Result<T>` 时取 `data` 部分)。
   - 无请求参数时也要显式定义,例如 `type TenantRoleListRequest = Record<string, never>`。
> 说明:存量页面中部分 columns 仍定义在页面组件内部、部分函数使用 `function` 声明。新增和修改代码一律遵循上述规范,存量代码在后续迭代中逐步迁移。
   ```ts
   // types.ts
   export interface TenantRoleToggleStatusRequest {
     id: number;
   }
   export interface TenantRoleToggleStatusResponse {
     id: number;
     status: RoleStatus;
   }
## 六、新增页面流程
   // service.ts
   export const toggleRoleStatus = ({ id }: TenantRoleToggleStatusRequest) =>
     request<TenantRoleToggleStatusRequest, TenantRoleToggleStatusResponse>({
       url: `/tenant/roles/${id}/toggle-status`,
       method: 'PUT',
       headers: getAuthHeaders(),
     });
   ```
1. 在 `src/pages/<业务域>/<页面>/` 下创建 `index.tsx`(有表格时增加 `columns.tsx`,接口调用放在 `server.ts`,类型放 `types.ts`,有独立样式时增加同名 `.scss`)。
2. 在 `src/router/routeConfig.tsx` 中增加路由配置:
   - 使用完整路径(如 `/system/users`)。
   - 配置 `title`、`icon`、`element`。
   - 需要分组时使用 `children`。
3. 执行 `npm run typecheck` 验证。
3. **接口调用必须使用 `.then` 链式处理,禁止 `await` + `try/catch`**:成功逻辑写在 `.then`,失败逻辑写在 `.catch`(错误已由 `request` 统一提示时可留空),收尾逻辑写在 `.finally`。
示例:
   ```ts
   // ✅
   getOrgList({ page: 1, size: 20 })
     .then((data) => {
       setDataSource(data.records);
     })
     .catch(() => {
       // 错误已由 request 统一提示
     })
     .finally(() => {
       setLoading(false);
     });
```tsx
{
  path: '/business/invoices',
  title: '发票管理',
  icon: <FileTextOutlined />,
  element: <Invoices />,
}
```
   // ❌
   try {
     const data = await getOrgList({ page: 1, size: 20 });
     setDataSource(data.records);
   } catch {
     // ...
   } finally {
     setLoading(false);
   }
   ```
## 七、接口与状态约定
4. **接口按作用域拆分文件**:
   - 页面接口放在页面目录下的 `service.ts`,页面与组件通过它调用,不要在组件内直接写请求实现。
   - 跨页面复用的公共接口按业务域拆分到 `src/api/` 下(如 `src/api/auth.ts`)。
- **每个接口都必须为其请求(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;
}
- `request<TReq, TRes>(config, options)`:泛型分别表示请求参数 / 请求体与响应 `data` 的类型;默认只返回 `data`,`parse: false` 返回完整 `AxiosResponse`;失败抛出 `ApiError`(含 `status`、`data`、`response`、`config`);默认合并相同并发请求(`merge: false` 关闭);`showError: false` 关闭自动提示;`signal` 取消当前调用。
- `src/api/auth.ts` 调用真实登录接口,登录成功后由 `src/store/auth.ts` 写入登录态;全局登录态只放在 `src/store/auth.ts`,页面局部状态使用 `useState` / `useMemo`。
- 不要在前端持久化敏感信息;真实项目的 Token 建议使用 HttpOnly Cookie,由后端处理过期与刷新。
// 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。
- 开发环境通过 Vite 代理 `/proxy-api` 转发到 `VITE_API_PROXY_TARGET`(未配置时取 `VITE_API_BASE_URL`),因此浏览器端不会产生跨域请求;生产环境需后端配置 CORS,或使用同域反向代理。
  - 泛型:`request<TReq, TRes>(config, options)`,分别表示请求参数/请求体与响应 `data` 的类型。
  - 默认只返回响应 `data`;传 `parse: false` 时返回完整 `AxiosResponse`。
  - 默认自动提示错误,`showError: false` 可关闭;失败抛出 `ApiError`,包含 `status`、`data`、`response`、`config` 与原始错误。
  - 默认合并方法、URL、参数与请求体都相同的并发请求;`merge: false` 时每次都单独发送。
  - 通过 `signal: AbortSignal` 取消当前调用方的请求。
- `src/api/auth.ts` 调用真实登录接口,登录成功后仍由 `src/store/auth.ts` 写入登录态。
- 全局登录态只放在 `src/store/auth.ts`;页面局部状态使用 `useState` / `useMemo`。
- 不要在前端持久化敏感信息;真实项目的 Token 建议使用 HttpOnly Cookie,并由后端处理过期与刷新。
## 八、样式与可访问性
- 使用 SCSS,不在 JSX 中堆叠大量内联样式;动态颜色等少量样式可使用 `style`。
- 颜色、圆角优先使用已有的 CSS 变量或 Ant Design token。
- 使用 SCSS,不在 JSX 中堆叠内联样式;动态颜色等少量样式可使用 `style`。
- 颜色、圆角优先使用已有 CSS 变量或 Ant Design token;页面级样式与页面同目录,使用 BEM 类名(如 `.user-cell__name`)。
- 交互元素保持可读的 hover / focus 状态;图标按钮补充 `aria-label`。
- 页面需兼容窄屏:表格使用 `scroll={{ x: ... }}`,左右布局使用栅格或 flex 换行。
- 兼容窄屏:表格使用 `scroll={{ x: ... }}`,左右布局使用栅格或 flex 换行。
## 九、部署注意
## 七、部署注意
- 使用 BrowserRouter,生产环境需要配置 SPA fallback:所有前端路由回退到 `index.html`。
- 构建产物在 `dist/`。
- 开发环境的跨域由 Vite 代理 `/proxy-api` 处理;生产环境需要后端开启 CORS,或将前端与接口部署在同一域名下由反向代理转发。
- 当前主包较大(Ant Design 全量依赖),后续可通过路由懒加载 / 手动分包优化。
- 使用 BrowserRouter,生产环境需配置 SPA fallback:所有前端路由回退到 `index.html`;构建产物在 `dist/`。
- 开发环境通过 Vite 代理 `/proxy-api` 转发到 `VITE_API_PROXY_TARGET`(未配置时取 `VITE_API_BASE_URL`),浏览器端不会跨域;生产环境需后端开启 CORS,或将前端与接口部署在同一域名下由反向代理转发。
- 已启用路由懒加载与依赖拆包:`vite.config.ts` 的 `manualChunks` 将 React、antd 核心、其余依赖拆成长缓存 chunk,ProComponents 及业务页面依赖由 Rollup 按需拆分为异步 chunk。新增页面时保持懒加载即可,无需再调整拆包配置。
README.md
@@ -93,7 +93,7 @@
- 通过 `signal: AbortSignal` 取消调用方的请求。
- 失败时抛出 `ApiError`,保留 `status`、`data`、`response`、`config` 与原始错误。
页面级接口调用放在页面目录的 `server.ts`,组件通过它调用请求。
页面级接口调用放在页面目录的 `service.ts`,组件通过它调用请求。
### 5. 演示页面
@@ -105,6 +105,7 @@
| 部门管理 | `/system/departments`   | 组织树、增删改、启用 / 停用        |
| 订单管理 | `/business/orders`      | 筛选、处理订单、导出               |
| 客户列表 | `/customers/list`       | 接口数据,按归属销售员筛选         |
| 审计日志 | `/platform/audit-logs`  | 接口数据,多条件筛选、详情查看     |
| 404      | 任意未知地址            | 页面不存在提示                     |
## 目录结构
src/api/request.ts
@@ -292,7 +292,6 @@
  try {
    const response = merge && signal ? await raceWithSignal(task, signal) : await task;
    console.log('response', response);
    return formatResponse(response, options);
  } catch (error) {
    const apiError = toApiError(error);
src/components/PageLoading.scss
New file
@@ -0,0 +1,14 @@
.page-loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  min-height: 320px;
  padding: 48px 0;
  &__text {
    color: var(--app-text-secondary);
    font-size: 13px;
  }
}
src/components/PageLoading.tsx
New file
@@ -0,0 +1,12 @@
import { Spin } from 'antd';
import './PageLoading.scss';
/** 路由懒加载时的占位内容,尽量保持内容区高度,避免页面跳动。 */
const PageLoading = () => (
  <div className="page-loading" role="status" aria-live="polite">
    <Spin size="large" />
    <span className="page-loading__text">页面加载中…</span>
  </div>
);
export default PageLoading;
src/layouts/BasicLayout.tsx
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { Suspense, useEffect, useMemo, useState } from 'react';
import {
  BellOutlined,
  DownOutlined,
@@ -20,6 +20,7 @@
  Tooltip,
} from 'antd';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import PageLoading from '@/components/PageLoading';
import { buildMenuItems, getOpenKeys } from '@/router/menu';
import { appRoutes, findBreadcrumb } from '@/router/routeConfig';
import { useAuthStore } from '@/store/auth';
@@ -181,7 +182,10 @@
        </Header>
        <Content className="app-content">
          <Outlet />
          {/* 路由懒加载边界放在内容区内,切页时侧边栏与头部保持不动 */}
          <Suspense fallback={<PageLoading />}>
            <Outlet />
          </Suspense>
        </Content>
        <Footer className="app-footer">
src/pages/customers/list/components/CustomerDetailDrawer.tsx
@@ -2,7 +2,7 @@
import { Button, Descriptions, Drawer, Empty, Spin, Tag } from 'antd';
import type { DescriptionsProps } from 'antd';
import { customerTypeMeta, levelMeta, renderTags, sourceMeta, stageMeta } from '../columns';
import { getCustomerDetail } from '../server';
import { getCustomerDetail } from '../service';
import type { CustomerVO } from '../types';
import { formatDateTime } from '../utils';
src/pages/customers/list/components/FollowUpRecordsDrawer.tsx
@@ -2,7 +2,7 @@
import { Button, Drawer, Empty, Spin, Tag, Timeline } from 'antd';
import type { TimelineProps } from 'antd';
import { followMethodMeta, followRecordTypeMeta, followStatusMeta, stageMeta } from '../columns';
import { getCustomerFollowUps } from '../server';
import { getCustomerFollowUps } from '../service';
import type { FollowStatus, FollowUpVO } from '../types';
import { formatDateTime } from '../utils';
import './FollowUpRecordsDrawer.scss';
src/pages/customers/list/index.tsx
@@ -3,7 +3,7 @@
import { createCustomerColumns } from './columns';
import CustomerDetailDrawer from './components/CustomerDetailDrawer';
import FollowUpRecordsDrawer from './components/FollowUpRecordsDrawer';
import { getCustomerList } from './server';
import { getCustomerList } from './service';
import type { CustomerListQuery, CustomerVO } from './types';
const CustomerList = () => {
src/pages/customers/list/service.ts
src/pages/platform/audit-logs/AuditLogs.scss
New file
@@ -0,0 +1,14 @@
.audit-log-page {
  &__change-detail {
    max-height: 240px;
    margin: 0;
    padding: 8px 12px;
    overflow: auto;
    font-size: 12px;
    line-height: 1.6;
    white-space: pre-wrap;
    word-break: break-all;
    background: var(--app-bg);
    border-radius: 6px;
  }
}
src/pages/platform/audit-logs/columns.tsx
New file
@@ -0,0 +1,172 @@
import { Button, Space, Tag } from 'antd';
import type { ProColumns } from '@ant-design/pro-components';
import dayjs from 'dayjs';
import type { AuditLogResultStatus, AuditLogVO } from './types';
/** 业务模块(后端可能扩展,未命中的直接展示原值)。 */
export const moduleText: Record<string, string> = {
  EMPLOYEE: '员工',
  ORG: '组织',
  ROLE: '角色',
  CUSTOMER: '客户',
  FOLLOW_UP: '跟进',
  TENANT: '租户',
  SYSTEM: '系统',
};
/** 操作动作(后端可能扩展,未命中的直接展示原值)。 */
export const actionText: Record<string, string> = {
  CREATE: '新增',
  UPDATE: '修改',
  DELETE: '删除',
  IMPORT: '导入',
  EXPORT: '导出',
  LOGIN: '登录',
  LOGOUT: '退出',
};
export const resultStatusMeta: Record<AuditLogResultStatus, { text: string; color: string }> = {
  SUCCESS: { text: '成功', color: 'success' },
  FAILURE: { text: '失败', color: 'error' },
};
const toValueEnum = (meta: Record<string, string>) =>
  Object.fromEntries(Object.entries(meta).map(([value, text]) => [value, { text }]));
export const getModuleText = (value?: string) => (value ? moduleText[value] ?? value : '-');
export const getActionText = (value?: string) => (value ? actionText[value] ?? value : '-');
export const createAuditLogColumns = (options: {
  onDetail: (record: AuditLogVO) => void;
}): ProColumns<AuditLogVO>[] => [
  {
    title: '日志 ID',
    dataIndex: 'logId',
    width: 100,
    search: false,
  },
  {
    title: '操作时间',
    dataIndex: 'createdAt',
    valueType: 'dateTime',
    width: 170,
    search: false,
  },
  {
    title: '操作人',
    dataIndex: 'operatorName',
    width: 140,
    search: false,
    render: (_value, record) => (
      <Space direction="vertical" size={0}>
        <span>{record.operatorName || '-'}</span>
        {record.operatorRole && <span className="text-muted">{record.operatorRole}</span>}
      </Space>
    ),
  },
  {
    title: '操作人 ID',
    dataIndex: 'operatorId',
    valueType: 'digit',
    hideInTable: true,
    fieldProps: { placeholder: '请输入操作人 ID' },
  },
  {
    title: '业务模块',
    dataIndex: 'module',
    width: 100,
    valueEnum: toValueEnum(moduleText),
    render: (_value, record) => getModuleText(record.module),
  },
  {
    title: '操作动作',
    dataIndex: 'action',
    width: 100,
    valueEnum: toValueEnum(actionText),
    render: (_value, record) => getActionText(record.action),
  },
  {
    title: '操作描述',
    dataIndex: 'operationDesc',
    width: 120,
    search: false,
    ellipsis: true,
    render: (_value, record) => record.operationDesc || '-',
  },
  {
    title: '操作对象',
    dataIndex: 'entityName',
    width: 180,
    search: false,
    ellipsis: true,
    render: (_value, record) => record.entityName || '-',
  },
  {
    title: '实体类型',
    dataIndex: 'entityType',
    hideInTable: true,
    fieldProps: { placeholder: '请输入实体类型' },
  },
  {
    title: '请求',
    dataIndex: 'requestPath',
    width: 220,
    search: false,
    ellipsis: true,
    render: (_value, record) => (
      <Space size={4}>
        <Tag bordered={false}>{record.requestMethod || '-'}</Tag>
        <span>{record.requestPath || '-'}</span>
      </Space>
    ),
  },
  {
    title: '来源 IP',
    dataIndex: 'requestIp',
    width: 130,
    search: false,
  },
  {
    title: '结果',
    dataIndex: 'resultStatus',
    width: 90,
    valueEnum: {
      SUCCESS: { text: resultStatusMeta.SUCCESS.text },
      FAILURE: { text: resultStatusMeta.FAILURE.text },
    },
    render: (_value, record) => {
      const meta = record.resultStatus ? resultStatusMeta[record.resultStatus] : undefined;
      return meta ? (
        <Tag color={meta.color} bordered={false}>
          {meta.text}
        </Tag>
      ) : (
        '-'
      );
    },
  },
  {
    title: '操作',
    key: 'option',
    valueType: 'option',
    width: 80,
    render: (_value, record) => (
      <Button type="link" size="small" onClick={() => options.onDetail(record)}>
        详情
      </Button>
    ),
  },
  {
    title: '操作时间',
    dataIndex: 'timeRange',
    valueType: 'dateRange',
    hideInTable: true,
    search: {
      transform: (value: [string, string]) => ({
        start: value?.[0] ? dayjs(value[0]).format('YYYY-MM-DD 00:00:00') : undefined,
        end: value?.[1] ? dayjs(value[1]).format('YYYY-MM-DD 23:59:59') : undefined,
      }),
    },
  },
];
src/pages/platform/audit-logs/components/AuditLogDetailDrawer.tsx
New file
@@ -0,0 +1,82 @@
import { useMemo } from 'react';
import { Descriptions, Drawer, Tag } from 'antd';
import type { DescriptionsProps } from 'antd';
import dayjs from 'dayjs';
import { getActionText, getModuleText, resultStatusMeta } from '../columns';
import type { AuditLogVO } from '../types';
import '../AuditLogs.scss';
interface AuditLogDetailDrawerProps {
  open: boolean;
  /** 待展示的审计日志,关闭时为 null。 */
  record: AuditLogVO | null;
  onClose: () => void;
}
const renderTag = (meta?: { text: string; color: string }) =>
  meta ? (
    <Tag color={meta.color} bordered={false}>
      {meta.text}
    </Tag>
  ) : (
    '-'
  );
const AuditLogDetailDrawer = ({ open, record, onClose }: AuditLogDetailDrawerProps) => {
  const items = useMemo<DescriptionsProps['items']>(() => {
    if (!record) return [];
    return [
      { key: 'logId', label: '日志 ID', children: record.logId },
      {
        key: 'createdAt',
        label: '操作时间',
        children: record.createdAt ? dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-',
      },
      {
        key: 'operator',
        label: '操作人',
        children: `${record.operatorName || '-'}${record.operatorRole ? `(${record.operatorRole})` : ''}`,
      },
      { key: 'operatorId', label: '操作人 ID', children: record.operatorId ?? '-' },
      { key: 'tenantId', label: '租户 ID', children: record.tenantId || '-' },
      { key: 'module', label: '业务模块', children: getModuleText(record.module) },
      { key: 'action', label: '操作动作', children: getActionText(record.action) },
      { key: 'operationDesc', label: '操作描述', children: record.operationDesc || '-' },
      { key: 'entityType', label: '实体类型', children: record.entityType || '-' },
      { key: 'entityId', label: '实体 ID', children: record.entityId || '-' },
      { key: 'entityName', label: '实体名称', children: record.entityName || '-' },
      {
        key: 'resultStatus',
        label: '结果状态',
        children: renderTag(record.resultStatus ? resultStatusMeta[record.resultStatus] : undefined),
      },
      { key: 'errorMessage', label: '错误信息', children: record.errorMessage || '-' },
      { key: 'requestMethod', label: '请求方法', children: record.requestMethod || '-' },
      { key: 'requestPath', label: '请求路径', children: record.requestPath || '-' },
      { key: 'requestIp', label: '来源 IP', children: record.requestIp || '-' },
      {
        key: 'userAgent',
        label: 'User-Agent',
        children: <span className="text-muted">{record.userAgent || '-'}</span>,
      },
      {
        key: 'changeDetail',
        label: '变更明细',
        children: record.changeDetail ? (
          <pre className="audit-log-page__change-detail">{record.changeDetail}</pre>
        ) : (
          '-'
        ),
      },
    ];
  }, [record]);
  return (
    <Drawer title="审计日志详情" width={560} open={open} onClose={onClose}>
      {record && <Descriptions column={1} bordered size="small" items={items} />}
    </Drawer>
  );
};
export default AuditLogDetailDrawer;
src/pages/platform/audit-logs/index.tsx
New file
@@ -0,0 +1,64 @@
import { useCallback, useMemo, useState } from 'react';
import { ProTable } from '@ant-design/pro-components';
import { createAuditLogColumns } from './columns';
import AuditLogDetailDrawer from './components/AuditLogDetailDrawer';
import { getAuditLogList } from './service';
import type { AuditLogListQuery, AuditLogVO } from './types';
const AuditLogs = () => {
  const [detail, setDetail] = useState<AuditLogVO | null>(null);
  const openDetail = useCallback((record: AuditLogVO) => {
    setDetail(record);
  }, []);
  const closeDetail = useCallback(() => {
    setDetail(null);
  }, []);
  const columns = useMemo(() => createAuditLogColumns({ onDetail: openDetail }), [openDetail]);
  const fetchList = useCallback(
    (params: AuditLogListQuery) =>
      getAuditLogList({
        page: params.current,
        size: params.pageSize,
        module: params.module,
        action: params.action,
        entityType: params.entityType,
        resultStatus: params.resultStatus,
        operatorId: params.operatorId,
        start: params.start,
        end: params.end,
      }).then((result) => ({
        data: result.records ?? [],
        total: result.total ?? 0,
        success: true,
      })),
    [],
  );
  return (
    <div className="audit-log-page">
      <ProTable<AuditLogVO, AuditLogListQuery>
        rowKey="logId"
        columns={columns}
        cardProps={{ bodyStyle: { padding: 20 } }}
        search={{ labelWidth: 'auto', defaultCollapsed: false }}
        scroll={{ x: 1430 }}
        options={false}
        pagination={{
          defaultPageSize: 10,
          showSizeChanger: true,
          hideOnSinglePage: false,
          showTotal: (total) => `共 ${total} 条`,
        }}
        request={fetchList}
      />
      <AuditLogDetailDrawer open={detail !== null} record={detail} onClose={closeDetail} />
    </div>
  );
};
export default AuditLogs;
src/pages/platform/audit-logs/service.ts
New file
@@ -0,0 +1,12 @@
import { getAuthHeaders } from '@/api/authHeaders';
import { request } from '@/api/request';
import type { TenantAuditLogListRequest, TenantAuditLogListResponse } from './types';
/** 查询本租户审计日志(分页)。 */
export const getAuditLogList = (params: TenantAuditLogListRequest) =>
  request<TenantAuditLogListRequest, TenantAuditLogListResponse>({
    url: '/tenant/audit-logs',
    method: 'GET',
    params,
    headers: getAuthHeaders(),
  });
src/pages/platform/audit-logs/types.ts
New file
@@ -0,0 +1,70 @@
import type { PageResult } from '@/types/api';
/** 审计日志结果状态。 */
export type AuditLogResultStatus = 'SUCCESS' | 'FAILURE';
/** 审计日志(AuditLogVO)。 */
export interface AuditLogVO {
  logId: number;
  tenantId?: string;
  operatorId?: number;
  operatorName?: string;
  operatorRole?: string;
  /** 业务模块,如 EMPLOYEE / ORG / ROLE / CUSTOMER / FOLLOW_UP / TENANT / SYSTEM。 */
  module?: string;
  /** 操作动作,如 CREATE / UPDATE / DELETE / IMPORT。 */
  action?: string;
  operationDesc?: string;
  entityType?: string;
  entityId?: string;
  entityName?: string;
  changeDetail?: string;
  requestIp?: string;
  userAgent?: string;
  requestPath?: string;
  requestMethod?: string;
  resultStatus?: AuditLogResultStatus;
  errorMessage?: string;
  createdAt?: string;
}
/** 审计日志分页查询参数(AuditLogPageQuery)。 */
export interface AuditLogPageQuery {
  /** 页码。 */
  page?: number;
  /** 每页条数。 */
  size?: number;
  /** 业务模块。 */
  module?: string;
  /** 操作动作。 */
  action?: string;
  /** 实体类型。 */
  entityType?: string;
  /** 结果状态。 */
  resultStatus?: AuditLogResultStatus;
  /** 操作人 ID。 */
  operatorId?: number;
  /** 开始时间(yyyy-MM-dd HH:mm)。 */
  start?: string;
  /** 结束时间(yyyy-MM-dd HH:mm)。 */
  end?: string;
}
/** 审计日志列表请求参数。 */
export interface TenantAuditLogListRequest extends AuditLogPageQuery {}
/** 审计日志列表响应数据(ResultPageResultAuditLogVO 的 data)。 */
export type TenantAuditLogListResponse = PageResult<AuditLogVO>;
/** ProTable 搜索表单参数,字段与请求参数中可筛选的字段保持一致。 */
export interface AuditLogListQuery {
  current?: number;
  pageSize?: number;
  module?: string;
  action?: string;
  entityType?: string;
  resultStatus?: AuditLogResultStatus;
  operatorId?: number;
  start?: string;
  end?: string;
}
src/pages/system/departments/columns.tsx
@@ -101,7 +101,13 @@
        <Button type="link" size="small" onClick={() => options.onCreateChild(record)}>
          新增子部门
        </Button>
        <Button type="link" size="small" onClick={() => options.onEdit(record)}>
        {/* 总部为租户根组织,不允许编辑 */}
        <Button
          type="link"
          size="small"
          disabled={record.orgType === 'HQ'}
          onClick={() => options.onEdit(record)}
        >
          编辑
        </Button>
        <Button type="link" size="small" onClick={() => options.onToggle(record)}>
src/pages/system/departments/components/DepartmentFormDrawer.tsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ProFormSelect, ProFormTreeSelect } from '@ant-design/pro-components';
import {
  App as AntdApp,
  Button,
@@ -8,9 +9,8 @@
  InputNumber,
  Select,
  Space,
  TreeSelect,
} from 'antd';
import { createOrg, updateOrg } from '../server';
import { createOrg, getLeaderDetail, getLeaderOptions, updateOrg } from '../service';
import type { DepartmentFormValues, OrgVO } from '../types';
import { toOrgTreeSelectData } from '../utils';
@@ -23,8 +23,6 @@
  parentId?: number;
  /** 组织树,用于上级组织选择。 */
  orgTree: OrgVO[];
  /** 负责人员工下拉选项。 */
  leaderOptions: { label: string; value: number }[];
  onClose: () => void;
  onSuccess: () => void;
}
@@ -35,7 +33,6 @@
  record,
  parentId,
  orgTree,
  leaderOptions,
  onClose,
  onSuccess,
}: DepartmentFormDrawerProps) => {
@@ -43,10 +40,46 @@
  const [submitting, setSubmitting] = useState(false);
  const { message } = AntdApp.useApp();
  const isEdit = mode === 'edit';
  /** 新增入口只有「新增子部门」,上级组织已确定,不允许再选择。 */
  const parentDisabled = !isEdit;
  const parentOptions = useMemo(
    () => toOrgTreeSelectData(orgTree, isEdit ? record?.id : undefined),
    [isEdit, orgTree, record],
  );
  /** 负责人远程搜索:按姓名 / 手机号模糊查询员工。 */
  const requestLeaderOptions = useCallback(
    async ({ keyWords }: { keyWords?: string }) => {
      const keyword = keyWords?.trim();
      const result = await getLeaderOptions({
        page: 1,
        size: 50,
        keyword: keyword || undefined,
      });
      const options = (result.records ?? []).map((employee) => ({
        label: employee.name,
        value: employee.id,
      }));
      // 编辑时把当前负责人补进首屏选项,避免回填后显示为员工 ID。
      const currentLeaderId = isEdit ? record?.leaderId : undefined;
      if (
        !keyword &&
        currentLeaderId &&
        !options.some((option) => option.value === currentLeaderId)
      ) {
        try {
          const leader = await getLeaderDetail({ id: currentLeaderId });
          options.unshift({ label: leader.name, value: leader.id });
        } catch {
          // 错误已由 request 统一提示
        }
      }
      return options;
    },
    [isEdit, record],
  );
  useEffect(() => {
@@ -97,7 +130,7 @@
    }
  }, [form, isEdit, message, onSuccess, record]);
  const title = isEdit ? '编辑部门' : parentId ? '新增子部门' : '新增部门';
  const title = isEdit ? '编辑部门' : '新增子部门';
  return (
    <Drawer
@@ -140,26 +173,31 @@
            ]}
          />
        </Form.Item>
        <Form.Item name="parentId" label="上级组织">
          <TreeSelect
            treeData={parentOptions}
            treeDefaultExpandAll
            allowClear
            showSearch
            treeNodeFilterProp="title"
            placeholder="不选则作为根组织"
            style={{ width: '100%' }}
          />
        </Form.Item>
        <Form.Item name="leaderId" label="负责人">
          <Select
            options={leaderOptions}
            showSearch
            optionFilterProp="label"
            allowClear
            placeholder="请选择负责人"
          />
        </Form.Item>
        <ProFormTreeSelect
          name="parentId"
          label="上级组织"
          placeholder="不选则作为根组织"
          disabled={parentDisabled}
          fieldProps={{
            treeData: parentOptions,
            treeDefaultExpandAll: true,
            allowClear: true,
            showSearch: true,
            treeNodeFilterProp: 'title',
            style: { width: '100%' },
          }}
        />
        <ProFormSelect
          name="leaderId"
          label="负责人"
          rules={isEdit ? undefined : [{ required: true, message: '请选择负责人' }]}
          placeholder="请输入姓名搜索负责人"
          showSearch
          allowClear
          debounceTime={300}
          request={requestLeaderOptions}
          fieldProps={{ filterOption: false, style: { width: '100%' } }}
        />
        <Form.Item name="region" label="所在地区">
          <Input placeholder="请输入所在地区" maxLength={50} />
        </Form.Item>
src/pages/system/departments/index.tsx
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Key } from 'react';
import { PlusOutlined } from '@ant-design/icons';
import { ProTable } from '@ant-design/pro-components';
import type { ActionType } from '@ant-design/pro-components';
import { App as AntdApp, Button } from 'antd';
import { App as AntdApp } from 'antd';
import { createDepartmentColumns } from './columns';
import DepartmentFormDrawer from './components/DepartmentFormDrawer';
import { deleteOrg, getLeaderOptions, getOrgTree, toggleOrgStatus } from './server';
import { deleteOrg, getLeaderOptions, getOrgTree, toggleOrgStatus } from './service';
import type { DepartmentListQuery, OrgVO } from './types';
import { collectOrgIds, filterOrgTree } from './utils';
@@ -26,7 +25,7 @@
    const loadLeaders = async () => {
      try {
        const result = await getLeaderOptions();
        const result = await getLeaderOptions({ page: 1, size: 200 });
        if (!active) return;
        setLeaderOptions(
@@ -59,13 +58,6 @@
    const data = filterOrgTree(list, params);
    return { data, total: data.length, success: true };
  }, []);
  const openCreateRoot = useCallback(() => {
    setActiveOrg(null);
    setParentId(undefined);
    setFormMode('create');
    setFormOpen(true);
  }, []);
  const openCreateChild = useCallback((record: OrgVO) => {
@@ -155,11 +147,6 @@
          expandedRowKeys,
          onExpandedRowsChange: (keys) => setExpandedRowKeys([...keys]),
        }}
        toolBarRender={() => [
          <Button key="create" type="primary" icon={<PlusOutlined />} onClick={openCreateRoot}>
            新增部门
          </Button>,
        ]}
        request={fetchDepartments}
      />
@@ -169,7 +156,6 @@
        record={activeOrg}
        parentId={parentId}
        orgTree={orgTree}
        leaderOptions={leaderOptions}
        onClose={closeForm}
        onSuccess={handleFormSuccess}
      />
src/pages/system/departments/service.ts
File was renamed from src/pages/system/departments/server.ts
@@ -1,6 +1,8 @@
import { getAuthHeaders } from '@/api/authHeaders';
import { request } from '@/api/request';
import type {
  DepartmentLeaderDetailRequest,
  DepartmentLeaderDetailResponse,
  TenantEmployeeOptionListRequest,
  TenantEmployeeOptionListResponse,
  TenantOrgCreateRequest,
@@ -59,11 +61,19 @@
    headers: getAuthHeaders(),
  });
/** 员工列表,用于负责人下拉选项。 */
export const getLeaderOptions = (params: TenantEmployeeOptionListRequest = { page: 1, size: 200 }) =>
/** 员工列表,用于负责人下拉远程搜索。 */
export const getLeaderOptions = (params: TenantEmployeeOptionListRequest) =>
  request<TenantEmployeeOptionListRequest, TenantEmployeeOptionListResponse>({
    url: '/tenant/employees',
    method: 'GET',
    params,
    headers: getAuthHeaders(),
  });
/** 员工详情,用于编辑时回填负责人姓名。 */
export const getLeaderDetail = ({ id }: DepartmentLeaderDetailRequest) =>
  request<DepartmentLeaderDetailRequest, DepartmentLeaderDetailResponse>({
    url: `/tenant/employees/${id}`,
    method: 'GET',
    headers: getAuthHeaders(),
  });
src/pages/system/departments/types.ts
@@ -72,10 +72,12 @@
/** PUT /tenant/organizations/{id}/toggle-status 响应数据(ResultVoid 的 data)。 */
export type TenantOrgToggleStatusResponse = void;
/** GET /tenant/employees 请求参数(仅用于负责人下拉)。 */
/** GET /tenant/employees 请求参数(负责人下拉远程搜索)。 */
export interface TenantEmployeeOptionListRequest {
  page?: number;
  size?: number;
  /** 姓名 / 手机号模糊搜索。 */
  keyword?: string;
}
/** 负责人选项(EmployeeVO 子集)。 */
@@ -88,6 +90,15 @@
/** GET /tenant/employees 响应数据(ResultPageResultEmployeeVO 的 data)。 */
export type TenantEmployeeOptionListResponse = PageResult<DepartmentLeaderOption>;
/** GET /tenant/employees/{id} 请求参数(编辑时回填负责人姓名)。 */
export interface DepartmentLeaderDetailRequest {
  /** 员工 ID(路径参数)。 */
  id: number;
}
/** GET /tenant/employees/{id} 响应数据(EmployeeVO 子集)。 */
export type DepartmentLeaderDetailResponse = DepartmentLeaderOption;
/** ProTable 筛选参数:组织树接口没有查询参数,这些字段仅用于前端本地过滤。 */
export interface DepartmentListQuery {
  orgName?: string;
src/pages/system/roles/index.tsx
@@ -3,7 +3,7 @@
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 { getRoleDetail, getRoleList, toggleRoleStatus } from './service';
import type { RoleListQuery, RoleVO } from './types';
const Roles = () => {
src/pages/system/roles/service.ts
src/pages/system/users/columns.tsx
@@ -7,10 +7,13 @@
  DISABLED: { text: '禁用', color: 'default' },
};
/** 兼容后端返回未知或小写状态,避免直接索引得到 undefined。 */
export const getEmployeeStatusMeta = (status?: string) => {
  const key = String(status ?? '').toUpperCase() as EmployeeStatus;
  return employeeStatusMeta[key] ?? { text: status || '-', color: 'default' };
/** 兼容后端返回 null、小写或未知状态:ACTIVE 与空值均为启用,仅 DISABLED 为禁用。 */
export const getEmployeeStatusMeta = (status?: string | null) => {
  const key = String(status ?? '').toUpperCase();
  if (!key || key === 'ACTIVE') return employeeStatusMeta.ACTIVE;
  if (key === 'DISABLED') return employeeStatusMeta.DISABLED;
  return { text: status || '-', color: 'default' };
};
export const createUserColumns = (options: {
src/pages/system/users/components/UserFormDrawer.tsx
@@ -1,17 +1,16 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ProFormCascader, ProFormSelect } from '@ant-design/pro-components';
import {
  App as AntdApp,
  Button,
  Cascader,
  Drawer,
  Form,
  Input,
  InputNumber,
  Select,
  Space,
  Spin,
} from 'antd';
import { createEmployee, getEmployeeDetail, updateEmployee } from '../server';
import { createEmployee, getEmployeeDetail, updateEmployee } from '../service';
import type {
  EmployeeFormValues,
  EmployeeVO,
@@ -24,6 +23,10 @@
  label: string;
  children?: OrgCascaderOption[];
}
/** 去掉最外层公司节点,只保留公司下的组织用于选择。 */
const getSelectableOrgNodes = (nodes: OrgTreeNode[]): OrgTreeNode[] =>
  nodes.flatMap((node) => (node.children?.length ? node.children : [node]));
/** 组织树转为级联选择选项,按层级展示。 */
const toCascaderOptions = (nodes: OrgTreeNode[]): OrgCascaderOption[] =>
@@ -65,7 +68,7 @@
): EmployeeFormValues => ({
  name: employee.name,
  phone: employee.phone,
  orgPath: findOrgPath(orgTree, employee.orgId) ?? [],
  orgPath: findOrgPath(getSelectableOrgNodes(orgTree), employee.orgId) ?? [],
  roleId: employee.roleId,
  position: employee.position,
  email: employee.email,
@@ -87,7 +90,10 @@
  const [loading, setLoading] = useState(false);
  const { message } = AntdApp.useApp();
  const isDetail = mode === 'detail';
  const orgCascaderOptions = useMemo(() => toCascaderOptions(orgTree), [orgTree]);
  const orgCascaderOptions = useMemo(
    () => toCascaderOptions(getSelectableOrgNodes(orgTree)),
    [orgTree],
  );
  useEffect(() => {
    if (!open) return;
@@ -196,30 +202,39 @@
          >
            <Input placeholder="请输入手机号" maxLength={11} disabled={isDetail} />
          </Form.Item>
          <Form.Item
          <ProFormCascader
            name="orgPath"
            label="所属组织"
            rules={[{ required: true, message: '请选择所属组织' }]}
          >
            <Cascader
              options={orgCascaderOptions}
              changeOnSelect
              showSearch
              placeholder="请选择所属组织"
              disabled={isDetail}
              style={{ width: '100%' }}
            />
          </Form.Item>
          <Form.Item
            placeholder="请选择所属组织"
            disabled={isDetail}
            fieldProps={{
              options: orgCascaderOptions,
              changeOnSelect: true,
              showSearch: true,
              style: { width: '100%' },
            }}
          />
          <ProFormSelect
            name="roleId"
            label="角色"
            rules={[{ required: true, message: '请选择角色' }]}
            placeholder="请选择角色"
            disabled={isDetail}
            showSearch
            options={roleOptions}
            fieldProps={{ optionFilterProp: 'label', style: { width: '100%' } }}
          />
          <Form.Item
            name="holdLimit"
            label="客户持有上限"
            rules={[{ required: true, message: '请输入客户持有上限' }]}
          >
            <Select
              showSearch
              optionFilterProp="label"
              options={roleOptions}
              placeholder="请选择角色"
            <InputNumber
              min={0}
              precision={0}
              placeholder="请输入客户持有上限"
              style={{ width: '100%' }}
              disabled={isDetail}
            />
          </Form.Item>
@@ -228,14 +243,6 @@
          </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"
src/pages/system/users/index.tsx
@@ -19,7 +19,7 @@
  getOrgTree,
  getRoleOptions,
  importEmployees,
} from './server';
} from './service';
import type {
  EmployeeListQuery,
  EmployeeVO,
src/pages/system/users/service.ts
src/router/routeConfig.tsx
@@ -1,21 +1,29 @@
import type { ReactNode } from 'react';
import { lazy, type ReactNode } from 'react';
import {
  ApartmentOutlined,
  AppstoreOutlined,
  AuditOutlined,
  ContactsOutlined,
  DashboardOutlined,
  ProfileOutlined,
  SafetyCertificateOutlined,
  SafetyOutlined,
  SettingOutlined,
  TeamOutlined,
  UnorderedListOutlined,
} from '@ant-design/icons';
import Orders from '@/pages/business/orders';
import CustomerList from '@/pages/customers/list';
import Dashboard from '@/pages/dashboard';
import Departments from '@/pages/system/departments';
import Roles from '@/pages/system/roles';
import Users from '@/pages/system/users';
/**
 * 页面组件统一按路由懒加载:首屏只加载布局与当前页面,
 * 其余页面在切换菜单时按需加载,避免所有页面打进同一个入口 chunk。
 */
const Orders = lazy(() => import('@/pages/business/orders'));
const CustomerList = lazy(() => import('@/pages/customers/list'));
const Dashboard = lazy(() => import('@/pages/dashboard'));
const AuditLogs = lazy(() => import('@/pages/platform/audit-logs'));
const Departments = lazy(() => import('@/pages/system/departments'));
const Roles = lazy(() => import('@/pages/system/roles'));
const Users = lazy(() => import('@/pages/system/users'));
export interface AppRouteItem {
  /** 完整路径,同时作为菜单 key */
@@ -90,6 +98,19 @@
      },
    ],
  },
  {
    path: '/platform',
    title: '平台管理',
    icon: <SettingOutlined />,
    children: [
      {
        path: '/platform/audit-logs',
        title: '审计日志',
        icon: <AuditOutlined />,
        element: <AuditLogs />,
      },
    ],
  },
];
/** 从路由树中找到某个路径的完整面包屑链路。 */
vite.config.ts
@@ -2,6 +2,71 @@
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite';
/**
 * 布局、登录等入口页面会用到的 antd 组件。
 * 这些模块首屏必载,单独放进 antd chunk 以稳定缓存;
 * 表格、日期选择等只在业务页面使用的组件则交给 Rollup 按需分包。
 *
 * 注意:不要匹配 `antd/es/index.js`(`from 'antd'` 的 barrel 文件)。
 * manualChunks 会把 chunk 当成入口保留全部导出,barrel 进手动 chunk 后
 * 会把所有 antd 组件都拉进首屏(约 1.3MB),失去按需分包的意义。
 */
const ANTD_CORE_DIRS = [
  '_util',
  'app',
  'avatar',
  'breadcrumb',
  'button',
  'config-provider',
  'dropdown',
  'form',
  'input',
  'layout',
  'locale',
  'menu',
  'message',
  'modal',
  'notification',
  'space',
  'spin',
  'style',
  'tag',
  'theme',
  'tooltip',
  'version',
  'wave',
].join('|');
/** 第三方依赖拆包规则,页面本身通过路由懒加载按需加载。 */
const resolveVendorChunk = (id: string): string | undefined => {
  if (!id.includes('node_modules')) return undefined;
  const path = id.replace(/\\/g, '/');
  // 图标与 antd 互相引用,放在同一个 chunk,避免循环依赖。
  if (
    new RegExp(
      `\\/node_modules\\/(antd\\/es\\/(${ANTD_CORE_DIRS})\\/|@ant-design\\/(cssinjs|cssinjs-utils|colors|fast-color|icons|icons-svg)\\/)`,
    ).test(path)
  ) {
    return 'antd';
  }
  if (
    /\/node_modules\/(react|react-dom|scheduler|react-router|react-router-dom|@remix-run\/[^/]+)\//.test(
      path,
    )
  ) {
    return 'react';
  }
  if (/\/node_modules\/(axios|zustand|dayjs)\//.test(path)) {
    return 'vendor';
  }
  return undefined;
};
/** 开发环境通过 Vite 代理访问后端,避免浏览器跨域。 */
export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '');
@@ -26,5 +91,14 @@
        },
      },
    },
    build: {
      // 首屏 chunk 已控制在 600KB 以内;按需加载的 ProComponents 业务包约 830KB。
      chunkSizeWarningLimit: 900,
      rollupOptions: {
        output: {
          manualChunks: resolveVendorChunk,
        },
      },
    },
  };
});