21 files modified
6 files renamed
8 files added
| | |
| | | --- |
| | | name: new-page |
| | | description: 当用户要创建新页面、新建新页面、新建页面、新增页面、加一个页面或搭建页面脚手架时使用。先选择分支:表格/列表页使用 ProTable 模板,表单/详情/仪表盘等非表格页使用 Ant Design 模板;两个分支都会生成 server.ts 维护接口调用,并统一执行代码规范、路由注册、文档更新与类型检查。 |
| | | description: 当用户要创建新页面、新建新页面、新建页面、新增页面、加一个页面或搭建页面脚手架时使用。先选择分支:表格/列表页使用 ProTable 模板,表单/详情/仪表盘等非表格页使用 Ant Design 模板;两个分支都会生成 service.ts 维护接口调用,并统一执行代码规范、路由注册、文档更新与类型检查。 |
| | | --- |
| | | |
| | | # 新增页面 |
| | |
| | | | 页面名 | 目录名与组件名 | `invoices` / `Invoices` | |
| | | | 路由 | 完整路径,同时作为菜单 key | `/business/invoices` | |
| | | | 菜单标题 / 图标 | 中文标题 + `@ant-design/icons` | 发票管理 / `FileTextOutlined` | |
| | | | 数据来源 | 接口调用放在同目录 `server.ts`;演示阶段可先用本地 mock | `server.ts` | |
| | | | 数据来源 | 接口调用放在同目录 `service.ts`;演示阶段可先用本地 mock | `service.ts` | |
| | | | 是否分组 | 是否放入现有的「系统管理 / 业务管理」分组 | `children` | |
| | | |
| | | 表格页额外确认:字段、列展示方式、可搜索字段、分页大小。 |
| | |
| | | ├── index.tsx # 页面组件(默认导出) |
| | | ├── columns.tsx # ProColumns<T>[] 列定义 |
| | | ├── types.ts # 记录类型与查询参数 |
| | | ├── server.ts # 接口调用(列表查询、删除等) |
| | | ├── service.ts # 接口调用(列表查询、删除等) |
| | | └── <Page>.scss # 页面级样式(按需新增) |
| | | ``` |
| | | |
| | |
| | | - 使用 `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 } }}`。 |
| | |
| | | src/pages/<domain>/<page>/ |
| | | ├── index.tsx # 页面组件(默认导出) |
| | | ├── types.ts # 表单 / 详情类型 |
| | | ├── server.ts # 接口调用(保存、详情查询等) |
| | | ├── service.ts # 接口调用(保存、详情查询等) |
| | | └── <Page>.scss # 页面级样式(按需新增) |
| | | ``` |
| | | |
| | |
| | | - 表单使用 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`,不要用静态方法。 |
| | |
| | | |
| | | ### 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: '发票管理', |
| | |
| | | }, |
| | | ``` |
| | | |
| | | 放入现有分组时写在其 `children` 中;新增一级分组时保持与现有分组相同的结构。 |
| | | 页面组件必须用 `lazy(() => import(...))` 注册,不要静态 import,否则会把页面打进首屏 chunk。放入现有分组时写在其 `children` 中;新增一级分组时保持与现有分组相同的结构。 |
| | | |
| | | ### 4.3 更新文档 |
| | | |
| | |
| | | - [ ] 已选择正确分支并整体使用对应模板 |
| | | - [ ] 表格页使用 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()` 获取 |
| | |
| | | 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'; |
| | | |
| | |
| | | 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 ( |
| | |
| | | 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'; |
| | | |
| | |
| | | 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], |
| | |
| | | ); |
| | | |
| | | const fetchList = useCallback( |
| | | (params: XxxListParams) => getXxxList(params), |
| | | (params: XxxListParams) => |
| | | getXxxList(params).then((result) => ({ |
| | | data: result.data, |
| | | total: result.total, |
| | | success: result.success, |
| | | })), |
| | | [], |
| | | ); |
| | | |
| | |
| | | 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 |
| | |
| | | # 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)。 |
| | | |
| | | ## 一、常用命令 |
| | | |
| | |
| | | |
| | | 改动代码后至少执行 `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 () => { |
| | | // ... |
| | |
| | | }; |
| | | |
| | | 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。新增页面时保持懒加载即可,无需再调整拆包配置。 |
| | |
| | | - 通过 `signal: AbortSignal` 取消调用方的请求。 |
| | | - 失败时抛出 `ApiError`,保留 `status`、`data`、`response`、`config` 与原始错误。 |
| | | |
| | | 页面级接口调用放在页面目录的 `server.ts`,组件通过它调用请求。 |
| | | 页面级接口调用放在页面目录的 `service.ts`,组件通过它调用请求。 |
| | | |
| | | ### 5. 演示页面 |
| | | |
| | |
| | | | 部门管理 | `/system/departments` | 组织树、增删改、启用 / 停用 | |
| | | | 订单管理 | `/business/orders` | 筛选、处理订单、导出 | |
| | | | 客户列表 | `/customers/list` | 接口数据,按归属销售员筛选 | |
| | | | 审计日志 | `/platform/audit-logs` | 接口数据,多条件筛选、详情查看 | |
| | | | 404 | 任意未知地址 | 页面不存在提示 | |
| | | |
| | | ## 目录结构 |
| | |
| | | |
| | | 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); |
| New file |
| | |
| | | .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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | |
| | | import { useEffect, useMemo, useState } from 'react'; |
| | | import { Suspense, useEffect, useMemo, useState } from 'react'; |
| | | import { |
| | | BellOutlined, |
| | | DownOutlined, |
| | |
| | | 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'; |
| | |
| | | </Header> |
| | | |
| | | <Content className="app-content"> |
| | | <Outlet /> |
| | | {/* 路由懒加载边界放在内容区内,切页时侧边栏与头部保持不动 */} |
| | | <Suspense fallback={<PageLoading />}> |
| | | <Outlet /> |
| | | </Suspense> |
| | | </Content> |
| | | |
| | | <Footer className="app-footer"> |
| | |
| | | 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'; |
| | | |
| | |
| | | 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'; |
| | |
| | | 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 = () => { |
| New file |
| | |
| | | .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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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, |
| | | }), |
| | | }, |
| | | }, |
| | | ]; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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(), |
| | | }); |
| New file |
| | |
| | | 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; |
| | | } |
| | |
| | | <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)}> |
| | |
| | | import { useCallback, useEffect, useMemo, useState } from 'react'; |
| | | import { ProFormSelect, ProFormTreeSelect } from '@ant-design/pro-components'; |
| | | import { |
| | | App as AntdApp, |
| | | Button, |
| | |
| | | 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'; |
| | | |
| | |
| | | parentId?: number; |
| | | /** 组织树,用于上级组织选择。 */ |
| | | orgTree: OrgVO[]; |
| | | /** 负责人员工下拉选项。 */ |
| | | leaderOptions: { label: string; value: number }[]; |
| | | onClose: () => void; |
| | | onSuccess: () => void; |
| | | } |
| | |
| | | record, |
| | | parentId, |
| | | orgTree, |
| | | leaderOptions, |
| | | onClose, |
| | | onSuccess, |
| | | }: DepartmentFormDrawerProps) => { |
| | |
| | | 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(() => { |
| | |
| | | } |
| | | }, [form, isEdit, message, onSuccess, record]); |
| | | |
| | | const title = isEdit ? '编辑部门' : parentId ? '新增子部门' : '新增部门'; |
| | | const title = isEdit ? '编辑部门' : '新增子部门'; |
| | | |
| | | return ( |
| | | <Drawer |
| | |
| | | ]} |
| | | /> |
| | | </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> |
| | |
| | | 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'; |
| | | |
| | |
| | | |
| | | const loadLeaders = async () => { |
| | | try { |
| | | const result = await getLeaderOptions(); |
| | | const result = await getLeaderOptions({ page: 1, size: 200 }); |
| | | if (!active) return; |
| | | |
| | | setLeaderOptions( |
| | |
| | | |
| | | 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) => { |
| | |
| | | expandedRowKeys, |
| | | onExpandedRowsChange: (keys) => setExpandedRowKeys([...keys]), |
| | | }} |
| | | toolBarRender={() => [ |
| | | <Button key="create" type="primary" icon={<PlusOutlined />} onClick={openCreateRoot}> |
| | | 新增部门 |
| | | </Button>, |
| | | ]} |
| | | request={fetchDepartments} |
| | | /> |
| | | |
| | |
| | | record={activeOrg} |
| | | parentId={parentId} |
| | | orgTree={orgTree} |
| | | leaderOptions={leaderOptions} |
| | | onClose={closeForm} |
| | | onSuccess={handleFormSuccess} |
| | | /> |
| File was renamed from src/pages/system/departments/server.ts |
| | |
| | | import { getAuthHeaders } from '@/api/authHeaders'; |
| | | import { request } from '@/api/request'; |
| | | import type { |
| | | DepartmentLeaderDetailRequest, |
| | | DepartmentLeaderDetailResponse, |
| | | TenantEmployeeOptionListRequest, |
| | | TenantEmployeeOptionListResponse, |
| | | TenantOrgCreateRequest, |
| | |
| | | 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(), |
| | | }); |
| | |
| | | /** 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 子集)。 */ |
| | |
| | | /** 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; |
| | |
| | | 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 = () => { |
| | |
| | | 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: { |
| | |
| | | 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, |
| | |
| | | label: string; |
| | | children?: OrgCascaderOption[]; |
| | | } |
| | | |
| | | /** 去掉最外层公司节点,只保留公司下的组织用于选择。 */ |
| | | const getSelectableOrgNodes = (nodes: OrgTreeNode[]): OrgTreeNode[] => |
| | | nodes.flatMap((node) => (node.children?.length ? node.children : [node])); |
| | | |
| | | /** 组织树转为级联选择选项,按层级展示。 */ |
| | | const toCascaderOptions = (nodes: OrgTreeNode[]): OrgCascaderOption[] => |
| | |
| | | ): 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, |
| | |
| | | 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; |
| | |
| | | > |
| | | <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> |
| | |
| | | </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" |
| | |
| | | getOrgTree, |
| | | getRoleOptions, |
| | | importEmployees, |
| | | } from './server'; |
| | | } from './service'; |
| | | import type { |
| | | EmployeeListQuery, |
| | | EmployeeVO, |
| | |
| | | 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 */ |
| | |
| | | }, |
| | | ], |
| | | }, |
| | | { |
| | | path: '/platform', |
| | | title: '平台管理', |
| | | icon: <SettingOutlined />, |
| | | children: [ |
| | | { |
| | | path: '/platform/audit-logs', |
| | | title: '审计日志', |
| | | icon: <AuditOutlined />, |
| | | element: <AuditLogs />, |
| | | }, |
| | | ], |
| | | }, |
| | | ]; |
| | | |
| | | /** 从路由树中找到某个路径的完整面包屑链路。 */ |
| | |
| | | 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(), ''); |
| | |
| | | }, |
| | | }, |
| | | }, |
| | | build: { |
| | | // 首屏 chunk 已控制在 600KB 以内;按需加载的 ProComponents 业务包约 830KB。 |
| | | chunkSizeWarningLimit: 900, |
| | | rollupOptions: { |
| | | output: { |
| | | manualChunks: resolveVendorChunk, |
| | | }, |
| | | }, |
| | | }, |
| | | }; |
| | | }); |