From cd2b7d36f3610417c268d062454b7ec43e9ca937 Mon Sep 17 00:00:00 2001
From: wsj <593216709@qq.com>
Date: Wed, 28 Oct 2026 22:51:24 +0800
Subject: [PATCH] feat: 日志页面
---
src/pages/system/departments/types.ts | 13
src/pages/platform/audit-logs/AuditLogs.scss | 14 +
src/pages/system/departments/index.tsx | 18 -
CLAUDE.md | 83 ++++++
src/pages/platform/audit-logs/index.tsx | 64 ++++
.claude/skills/new-page/SKILL.md | 4
src/pages/system/departments/columns.tsx | 8
README.md | 1
.claude/skills/new-page/templates/non-table-page/index.tsx | 22
src/pages/platform/audit-logs/columns.tsx | 172 +++++++++++++
src/pages/platform/audit-logs/types.ts | 70 +++++
src/pages/system/users/columns.tsx | 11
src/router/routeConfig.tsx | 16 +
src/pages/system/users/components/UserFormDrawer.tsx | 65 ++--
src/pages/platform/audit-logs/components/AuditLogDetailDrawer.tsx | 82 ++++++
src/pages/system/departments/components/DepartmentFormDrawer.tsx | 90 ++++--
src/pages/system/departments/server.ts | 14
src/pages/platform/audit-logs/server.ts | 12
.claude/skills/new-page/templates/table-page/index.tsx | 21 +
19 files changed, 679 insertions(+), 101 deletions(-)
diff --git a/.claude/skills/new-page/SKILL.md b/.claude/skills/new-page/SKILL.md
index 4e5b61c..aed1cf0 100644
--- a/.claude/skills/new-page/SKILL.md
+++ b/.claude/skills/new-page/SKILL.md
@@ -50,7 +50,7 @@
- 使用 `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`。
@@ -87,6 +87,7 @@
- 页面级接口调用统一放在页面目录的 `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`,不要用静态方法。
@@ -134,6 +135,7 @@
- [ ] 已选择正确分支并整体使用对应模板
- [ ] 表格页使用 ProTable,columns 独立在 `columns.tsx`
- [ ] `request` 已提取为独立的 `useCallback` 方法,未在标签内内联
+- [ ] 接口请求使用 `.then` 链式处理,没有 `await` + `try/catch`
- [ ] 表格页无 `toolBarRender` 时已加 `cardProps={{ bodyStyle: { padding: 20 } }}`,有 `toolBarRender` 时未加
- [ ] 非表格页未创建 `columns.tsx`,使用合适的 Ant Design 组件
- [ ] 两个分支均已创建 `server.ts`,组件内没有直接写请求
diff --git a/.claude/skills/new-page/templates/non-table-page/index.tsx b/.claude/skills/new-page/templates/non-table-page/index.tsx
index 3830100..53cf618 100644
--- a/.claude/skills/new-page/templates/non-table-page/index.tsx
+++ b/.claude/skills/new-page/templates/non-table-page/index.tsx
@@ -9,17 +9,19 @@
const [form] = Form.useForm<XxxFormValues>();
const { message } = AntdApp.useApp();
- const handleFinish = async (values: XxxFormValues) => {
+ const handleFinish = (values: XxxFormValues) => {
setSubmitting(true);
- try {
- await saveXxx(values);
- message.success('保存成功');
- form.resetFields();
- } catch (error) {
- message.error(error instanceof Error ? error.message : '保存失败,请稍后重试');
- } finally {
- setSubmitting(false);
- }
+ saveXxx(values)
+ .then(() => {
+ message.success('保存成功');
+ form.resetFields();
+ })
+ .catch((error) => {
+ message.error(error instanceof Error ? error.message : '保存失败,请稍后重试');
+ })
+ .finally(() => {
+ setSubmitting(false);
+ });
};
return (
diff --git a/.claude/skills/new-page/templates/table-page/index.tsx b/.claude/skills/new-page/templates/table-page/index.tsx
index f7aa4a7..90befcd 100644
--- a/.claude/skills/new-page/templates/table-page/index.tsx
+++ b/.claude/skills/new-page/templates/table-page/index.tsx
@@ -20,11 +20,15 @@
okText: '删除',
cancelText: '取消',
okButtonProps: { danger: true },
- onOk: async () => {
- await deleteXxx(record.key);
- message.success('已删除');
- actionRef.current?.reload();
- },
+ onOk: () =>
+ deleteXxx(record.key)
+ .then(() => {
+ message.success('已删除');
+ actionRef.current?.reload();
+ })
+ .catch(() => {
+ // 错误已由 request 统一提示
+ }),
});
},
[message, modal],
@@ -36,7 +40,12 @@
);
const fetchList = useCallback(
- (params: XxxListParams) => getXxxList(params),
+ (params: XxxListParams) =>
+ getXxxList(params).then((result) => ({
+ data: result.data,
+ total: result.total,
+ success: result.success,
+ })),
[],
);
diff --git a/CLAUDE.md b/CLAUDE.md
index 17347af..d2ca45b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -41,7 +41,7 @@
│ ├── 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 结构生成
@@ -119,11 +119,58 @@
- 登录校验:`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 中,也不写在页面组件内部。统一提取到页面目录单独维护。
@@ -231,6 +278,32 @@
- 页面级接口调用统一放在页面目录下的 `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`。
diff --git a/README.md b/README.md
index 1c7ecde..1524946 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,7 @@
| 部门管理 | `/system/departments` | 组织树、增删改、启用 / 停用 |
| 订单管理 | `/business/orders` | 筛选、处理订单、导出 |
| 客户列表 | `/customers/list` | 接口数据,按归属销售员筛选 |
+| 审计日志 | `/platform/audit-logs` | 接口数据,多条件筛选、详情查看 |
| 404 | 任意未知地址 | 页面不存在提示 |
## 目录结构
diff --git a/src/pages/platform/audit-logs/AuditLogs.scss b/src/pages/platform/audit-logs/AuditLogs.scss
new file mode 100644
index 0000000..0059734
--- /dev/null
+++ b/src/pages/platform/audit-logs/AuditLogs.scss
@@ -0,0 +1,14 @@
+.audit-log-page {
+ &__change-detail {
+ max-height: 240px;
+ margin: 0;
+ padding: 8px 12px;
+ overflow: auto;
+ font-size: 12px;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ word-break: break-all;
+ background: var(--app-bg);
+ border-radius: 6px;
+ }
+}
diff --git a/src/pages/platform/audit-logs/columns.tsx b/src/pages/platform/audit-logs/columns.tsx
new file mode 100644
index 0000000..2ee7092
--- /dev/null
+++ b/src/pages/platform/audit-logs/columns.tsx
@@ -0,0 +1,172 @@
+import { Button, Space, Tag } from 'antd';
+import type { ProColumns } from '@ant-design/pro-components';
+import dayjs from 'dayjs';
+import type { AuditLogResultStatus, AuditLogVO } from './types';
+
+/** 业务模块(后端可能扩展,未命中的直接展示原值)。 */
+export const moduleText: Record<string, string> = {
+ EMPLOYEE: '员工',
+ ORG: '组织',
+ ROLE: '角色',
+ CUSTOMER: '客户',
+ FOLLOW_UP: '跟进',
+ TENANT: '租户',
+ SYSTEM: '系统',
+};
+
+/** 操作动作(后端可能扩展,未命中的直接展示原值)。 */
+export const actionText: Record<string, string> = {
+ CREATE: '新增',
+ UPDATE: '修改',
+ DELETE: '删除',
+ IMPORT: '导入',
+ EXPORT: '导出',
+ LOGIN: '登录',
+ LOGOUT: '退出',
+};
+
+export const resultStatusMeta: Record<AuditLogResultStatus, { text: string; color: string }> = {
+ SUCCESS: { text: '成功', color: 'success' },
+ FAILURE: { text: '失败', color: 'error' },
+};
+
+const toValueEnum = (meta: Record<string, string>) =>
+ Object.fromEntries(Object.entries(meta).map(([value, text]) => [value, { text }]));
+
+export const getModuleText = (value?: string) => (value ? moduleText[value] ?? value : '-');
+
+export const getActionText = (value?: string) => (value ? actionText[value] ?? value : '-');
+
+export const createAuditLogColumns = (options: {
+ onDetail: (record: AuditLogVO) => void;
+}): ProColumns<AuditLogVO>[] => [
+ {
+ title: '日志 ID',
+ dataIndex: 'logId',
+ width: 100,
+ search: false,
+ },
+ {
+ title: '操作时间',
+ dataIndex: 'createdAt',
+ valueType: 'dateTime',
+ width: 170,
+ search: false,
+ },
+ {
+ title: '操作人',
+ dataIndex: 'operatorName',
+ width: 140,
+ search: false,
+ render: (_value, record) => (
+ <Space direction="vertical" size={0}>
+ <span>{record.operatorName || '-'}</span>
+ {record.operatorRole && <span className="text-muted">{record.operatorRole}</span>}
+ </Space>
+ ),
+ },
+ {
+ title: '操作人 ID',
+ dataIndex: 'operatorId',
+ valueType: 'digit',
+ hideInTable: true,
+ fieldProps: { placeholder: '请输入操作人 ID' },
+ },
+ {
+ title: '业务模块',
+ dataIndex: 'module',
+ width: 100,
+ valueEnum: toValueEnum(moduleText),
+ render: (_value, record) => getModuleText(record.module),
+ },
+ {
+ title: '操作动作',
+ dataIndex: 'action',
+ width: 100,
+ valueEnum: toValueEnum(actionText),
+ render: (_value, record) => getActionText(record.action),
+ },
+ {
+ title: '操作描述',
+ dataIndex: 'operationDesc',
+ width: 120,
+ search: false,
+ ellipsis: true,
+ render: (_value, record) => record.operationDesc || '-',
+ },
+ {
+ title: '操作对象',
+ dataIndex: 'entityName',
+ width: 180,
+ search: false,
+ ellipsis: true,
+ render: (_value, record) => record.entityName || '-',
+ },
+ {
+ title: '实体类型',
+ dataIndex: 'entityType',
+ hideInTable: true,
+ fieldProps: { placeholder: '请输入实体类型' },
+ },
+ {
+ title: '请求',
+ dataIndex: 'requestPath',
+ width: 220,
+ search: false,
+ ellipsis: true,
+ render: (_value, record) => (
+ <Space size={4}>
+ <Tag bordered={false}>{record.requestMethod || '-'}</Tag>
+ <span>{record.requestPath || '-'}</span>
+ </Space>
+ ),
+ },
+ {
+ title: '来源 IP',
+ dataIndex: 'requestIp',
+ width: 130,
+ search: false,
+ },
+ {
+ title: '结果',
+ dataIndex: 'resultStatus',
+ width: 90,
+ valueEnum: {
+ SUCCESS: { text: resultStatusMeta.SUCCESS.text },
+ FAILURE: { text: resultStatusMeta.FAILURE.text },
+ },
+ render: (_value, record) => {
+ const meta = record.resultStatus ? resultStatusMeta[record.resultStatus] : undefined;
+ return meta ? (
+ <Tag color={meta.color} bordered={false}>
+ {meta.text}
+ </Tag>
+ ) : (
+ '-'
+ );
+ },
+ },
+ {
+ title: '操作',
+ key: 'option',
+ valueType: 'option',
+ width: 80,
+ render: (_value, record) => (
+ <Button type="link" size="small" onClick={() => options.onDetail(record)}>
+ 详情
+ </Button>
+ ),
+ },
+ {
+ title: '操作时间',
+ dataIndex: 'timeRange',
+ valueType: 'dateRange',
+ hideInTable: true,
+ search: {
+ transform: (value: [string, string]) => ({
+ start: value?.[0] ? dayjs(value[0]).format('YYYY-MM-DD 00:00:00') : undefined,
+ end: value?.[1] ? dayjs(value[1]).format('YYYY-MM-DD 23:59:59') : undefined,
+ }),
+ },
+ },
+];
diff --git a/src/pages/platform/audit-logs/components/AuditLogDetailDrawer.tsx b/src/pages/platform/audit-logs/components/AuditLogDetailDrawer.tsx
new file mode 100644
index 0000000..a5ebb39
--- /dev/null
+++ b/src/pages/platform/audit-logs/components/AuditLogDetailDrawer.tsx
@@ -0,0 +1,82 @@
+import { useMemo } from 'react';
+import { Descriptions, Drawer, Tag } from 'antd';
+import type { DescriptionsProps } from 'antd';
+import dayjs from 'dayjs';
+import { getActionText, getModuleText, resultStatusMeta } from '../columns';
+import type { AuditLogVO } from '../types';
+import '../AuditLogs.scss';
+
+interface AuditLogDetailDrawerProps {
+ open: boolean;
+ /** 待展示的审计日志,关闭时为 null。 */
+ record: AuditLogVO | null;
+ onClose: () => void;
+}
+
+const renderTag = (meta?: { text: string; color: string }) =>
+ meta ? (
+ <Tag color={meta.color} bordered={false}>
+ {meta.text}
+ </Tag>
+ ) : (
+ '-'
+ );
+
+const AuditLogDetailDrawer = ({ open, record, onClose }: AuditLogDetailDrawerProps) => {
+ const items = useMemo<DescriptionsProps['items']>(() => {
+ if (!record) return [];
+
+ return [
+ { key: 'logId', label: '日志 ID', children: record.logId },
+ {
+ key: 'createdAt',
+ label: '操作时间',
+ children: record.createdAt ? dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-',
+ },
+ {
+ key: 'operator',
+ label: '操作人',
+ children: `${record.operatorName || '-'}${record.operatorRole ? `(${record.operatorRole})` : ''}`,
+ },
+ { key: 'operatorId', label: '操作人 ID', children: record.operatorId ?? '-' },
+ { key: 'tenantId', label: '租户 ID', children: record.tenantId || '-' },
+ { key: 'module', label: '业务模块', children: getModuleText(record.module) },
+ { key: 'action', label: '操作动作', children: getActionText(record.action) },
+ { key: 'operationDesc', label: '操作描述', children: record.operationDesc || '-' },
+ { key: 'entityType', label: '实体类型', children: record.entityType || '-' },
+ { key: 'entityId', label: '实体 ID', children: record.entityId || '-' },
+ { key: 'entityName', label: '实体名称', children: record.entityName || '-' },
+ {
+ key: 'resultStatus',
+ label: '结果状态',
+ children: renderTag(record.resultStatus ? resultStatusMeta[record.resultStatus] : undefined),
+ },
+ { key: 'errorMessage', label: '错误信息', children: record.errorMessage || '-' },
+ { key: 'requestMethod', label: '请求方法', children: record.requestMethod || '-' },
+ { key: 'requestPath', label: '请求路径', children: record.requestPath || '-' },
+ { key: 'requestIp', label: '来源 IP', children: record.requestIp || '-' },
+ {
+ key: 'userAgent',
+ label: 'User-Agent',
+ children: <span className="text-muted">{record.userAgent || '-'}</span>,
+ },
+ {
+ key: 'changeDetail',
+ label: '变更明细',
+ children: record.changeDetail ? (
+ <pre className="audit-log-page__change-detail">{record.changeDetail}</pre>
+ ) : (
+ '-'
+ ),
+ },
+ ];
+ }, [record]);
+
+ return (
+ <Drawer title="审计日志详情" width={560} open={open} onClose={onClose}>
+ {record && <Descriptions column={1} bordered size="small" items={items} />}
+ </Drawer>
+ );
+};
+
+export default AuditLogDetailDrawer;
diff --git a/src/pages/platform/audit-logs/index.tsx b/src/pages/platform/audit-logs/index.tsx
new file mode 100644
index 0000000..e1332d7
--- /dev/null
+++ b/src/pages/platform/audit-logs/index.tsx
@@ -0,0 +1,64 @@
+import { useCallback, useMemo, useState } from 'react';
+import { ProTable } from '@ant-design/pro-components';
+import { createAuditLogColumns } from './columns';
+import AuditLogDetailDrawer from './components/AuditLogDetailDrawer';
+import { getAuditLogList } from './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;
diff --git a/src/pages/platform/audit-logs/server.ts b/src/pages/platform/audit-logs/server.ts
new file mode 100644
index 0000000..b75ab32
--- /dev/null
+++ b/src/pages/platform/audit-logs/server.ts
@@ -0,0 +1,12 @@
+import { getAuthHeaders } from '@/api/authHeaders';
+import { request } from '@/api/request';
+import type { TenantAuditLogListRequest, TenantAuditLogListResponse } from './types';
+
+/** 查询本租户审计日志(分页)。 */
+export const getAuditLogList = (params: TenantAuditLogListRequest) =>
+ request<TenantAuditLogListRequest, TenantAuditLogListResponse>({
+ url: '/tenant/audit-logs',
+ method: 'GET',
+ params,
+ headers: getAuthHeaders(),
+ });
diff --git a/src/pages/platform/audit-logs/types.ts b/src/pages/platform/audit-logs/types.ts
new file mode 100644
index 0000000..258d453
--- /dev/null
+++ b/src/pages/platform/audit-logs/types.ts
@@ -0,0 +1,70 @@
+import type { PageResult } from '@/types/api';
+
+/** 审计日志结果状态。 */
+export type AuditLogResultStatus = 'SUCCESS' | 'FAILURE';
+
+/** 审计日志(AuditLogVO)。 */
+export interface AuditLogVO {
+ logId: number;
+ tenantId?: string;
+ operatorId?: number;
+ operatorName?: string;
+ operatorRole?: string;
+ /** 业务模块,如 EMPLOYEE / ORG / ROLE / CUSTOMER / FOLLOW_UP / TENANT / SYSTEM。 */
+ module?: string;
+ /** 操作动作,如 CREATE / UPDATE / DELETE / IMPORT。 */
+ action?: string;
+ operationDesc?: string;
+ entityType?: string;
+ entityId?: string;
+ entityName?: string;
+ changeDetail?: string;
+ requestIp?: string;
+ userAgent?: string;
+ requestPath?: string;
+ requestMethod?: string;
+ resultStatus?: AuditLogResultStatus;
+ errorMessage?: string;
+ createdAt?: string;
+}
+
+/** 审计日志分页查询参数(AuditLogPageQuery)。 */
+export interface AuditLogPageQuery {
+ /** 页码。 */
+ page?: number;
+ /** 每页条数。 */
+ size?: number;
+ /** 业务模块。 */
+ module?: string;
+ /** 操作动作。 */
+ action?: string;
+ /** 实体类型。 */
+ entityType?: string;
+ /** 结果状态。 */
+ resultStatus?: AuditLogResultStatus;
+ /** 操作人 ID。 */
+ operatorId?: number;
+ /** 开始时间(yyyy-MM-dd HH:mm)。 */
+ start?: string;
+ /** 结束时间(yyyy-MM-dd HH:mm)。 */
+ end?: string;
+}
+
+/** 审计日志列表请求参数。 */
+export interface TenantAuditLogListRequest extends AuditLogPageQuery {}
+
+/** 审计日志列表响应数据(ResultPageResultAuditLogVO 的 data)。 */
+export type TenantAuditLogListResponse = PageResult<AuditLogVO>;
+
+/** ProTable 搜索表单参数,字段与请求参数中可筛选的字段保持一致。 */
+export interface AuditLogListQuery {
+ current?: number;
+ pageSize?: number;
+ module?: string;
+ action?: string;
+ entityType?: string;
+ resultStatus?: AuditLogResultStatus;
+ operatorId?: number;
+ start?: string;
+ end?: string;
+}
diff --git a/src/pages/system/departments/columns.tsx b/src/pages/system/departments/columns.tsx
index ba67b61..766c638 100644
--- a/src/pages/system/departments/columns.tsx
+++ b/src/pages/system/departments/columns.tsx
@@ -101,7 +101,13 @@
<Button type="link" size="small" onClick={() => options.onCreateChild(record)}>
新增子部门
</Button>
- <Button type="link" size="small" onClick={() => options.onEdit(record)}>
+ {/* 总部为租户根组织,不允许编辑 */}
+ <Button
+ type="link"
+ size="small"
+ disabled={record.orgType === 'HQ'}
+ onClick={() => options.onEdit(record)}
+ >
编辑
</Button>
<Button type="link" size="small" onClick={() => options.onToggle(record)}>
diff --git a/src/pages/system/departments/components/DepartmentFormDrawer.tsx b/src/pages/system/departments/components/DepartmentFormDrawer.tsx
index b159038..126ee40 100644
--- a/src/pages/system/departments/components/DepartmentFormDrawer.tsx
+++ b/src/pages/system/departments/components/DepartmentFormDrawer.tsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { ProFormSelect, ProFormTreeSelect } from '@ant-design/pro-components';
import {
App as AntdApp,
Button,
@@ -8,9 +9,8 @@
InputNumber,
Select,
Space,
- TreeSelect,
} from 'antd';
-import { createOrg, updateOrg } from '../server';
+import { createOrg, getLeaderDetail, getLeaderOptions, updateOrg } from '../server';
import type { DepartmentFormValues, OrgVO } from '../types';
import { toOrgTreeSelectData } from '../utils';
@@ -23,8 +23,6 @@
parentId?: number;
/** 组织树,用于上级组织选择。 */
orgTree: OrgVO[];
- /** 负责人员工下拉选项。 */
- leaderOptions: { label: string; value: number }[];
onClose: () => void;
onSuccess: () => void;
}
@@ -35,7 +33,6 @@
record,
parentId,
orgTree,
- leaderOptions,
onClose,
onSuccess,
}: DepartmentFormDrawerProps) => {
@@ -43,10 +40,46 @@
const [submitting, setSubmitting] = useState(false);
const { message } = AntdApp.useApp();
const isEdit = mode === 'edit';
+ /** 新增入口只有「新增子部门」,上级组织已确定,不允许再选择。 */
+ const parentDisabled = !isEdit;
const parentOptions = useMemo(
() => toOrgTreeSelectData(orgTree, isEdit ? record?.id : undefined),
[isEdit, orgTree, record],
+ );
+
+ /** 负责人远程搜索:按姓名 / 手机号模糊查询员工。 */
+ const requestLeaderOptions = useCallback(
+ async ({ keyWords }: { keyWords?: string }) => {
+ const keyword = keyWords?.trim();
+ const result = await getLeaderOptions({
+ page: 1,
+ size: 50,
+ keyword: keyword || undefined,
+ });
+ const options = (result.records ?? []).map((employee) => ({
+ label: employee.name,
+ value: employee.id,
+ }));
+
+ // 编辑时把当前负责人补进首屏选项,避免回填后显示为员工 ID。
+ const currentLeaderId = isEdit ? record?.leaderId : undefined;
+ if (
+ !keyword &&
+ currentLeaderId &&
+ !options.some((option) => option.value === currentLeaderId)
+ ) {
+ try {
+ const leader = await getLeaderDetail({ id: currentLeaderId });
+ options.unshift({ label: leader.name, value: leader.id });
+ } catch {
+ // 错误已由 request 统一提示
+ }
+ }
+
+ return options;
+ },
+ [isEdit, record],
);
useEffect(() => {
@@ -97,7 +130,7 @@
}
}, [form, isEdit, message, onSuccess, record]);
- const title = isEdit ? '编辑部门' : parentId ? '新增子部门' : '新增部门';
+ const title = isEdit ? '编辑部门' : '新增子部门';
return (
<Drawer
@@ -140,26 +173,31 @@
]}
/>
</Form.Item>
- <Form.Item name="parentId" label="上级组织">
- <TreeSelect
- treeData={parentOptions}
- treeDefaultExpandAll
- allowClear
- showSearch
- treeNodeFilterProp="title"
- placeholder="不选则作为根组织"
- style={{ width: '100%' }}
- />
- </Form.Item>
- <Form.Item name="leaderId" label="负责人">
- <Select
- options={leaderOptions}
- showSearch
- optionFilterProp="label"
- allowClear
- placeholder="请选择负责人"
- />
- </Form.Item>
+ <ProFormTreeSelect
+ name="parentId"
+ label="上级组织"
+ placeholder="不选则作为根组织"
+ disabled={parentDisabled}
+ fieldProps={{
+ treeData: parentOptions,
+ treeDefaultExpandAll: true,
+ allowClear: true,
+ showSearch: true,
+ treeNodeFilterProp: 'title',
+ style: { width: '100%' },
+ }}
+ />
+ <ProFormSelect
+ name="leaderId"
+ label="负责人"
+ rules={isEdit ? undefined : [{ required: true, message: '请选择负责人' }]}
+ placeholder="请输入姓名搜索负责人"
+ showSearch
+ allowClear
+ debounceTime={300}
+ request={requestLeaderOptions}
+ fieldProps={{ filterOption: false, style: { width: '100%' } }}
+ />
<Form.Item name="region" label="所在地区">
<Input placeholder="请输入所在地区" maxLength={50} />
</Form.Item>
diff --git a/src/pages/system/departments/index.tsx b/src/pages/system/departments/index.tsx
index c62577d..781f807 100644
--- a/src/pages/system/departments/index.tsx
+++ b/src/pages/system/departments/index.tsx
@@ -1,9 +1,8 @@
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';
@@ -26,7 +25,7 @@
const loadLeaders = async () => {
try {
- const result = await getLeaderOptions();
+ const result = await getLeaderOptions({ page: 1, size: 200 });
if (!active) return;
setLeaderOptions(
@@ -59,13 +58,6 @@
const data = filterOrgTree(list, params);
return { data, total: data.length, success: true };
- }, []);
-
- const openCreateRoot = useCallback(() => {
- setActiveOrg(null);
- setParentId(undefined);
- setFormMode('create');
- setFormOpen(true);
}, []);
const openCreateChild = useCallback((record: OrgVO) => {
@@ -155,11 +147,6 @@
expandedRowKeys,
onExpandedRowsChange: (keys) => setExpandedRowKeys([...keys]),
}}
- toolBarRender={() => [
- <Button key="create" type="primary" icon={<PlusOutlined />} onClick={openCreateRoot}>
- 新增部门
- </Button>,
- ]}
request={fetchDepartments}
/>
@@ -169,7 +156,6 @@
record={activeOrg}
parentId={parentId}
orgTree={orgTree}
- leaderOptions={leaderOptions}
onClose={closeForm}
onSuccess={handleFormSuccess}
/>
diff --git a/src/pages/system/departments/server.ts b/src/pages/system/departments/server.ts
index cf003c6..64594ef 100644
--- a/src/pages/system/departments/server.ts
+++ b/src/pages/system/departments/server.ts
@@ -1,6 +1,8 @@
import { getAuthHeaders } from '@/api/authHeaders';
import { request } from '@/api/request';
import type {
+ DepartmentLeaderDetailRequest,
+ DepartmentLeaderDetailResponse,
TenantEmployeeOptionListRequest,
TenantEmployeeOptionListResponse,
TenantOrgCreateRequest,
@@ -59,11 +61,19 @@
headers: getAuthHeaders(),
});
-/** 员工列表,用于负责人下拉选项。 */
-export const getLeaderOptions = (params: TenantEmployeeOptionListRequest = { page: 1, size: 200 }) =>
+/** 员工列表,用于负责人下拉远程搜索。 */
+export const getLeaderOptions = (params: TenantEmployeeOptionListRequest) =>
request<TenantEmployeeOptionListRequest, TenantEmployeeOptionListResponse>({
url: '/tenant/employees',
method: 'GET',
params,
headers: getAuthHeaders(),
});
+
+/** 员工详情,用于编辑时回填负责人姓名。 */
+export const getLeaderDetail = ({ id }: DepartmentLeaderDetailRequest) =>
+ request<DepartmentLeaderDetailRequest, DepartmentLeaderDetailResponse>({
+ url: `/tenant/employees/${id}`,
+ method: 'GET',
+ headers: getAuthHeaders(),
+ });
diff --git a/src/pages/system/departments/types.ts b/src/pages/system/departments/types.ts
index c56653a..8f53413 100644
--- a/src/pages/system/departments/types.ts
+++ b/src/pages/system/departments/types.ts
@@ -72,10 +72,12 @@
/** PUT /tenant/organizations/{id}/toggle-status 响应数据(ResultVoid 的 data)。 */
export type TenantOrgToggleStatusResponse = void;
-/** GET /tenant/employees 请求参数(仅用于负责人下拉)。 */
+/** GET /tenant/employees 请求参数(负责人下拉远程搜索)。 */
export interface TenantEmployeeOptionListRequest {
page?: number;
size?: number;
+ /** 姓名 / 手机号模糊搜索。 */
+ keyword?: string;
}
/** 负责人选项(EmployeeVO 子集)。 */
@@ -88,6 +90,15 @@
/** GET /tenant/employees 响应数据(ResultPageResultEmployeeVO 的 data)。 */
export type TenantEmployeeOptionListResponse = PageResult<DepartmentLeaderOption>;
+/** GET /tenant/employees/{id} 请求参数(编辑时回填负责人姓名)。 */
+export interface DepartmentLeaderDetailRequest {
+ /** 员工 ID(路径参数)。 */
+ id: number;
+}
+
+/** GET /tenant/employees/{id} 响应数据(EmployeeVO 子集)。 */
+export type DepartmentLeaderDetailResponse = DepartmentLeaderOption;
+
/** ProTable 筛选参数:组织树接口没有查询参数,这些字段仅用于前端本地过滤。 */
export interface DepartmentListQuery {
orgName?: string;
diff --git a/src/pages/system/users/columns.tsx b/src/pages/system/users/columns.tsx
index 8640ce5..75f4180 100644
--- a/src/pages/system/users/columns.tsx
+++ b/src/pages/system/users/columns.tsx
@@ -7,10 +7,13 @@
DISABLED: { text: '禁用', color: 'default' },
};
-/** 兼容后端返回未知或小写状态,避免直接索引得到 undefined。 */
-export const getEmployeeStatusMeta = (status?: string) => {
- const key = String(status ?? '').toUpperCase() as EmployeeStatus;
- return employeeStatusMeta[key] ?? { text: status || '-', color: 'default' };
+/** 兼容后端返回 null、小写或未知状态:ACTIVE 与空值均为启用,仅 DISABLED 为禁用。 */
+export const getEmployeeStatusMeta = (status?: string | null) => {
+ const key = String(status ?? '').toUpperCase();
+
+ if (!key || key === 'ACTIVE') return employeeStatusMeta.ACTIVE;
+ if (key === 'DISABLED') return employeeStatusMeta.DISABLED;
+ return { text: status || '-', color: 'default' };
};
export const createUserColumns = (options: {
diff --git a/src/pages/system/users/components/UserFormDrawer.tsx b/src/pages/system/users/components/UserFormDrawer.tsx
index b71a3f6..4c5ffd2 100644
--- a/src/pages/system/users/components/UserFormDrawer.tsx
+++ b/src/pages/system/users/components/UserFormDrawer.tsx
@@ -1,13 +1,12 @@
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';
@@ -24,6 +23,10 @@
label: string;
children?: OrgCascaderOption[];
}
+
+/** 去掉最外层公司节点,只保留公司下的组织用于选择。 */
+const getSelectableOrgNodes = (nodes: OrgTreeNode[]): OrgTreeNode[] =>
+ nodes.flatMap((node) => (node.children?.length ? node.children : [node]));
/** 组织树转为级联选择选项,按层级展示。 */
const toCascaderOptions = (nodes: OrgTreeNode[]): OrgCascaderOption[] =>
@@ -65,7 +68,7 @@
): EmployeeFormValues => ({
name: employee.name,
phone: employee.phone,
- orgPath: findOrgPath(orgTree, employee.orgId) ?? [],
+ orgPath: findOrgPath(getSelectableOrgNodes(orgTree), employee.orgId) ?? [],
roleId: employee.roleId,
position: employee.position,
email: employee.email,
@@ -87,7 +90,10 @@
const [loading, setLoading] = useState(false);
const { message } = AntdApp.useApp();
const isDetail = mode === 'detail';
- const orgCascaderOptions = useMemo(() => toCascaderOptions(orgTree), [orgTree]);
+ const orgCascaderOptions = useMemo(
+ () => toCascaderOptions(getSelectableOrgNodes(orgTree)),
+ [orgTree],
+ );
useEffect(() => {
if (!open) return;
@@ -196,30 +202,39 @@
>
<Input placeholder="请输入手机号" maxLength={11} disabled={isDetail} />
</Form.Item>
- <Form.Item
+ <ProFormCascader
name="orgPath"
label="所属组织"
rules={[{ required: true, message: '请选择所属组织' }]}
- >
- <Cascader
- options={orgCascaderOptions}
- changeOnSelect
- showSearch
- placeholder="请选择所属组织"
- disabled={isDetail}
- style={{ width: '100%' }}
- />
- </Form.Item>
- <Form.Item
+ placeholder="请选择所属组织"
+ disabled={isDetail}
+ fieldProps={{
+ options: orgCascaderOptions,
+ changeOnSelect: true,
+ showSearch: true,
+ style: { width: '100%' },
+ }}
+ />
+ <ProFormSelect
name="roleId"
label="角色"
rules={[{ required: true, message: '请选择角色' }]}
+ placeholder="请选择角色"
+ disabled={isDetail}
+ showSearch
+ options={roleOptions}
+ fieldProps={{ optionFilterProp: 'label', style: { width: '100%' } }}
+ />
+ <Form.Item
+ name="holdLimit"
+ label="客户持有上限"
+ rules={[{ required: true, message: '请输入客户持有上限' }]}
>
- <Select
- showSearch
- optionFilterProp="label"
- options={roleOptions}
- placeholder="请选择角色"
+ <InputNumber
+ min={0}
+ precision={0}
+ placeholder="请输入客户持有上限"
+ style={{ width: '100%' }}
disabled={isDetail}
/>
</Form.Item>
@@ -228,14 +243,6 @@
</Form.Item>
<Form.Item name="region" label="负责区域">
<Input placeholder="请输入负责区域" disabled={isDetail} />
- </Form.Item>
- <Form.Item name="holdLimit" label="客户持有上限">
- <InputNumber
- min={0}
- precision={0}
- style={{ width: '100%' }}
- disabled={isDetail}
- />
</Form.Item>
<Form.Item
name="email"
diff --git a/src/router/routeConfig.tsx b/src/router/routeConfig.tsx
index 27ce482..dbb62a7 100644
--- a/src/router/routeConfig.tsx
+++ b/src/router/routeConfig.tsx
@@ -2,17 +2,20 @@
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';
@@ -90,6 +93,19 @@
},
],
},
+ {
+ path: '/platform',
+ title: '平台管理',
+ icon: <SettingOutlined />,
+ children: [
+ {
+ path: '/platform/audit-logs',
+ title: '审计日志',
+ icon: <AuditOutlined />,
+ element: <AuditLogs />,
+ },
+ ],
+ },
];
/** 从路由树中找到某个路径的完整面包屑链路。 */
--
Gitblit v1.9.3