13 files modified
6 files added
| | |
| | | - 使用 `ProTable`,不要手写「筛选表单 + Table」。 |
| | | - `columns` 必须放在独立 `columns.tsx`,类型为 `ProColumns<T>[]`;需要回调时导出 `createXxxColumns(options)` 工厂函数。 |
| | | - 页面中用 `useMemo` 缓存 columns,用 `useCallback` 包裹事件处理函数。 |
| | | - `request` 单独定义:在组件内声明 `const fetchList = useCallback((params: XxxListParams) => getXxxList(params), [])`,再传给 `request={fetchList}`;不要在 `<ProTable />` 标签里内联箭头函数。 |
| | | - `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`。 |
| | | - 页面组件不直接写请求:列表查询、删除等接口调用统一放在同目录的 `server.ts`,组件只调用导出的方法。 |
| | | - `server.ts` 使用 `@/api/request` 提供的 `request` 发送请求,不要直接使用 axios;泛型顺序为 `request<请求参数, 响应数据>`。 |
| | | - 分页使用 `defaultPageSize`(不要用受控的 `pageSize`);需要切换每页条数时设置 `showSizeChanger: true`。 |
| | |
| | | |
| | | - 页面级接口调用统一放在页面目录的 `server.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`,不要用静态方法。 |
| | |
| | | - [ ] 已选择正确分支并整体使用对应模板 |
| | | - [ ] 表格页使用 ProTable,columns 独立在 `columns.tsx` |
| | | - [ ] `request` 已提取为独立的 `useCallback` 方法,未在标签内内联 |
| | | - [ ] 接口请求使用 `.then` 链式处理,没有 `await` + `try/catch` |
| | | - [ ] 表格页无 `toolBarRender` 时已加 `cardProps={{ bodyStyle: { padding: 20 } }}`,有 `toolBarRender` 时未加 |
| | | - [ ] 非表格页未创建 `columns.tsx`,使用合适的 Ant Design 组件 |
| | | - [ ] 两个分支均已创建 `server.ts`,组件内没有直接写请求 |
| | |
| | | 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 ( |
| | |
| | | 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, |
| | | })), |
| | | [], |
| | | ); |
| | | |
| | |
| | | │ ├── business/orders/ |
| | | │ ├── customers/list/ |
| | | │ └── error/NotFound.tsx |
| | | │ # 页面目录:index.tsx、columns.tsx、types.ts、server.ts、同名 .scss(按需) |
| | | │ # 页面目录:index.tsx、columns.tsx、types.ts、server.ts、components/(按需)、同名 .scss(按需) |
| | | ├── router/ |
| | | │ ├── routeConfig.tsx # 路由 + 菜单唯一数据源(含页面组件) |
| | | │ ├── menu.tsx # 菜单过滤与 antd Menu 结构生成 |
| | |
| | | - 登录校验:`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}`。 |
| | | 4. **表单内的选择控件优先使用 ProForm 组件**:`Form` 内的 Select、TreeSelect、Cascader 等选择控件,只要选项来自接口(包括父组件传入的接口数据,如组织树、角色列表),必须使用 `@ant-design/pro-components` 提供的 `ProFormSelect`、`ProFormTreeSelect`、`ProFormCascader` 等,不要用 antd 原生控件包在 `Form.Item` 中;静态枚举选项不受此限制。远程搜索使用 `request`(配合 `debounceTime`),已有选项数据通过 `options` 或 `fieldProps` 传入,校验规则直接写在组件的 `rules` 上。 |
| | | ```tsx |
| | | // ✅ 选项来自接口 |
| | | <ProFormSelect |
| | | name="leaderId" |
| | | label="负责人" |
| | | showSearch |
| | | debounceTime={300} |
| | | request={async ({ keyWords }) => getLeaderOptions({ keyword: keyWords })} |
| | | /> |
| | | |
| | | ### 3. Table columns 必须单独提取 |
| | | // ✅ 选项由父组件传入 |
| | | <ProFormTreeSelect |
| | | name="parentId" |
| | | label="上级组织" |
| | | fieldProps={{ treeData: orgTree, showSearch: true }} |
| | | /> |
| | | |
| | | // ❌ 不要这样写 |
| | | <Form.Item name="leaderId" label="负责人"> |
| | | <Select options={leaderOptions} /> |
| | | </Form.Item> |
| | | ``` |
| | | 5. **不要手写**弹窗、Toast、下拉菜单、分页、表格、表单校验等基础交互。 |
| | | 6. 不引入新的 UI 组件库;确需新组件时,先检查是否已有可复用实现,再考虑在 `src/components/` 下新增。 |
| | | 7. 使用 Ant Design 5 的 API,不使用已废弃属性。例如 Card 使用 `variant="borderless"`,不要使用 `bordered={false}`。 |
| | | |
| | | ### 3. 独立组件必须放到 `components/`,超长页面拆分 |
| | | |
| | | 单独封装的组件不写在页面目录根部,统一放到页面目录下的 `components/` 子目录。 |
| | | |
| | | - Drawer、Modal 等弹层组件,以及详情卡片、表单、局部业务块等可独立成文件的组件,放到 `src/pages/<业务域>/<页面>/components/` 下。 |
| | | - 组件文件使用大驼峰命名,如 `UserFormDrawer.tsx`、`CustomerDetailDrawer.tsx`;组件私有的 `.scss` 与组件同目录,由组件自身引入。 |
| | | - 组件只接收 `props`,接口调用仍放在页面目录的 `server.ts`,由页面或组件按需调用;不要跨页面直接引用其他页面目录下的组件。 |
| | | - 单个页面文件(含 `index.tsx`)超过 300 行时必须拆分:按功能块抽取到 `components/`,`columns.tsx`、`types.ts`、`server.ts`、`utils.ts` 等继续单独维护。 |
| | | - 大于 300 行的复杂组件同样按功能拆分,不要在同一个文件里堆叠多个组件。 |
| | | |
| | | ```text |
| | | src/pages/system/users/ |
| | | ├── index.tsx # 页面组件(尽量精简) |
| | | ├── columns.tsx # 表格列定义 |
| | | ├── types.ts |
| | | ├── server.ts |
| | | ├── utils.ts # 按需 |
| | | ├── Users.scss |
| | | └── components/ |
| | | ├── UserFormDrawer.tsx |
| | | ├── UserDetailDrawer.tsx |
| | | └── UserImportModal.tsx |
| | | ``` |
| | | |
| | | ### 4. Table columns 必须单独提取 |
| | | |
| | | 所有表格的 `columns` 不得内联在 `<Table />` 的 JSX 中,也不写在页面组件内部。统一提取到页面目录单独维护。 |
| | | |
| | |
| | | - 页面级接口调用统一放在页面目录下的 `server.ts`,页面组件通过它调用接口,不要在组件内直接写请求实现。 |
| | | - 跨页面复用的接口可放在 `src/api/` 下,按业务域拆分文件。 |
| | | - 网络请求统一使用 `src/api/request.ts` 的 `request`(基于 axios),不要在页面或 `server.ts` 中直接使用 axios。 |
| | | - **接口调用使用 `.then` 链式处理,不要使用 `await` + `try/catch`**:成功逻辑写在 `.then`,失败逻辑写在 `.catch`(错误已由 `request` 统一提示时可只保留空 `catch`),收尾逻辑写在 `.finally`。禁止写成 `try { const data = await xxx() } catch {}`。(存量代码在后续迭代中逐步迁移。) |
| | | |
| | | ```ts |
| | | // ✅ 使用 .then 链式处理 |
| | | getOrgList({ page: 1, size: 20 }) |
| | | .then((data) => { |
| | | setDataSource(data.records); |
| | | }) |
| | | .catch(() => { |
| | | // 错误已由 request 统一提示 |
| | | }) |
| | | .finally(() => { |
| | | setLoading(false); |
| | | }); |
| | | |
| | | // ❌ 不要使用 await + try/catch |
| | | try { |
| | | const data = await getOrgList({ page: 1, size: 20 }); |
| | | setDataSource(data.records); |
| | | } catch { |
| | | // ... |
| | | } finally { |
| | | setLoading(false); |
| | | } |
| | | ``` |
| | | |
| | | - 开发环境通过 Vite 代理 `/proxy-api` 转发到 `VITE_API_PROXY_TARGET`(未配置时取 `VITE_API_BASE_URL`),因此浏览器端不会产生跨域请求;生产环境需后端配置 CORS,或使用同域反向代理。 |
| | | - 泛型:`request<TReq, TRes>(config, options)`,分别表示请求参数/请求体与响应 `data` 的类型。 |
| | | - 默认只返回响应 `data`;传 `parse: false` 时返回完整 `AxiosResponse`。 |
| | |
| | | | 部门管理 | `/system/departments` | 组织树、增删改、启用 / 停用 | |
| | | | 订单管理 | `/business/orders` | 筛选、处理订单、导出 | |
| | | | 客户列表 | `/customers/list` | 接口数据,按归属销售员筛选 | |
| | | | 审计日志 | `/platform/audit-logs` | 接口数据,多条件筛选、详情查看 | |
| | | | 404 | 任意未知地址 | 页面不存在提示 | |
| | | |
| | | ## 目录结构 |
| 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 './server'; |
| | | 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 '../server'; |
| | | 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'; |
| | |
| | | |
| | | 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} |
| | | /> |
| | |
| | | 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; |
| | |
| | | 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'; |
| | |
| | | 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" |
| | |
| | | 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 AuditLogs from '@/pages/platform/audit-logs'; |
| | | import Departments from '@/pages/system/departments'; |
| | | import Roles from '@/pages/system/roles'; |
| | | import Users from '@/pages/system/users'; |
| | |
| | | }, |
| | | ], |
| | | }, |
| | | { |
| | | path: '/platform', |
| | | title: '平台管理', |
| | | icon: <SettingOutlined />, |
| | | children: [ |
| | | { |
| | | path: '/platform/audit-logs', |
| | | title: '审计日志', |
| | | icon: <AuditOutlined />, |
| | | element: <AuditLogs />, |
| | | }, |
| | | ], |
| | | }, |
| | | ]; |
| | | |
| | | /** 从路由树中找到某个路径的完整面包屑链路。 */ |