feat(admin): 新增后台审计日志视图
This commit is contained in:
+3
-3
@@ -194,12 +194,12 @@ dist/assets/AdminLayout-*.js 36.87 kB │ gzip 13.02 kB
|
||||
## 5. 已知限制
|
||||
|
||||
1. 移动端订单列表/详情与消息中心已接入真实接口;下单、支付、交接、归还、结账等操作仍待迁移。
|
||||
2. 后台已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁、财务看板、公告管理,其余页面仍为统一占位。
|
||||
2. 后台已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁、财务看板、公告管理、审计日志,其余页面仍为统一占位。
|
||||
3. 分销后台已完成主题、Logo、登录页与 Shell 轻改,页面结构未重写。
|
||||
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`,申诉 `dispute:view` / `dispute:arbitrate`,财务 `wallet:view`,公告 `announcement:view` / `announcement:manage`;其余页面仍需核对。
|
||||
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`,申诉 `dispute:view` / `dispute:arbitrate`,财务 `wallet:view`,公告 `announcement:view` / `announcement:manage`,审计日志 `audit_log:view`;系统配置与分销站点仍需核对。
|
||||
5. SVG 中文尚未转曲。
|
||||
6. 入口包已降至 495 kB(gzip 161 kB);AntD 共享基础设施仍在入口,后续可拆独立入口。
|
||||
7. 移动端、订单、分销轻改与首屏优化均已提交;商品管理与审核在本批次提交。
|
||||
7. 移动端、订单、分销轻改与首屏优化均已提交;商品管理、审核、退款、提现、用户、申诉、财务、公告与审计日志已按批次提交。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Phase 2 验收报告(后台审计日志)
|
||||
|
||||
> 日期:2026-10-30
|
||||
> 项目:解忧商行(hfb_jysh)
|
||||
> 范围:`/console-jy8k3m/audit-logs`
|
||||
> 结论:**本切片通过,审计日志列表、筛选、分页与明细抽屉已接真实接口**
|
||||
|
||||
---
|
||||
|
||||
## 1. 接口与权限
|
||||
|
||||
| 能力 | 接口 | 权限码 |
|
||||
| --- | --- | --- |
|
||||
| 审计日志列表 | `GET /api/admin/audit-logs` | `audit_log:view` |
|
||||
|
||||
查询参数:
|
||||
|
||||
```text
|
||||
actor_id 管理员 ID,正整数
|
||||
action 动作代码,精确匹配
|
||||
biz_type 业务类型,精确匹配
|
||||
page 页码,默认 1
|
||||
page_size 每页条数,默认 20,最大 100
|
||||
```
|
||||
|
||||
响应字段:`items`、`total`、`page`、`page_size`。单条记录包含 `id`、`actor_type`、`actor_id`、`actor_username`、`actor_nickname`、`action`、`biz_type`、`biz_id`、`ip`、`user_agent`、`detail`、`created_at`。
|
||||
|
||||
## 2. 页面
|
||||
|
||||
`frontend/src/features/admin/AdminAuditLogsPage.tsx`
|
||||
|
||||
- 筛选:管理员 ID、动作、业务类型
|
||||
- 动作与业务类型支持下拉建议,也允许直接输入后端动作代码
|
||||
- 列:时间/编号、操作人、动作、业务对象、IP、变更摘要、操作
|
||||
- 分页 10 / 20 / 50
|
||||
- 行点击或「详情」打开右侧抽屉
|
||||
- 无 `audit_log:view` 权限时显示提示并停止请求
|
||||
|
||||
## 3. 明细抽屉
|
||||
|
||||
- 操作信息:发生时间、日志编号、操作人、操作人类型、动作代码、业务对象
|
||||
- 请求信息:来源 IP、User-Agent
|
||||
- 变更明细:`detail` 统一格式化为两空格缩进 JSON,等宽字体、可滚动
|
||||
- `detail` 兼容 JSON 对象、JSON 字符串与 `null`,无法解析的字符串按原文展示
|
||||
|
||||
## 4. 动作与业务类型文案
|
||||
|
||||
在 `frontend/src/features/admin/labels.ts` 增加:
|
||||
|
||||
- `auditActorTypeMap`:管理员 / 用户 / 系统
|
||||
- `auditBizTypeMap`:订单、商品、申诉、用户资料、后台账号、系统配置、支付配置、人工打款、运营支出、捡漏、分销、公告
|
||||
- `auditActionMap`:订单、商品、申诉、用户、配置、打款、支出、捡漏、分销与令牌刷新等已知动作的中文名称
|
||||
|
||||
未知动作或业务类型保留原始代码,避免后端新增动作时前端丢失信息。
|
||||
|
||||
## 5. 菜单与路由
|
||||
|
||||
- 路由:`frontend/src/app/AppRouter.tsx` 新增 `audit-logs`
|
||||
- 菜单:`frontend/src/layouts/AdminLayout.tsx` 权限码由错误的 `audit:view` 修正为后端实际使用的 `audit_log:view`
|
||||
- 占位列表同步移除 `audit-logs`
|
||||
|
||||
## 6. 验证结果
|
||||
|
||||
```text
|
||||
npx tsc --noEmit 通过
|
||||
npm run build 通过(入口 497.18 kB,gzip 161.36 kB)
|
||||
GET /api/admin/audit-logs?page=1&page_size=5 ok,total=1
|
||||
GET /api/admin/audit-logs?actor_id=1&action=... ok,筛选命中 1 条
|
||||
GET /console-jy8k3m/audit-logs 200
|
||||
```
|
||||
|
||||
开发库当前只有 1 条改密产生的审计记录;列表读取与筛选已实测,未触发新的资金或配置写操作。
|
||||
|
||||
## 7. 已知限制
|
||||
|
||||
1. 没有动作字典接口,筛选项来自前端维护的已知动作清单,后端新增动作需同步文案(未知动作仍可展示与按代码筛选)。
|
||||
2. 不支持时间范围筛选与关键字搜索,后端暂未提供对应参数。
|
||||
3. 没有导出 CSV 或日志归档能力。
|
||||
4. `detail` 仅在抽屉中展示,未做字段级 diff 高亮。
|
||||
|
||||
## 8. 下一步建议
|
||||
|
||||
1. 后台系统配置页。
|
||||
2. 线下出款与运营支出登记。
|
||||
3. 审计日志时间范围与关键字筛选(需后端补参数)。
|
||||
4. 移动端下单、支付与交接/归还/结账。
|
||||
+1
-1
@@ -395,7 +395,7 @@ hfb_jysh/
|
||||
|
||||
### Phase 4:管理后台核心(4–5 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板与公告管理;线下出款/运营支出、系统配置与审计日志待迁移。
|
||||
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、公告管理与审计日志;线下出款/运营支出与系统配置待迁移。
|
||||
|
||||
- 后台登录、工作台、订单列表、订单详情。
|
||||
- 商品列表、商品详情、审核。
|
||||
|
||||
@@ -103,6 +103,11 @@ const AdminAnnouncementsPage = lazy(() =>
|
||||
default: module.AdminAnnouncementsPage,
|
||||
}))
|
||||
)
|
||||
const AdminAuditLogsPage = lazy(() =>
|
||||
import('@/features/admin/AdminAuditLogsPage').then(module => ({
|
||||
default: module.AdminAuditLogsPage,
|
||||
}))
|
||||
)
|
||||
const AdminLayout = lazy(() =>
|
||||
import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout }))
|
||||
)
|
||||
@@ -113,7 +118,6 @@ const AdminSectionPlaceholder = lazy(() =>
|
||||
const adminSections: Array<{ path: string; title: string; description: string }> = [
|
||||
{ path: 'distribution/sites', title: '分销站点', description: '管理分站、规则、结算与站点钱包。' },
|
||||
{ path: 'system-configs', title: '系统配置', description: '维护首页、发布、订单与支付配置。' },
|
||||
{ path: 'audit-logs', title: '审计日志', description: '查看高风险操作与变更记录。' },
|
||||
]
|
||||
|
||||
export function AppRouter() {
|
||||
@@ -154,6 +158,7 @@ export function AppRouter() {
|
||||
<Route path="disputes" element={<AdminDisputesPage />} />
|
||||
<Route path="finance" element={<AdminFinancePage />} />
|
||||
<Route path="announcements" element={<AdminAnnouncementsPage />} />
|
||||
<Route path="audit-logs" element={<AdminAuditLogsPage />} />
|
||||
{adminSections.map(section => (
|
||||
<Route
|
||||
key={section.path}
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
AutoComplete,
|
||||
Button,
|
||||
InputNumber,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import { EyeOutlined, ReloadOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
|
||||
|
||||
import { fetchAdminAuditLogs } from '@/features/admin/api'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import { DetailDrawer, type DetailSection } from '@/features/admin/components/DetailDrawer'
|
||||
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
|
||||
import {
|
||||
auditActionMap,
|
||||
auditActionOptions,
|
||||
auditActorTypeMap,
|
||||
auditBizTypeMap,
|
||||
auditBizTypeOptions,
|
||||
} from '@/features/admin/labels'
|
||||
import type { AdminAuditLog, AdminAuditQuery } from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime, formatText } from '@/shared/utils/format'
|
||||
|
||||
const emptyFilters: AdminAuditQuery = {
|
||||
actor_id: undefined,
|
||||
action: '',
|
||||
biz_type: '',
|
||||
}
|
||||
|
||||
function parseDetail(detail: unknown): unknown {
|
||||
if (typeof detail !== 'string') return detail
|
||||
const text = detail.trim()
|
||||
if (!text) return null
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDetail(detail: unknown): string {
|
||||
const parsed = parseDetail(detail)
|
||||
if (parsed == null) return '—'
|
||||
if (typeof parsed === 'string') return parsed
|
||||
try {
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return String(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeValue(value: unknown): string {
|
||||
if (value == null) return 'null'
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeDetail(detail: unknown): string {
|
||||
const parsed = parseDetail(detail)
|
||||
if (parsed == null) return '—'
|
||||
if (typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const entries = Object.entries(parsed as Record<string, unknown>)
|
||||
if (entries.length === 0) return '{}'
|
||||
return entries
|
||||
.slice(0, 4)
|
||||
.map(([key, value]) => `${key}: ${summarizeValue(value)}`)
|
||||
.join(' · ')
|
||||
}
|
||||
const text = summarizeValue(parsed)
|
||||
return text.length > 120 ? `${text.slice(0, 120)}…` : text
|
||||
}
|
||||
|
||||
function actorName(row: AdminAuditLog) {
|
||||
return row.actor_nickname || row.actor_username || `ID ${row.actor_id}`
|
||||
}
|
||||
|
||||
function actorTypeLabel(value?: string | null) {
|
||||
if (!value) return '未知来源'
|
||||
return auditActorTypeMap[value] ?? value
|
||||
}
|
||||
|
||||
function bizTypeLabel(value?: string | null) {
|
||||
if (!value) return '未关联业务'
|
||||
return auditBizTypeMap[value] ?? value
|
||||
}
|
||||
|
||||
function buildColumns(onOpen: (row: AdminAuditLog) => void): TableColumnsType<AdminAuditLog> {
|
||||
return [
|
||||
{
|
||||
title: '时间 / 编号',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
fixed: 'left',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<strong>{formatDateTime(row.created_at)}</strong>
|
||||
<span className="admin-mono">#{row.id}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
key: 'actor',
|
||||
width: 210,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<button type="button" className="admin-order-link" onClick={() => onOpen(row)}>
|
||||
{actorName(row)}
|
||||
</button>
|
||||
<span>
|
||||
{actorTypeLabel(row.actor_type)}
|
||||
{row.actor_username ? ` · ${row.actor_username}` : ''}
|
||||
</span>
|
||||
<span className="admin-mono">ID {row.actor_id}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '动作',
|
||||
dataIndex: 'action',
|
||||
width: 230,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<strong>{auditActionMap[row.action] ?? row.action}</strong>
|
||||
<span className="admin-mono">{row.action}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '业务对象',
|
||||
key: 'biz',
|
||||
width: 150,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>{bizTypeLabel(row.biz_type)}</span>
|
||||
<span className="admin-mono">{row.biz_id != null ? `#${row.biz_id}` : '无关联 ID'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'IP',
|
||||
dataIndex: 'ip',
|
||||
width: 140,
|
||||
render: value => <span className="admin-mono">{formatText(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '变更摘要',
|
||||
key: 'summary',
|
||||
width: 320,
|
||||
render: (_, row) => (
|
||||
<Typography.Text type="secondary" ellipsis={{ tooltip: summarizeDetail(row.detail) }}>
|
||||
<span className="admin-audit-summary">{summarizeDetail(row.detail)}</span>
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 92,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => onOpen(row)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function buildSections(row: AdminAuditLog): DetailSection[] {
|
||||
return [
|
||||
{
|
||||
key: 'actor',
|
||||
title: '操作信息',
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
fields: [
|
||||
{
|
||||
key: 'time',
|
||||
label: '发生时间',
|
||||
value: formatDateTime(row.created_at),
|
||||
},
|
||||
{
|
||||
key: 'log_id',
|
||||
label: '日志编号',
|
||||
value: <span className="admin-mono">#{row.id}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actor',
|
||||
label: '操作人',
|
||||
value: (
|
||||
<div className="admin-stack">
|
||||
<strong>{actorName(row)}</strong>
|
||||
<span>{actorTypeLabel(row.actor_type)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actor_id',
|
||||
label: '操作人 ID',
|
||||
value: <span className="admin-mono">{row.actor_id}</span>,
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: '动作',
|
||||
value: (
|
||||
<div className="admin-stack">
|
||||
<strong>{auditActionMap[row.action] ?? row.action}</strong>
|
||||
<span className="admin-mono">{row.action}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'biz',
|
||||
label: '业务对象',
|
||||
value: (
|
||||
<div className="admin-stack">
|
||||
<strong>{bizTypeLabel(row.biz_type)}</strong>
|
||||
<span className="admin-mono">{row.biz_id != null ? `#${row.biz_id}` : '—'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'request',
|
||||
title: '请求信息',
|
||||
fields: [
|
||||
{
|
||||
key: 'ip',
|
||||
label: '来源 IP',
|
||||
value: <span className="admin-mono">{formatText(row.ip)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'user_agent',
|
||||
label: 'User-Agent',
|
||||
span: 2,
|
||||
value: <span className="admin-audit-ua">{formatText(row.user_agent)}</span>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'detail',
|
||||
title: '变更明细',
|
||||
fields: [
|
||||
{
|
||||
key: 'json',
|
||||
label: '原始 JSON',
|
||||
span: 2,
|
||||
value: <pre className="admin-detail__pre admin-detail__pre--tall">{normalizeDetail(row.detail)}</pre>,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function AdminAuditLogsPage() {
|
||||
const [draft, setDraft] = useState<AdminAuditQuery>(emptyFilters)
|
||||
const [applied, setApplied] = useState<AdminAuditQuery>(emptyFilters)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [selected, setSelected] = useState<AdminAuditLog | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const canView = Boolean(
|
||||
permissions?.includes('*') || permissions?.includes('audit_log:view')
|
||||
)
|
||||
|
||||
const { data, isPending, isFetching, isError, error, refetch } = useQuery({
|
||||
queryKey: ['admin-audit-logs', applied, page, pageSize],
|
||||
queryFn: () => fetchAdminAuditLogs({ ...applied, page, page_size: pageSize }),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: canView,
|
||||
})
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
buildColumns(row => {
|
||||
setSelected(row)
|
||||
setDetailOpen(true)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const patchFilter = (patch: Partial<AdminAuditQuery>) => {
|
||||
setDraft(current => ({ ...current, ...patch }))
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
setApplied({ ...draft })
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
setDraft(emptyFilters)
|
||||
setApplied(emptyFilters)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const hasActiveFilters = Boolean(draft.actor_id || draft.action || draft.biz_type)
|
||||
const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。'
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 系统"
|
||||
title="审计日志"
|
||||
description="追踪后台高风险操作、资金变更与关键配置调整,保留操作前后的完整明细。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">共 {data?.total ?? 0} 条</Tag>
|
||||
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
|
||||
<Tag>只读</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => void refetch()}
|
||||
loading={isFetching}
|
||||
disabled={!canView}
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{!canView ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="当前账号没有审计日志查看权限"
|
||||
description="需要 audit_log:view 权限才能读取后台操作记录,请联系超级管理员分配角色。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FilterBar
|
||||
onSearch={applyFilters}
|
||||
onReset={hasActiveFilters ? resetFilters : undefined}
|
||||
loading={isFetching}
|
||||
>
|
||||
<FilterField label="管理员 ID" width={160}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="如 1"
|
||||
value={draft.actor_id}
|
||||
onChange={value =>
|
||||
patchFilter({ actor_id: typeof value === 'number' ? value : undefined })
|
||||
}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="动作" width={270}>
|
||||
<AutoComplete
|
||||
allowClear
|
||||
value={draft.action || undefined}
|
||||
options={auditActionOptions}
|
||||
placeholder="如 order.admin_close"
|
||||
filterOption={(input, option) =>
|
||||
String(option?.value ?? '').includes(input) ||
|
||||
String(option?.label ?? '').includes(input)
|
||||
}
|
||||
onChange={value => patchFilter({ action: value ?? '' })}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="业务类型" width={200}>
|
||||
<AutoComplete
|
||||
allowClear
|
||||
value={draft.biz_type || undefined}
|
||||
options={auditBizTypeOptions}
|
||||
placeholder="如 order"
|
||||
filterOption={(input, option) =>
|
||||
String(option?.value ?? '').includes(input) ||
|
||||
String(option?.label ?? '').includes(input)
|
||||
}
|
||||
onChange={value => patchFilter({ biz_type: value ?? '' })}
|
||||
/>
|
||||
</FilterField>
|
||||
</FilterBar>
|
||||
|
||||
{isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="审计日志没有加载出来"
|
||||
description={errorText}
|
||||
action={
|
||||
<Button size="small" onClick={() => void refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<AdminAuditLog>
|
||||
title={<span>操作记录</span>}
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
{data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'}
|
||||
</Typography.Text>
|
||||
}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={isFetching}
|
||||
total={data?.total ?? 0}
|
||||
page={data?.page ?? page}
|
||||
pageSize={data?.page_size ?? pageSize}
|
||||
onPageChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
}}
|
||||
onRowClick={row => {
|
||||
setSelected(row)
|
||||
setDetailOpen(true)
|
||||
}}
|
||||
emptyDescription={canView ? '没有匹配的审计记录' : '当前账号没有查看权限'}
|
||||
/>
|
||||
|
||||
<DetailDrawer
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
title={selected ? auditActionMap[selected.action] ?? selected.action : '审计详情'}
|
||||
subtitle={
|
||||
selected
|
||||
? `#${selected.id} · ${formatDateTime(selected.created_at)} · ${actorName(selected)}`
|
||||
: undefined
|
||||
}
|
||||
tags={
|
||||
selected ? (
|
||||
<Space size={6} wrap>
|
||||
<Tag color="geekblue">{bizTypeLabel(selected.biz_type)}</Tag>
|
||||
<Tag>{actorTypeLabel(selected.actor_type)}</Tag>
|
||||
</Space>
|
||||
) : undefined
|
||||
}
|
||||
sections={selected ? buildSections(selected) : []}
|
||||
width={820}
|
||||
emptyDescription="没有可展示的审计详情"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import type {
|
||||
AdminAnnouncementPage,
|
||||
AdminAnnouncementPayload,
|
||||
AdminAnnouncementQuery,
|
||||
AdminAuditLog,
|
||||
AdminAuditPage,
|
||||
AdminAuditQuery,
|
||||
AdminCaptcha,
|
||||
AdminDispute,
|
||||
AdminDisputePage,
|
||||
@@ -405,3 +408,17 @@ export async function unarchiveAdminAnnouncement(id: number | string) {
|
||||
export async function deleteAdminAnnouncement(id: number | string) {
|
||||
await adminClient.delete(`/admin/announcements/${id}`)
|
||||
}
|
||||
|
||||
export async function fetchAdminAuditLogs(query: AdminAuditQuery) {
|
||||
const { data } = await adminClient.get<ApiResponse<Partial<AdminAuditPage>>>('/admin/audit-logs', {
|
||||
params: compactParams(query),
|
||||
})
|
||||
return {
|
||||
items: data.data.items ?? [],
|
||||
total: data.data.total ?? 0,
|
||||
page: data.data.page ?? query.page ?? 1,
|
||||
page_size: data.data.page_size ?? query.page_size ?? 20,
|
||||
}
|
||||
}
|
||||
|
||||
export type { AdminAuditLog }
|
||||
|
||||
@@ -218,3 +218,95 @@ export const announcementStatusOptions = Object.entries(announcementStatusMap).m
|
||||
export const announcementCategoryOptions = Object.entries(announcementCategoryMap).map(
|
||||
([value, label]) => ({ value, label })
|
||||
)
|
||||
|
||||
export const auditActorTypeMap: Record<string, string> = {
|
||||
admin: '管理员',
|
||||
user: '用户',
|
||||
system: '系统',
|
||||
}
|
||||
|
||||
export const auditBizTypeMap: Record<string, string> = {
|
||||
order: '订单',
|
||||
listing: '商品',
|
||||
dispute: '申诉',
|
||||
admin_user: '用户资料',
|
||||
admin: '后台账号',
|
||||
system_config: '系统配置',
|
||||
payment_config: '支付配置',
|
||||
manual_disbursement: '人工打款',
|
||||
operating_expense: '运营支出',
|
||||
pickup: '捡漏订单',
|
||||
pickup_shop: '捡漏店铺',
|
||||
distribution: '分销',
|
||||
announcement: '公告',
|
||||
}
|
||||
|
||||
export const auditActionMap: Record<string, string> = {
|
||||
'order.admin_close': '后台关闭订单',
|
||||
'order.admin_seal': '订单封存',
|
||||
'order.deposit_hold': '冻结押金',
|
||||
'order.deposit_release': '释放押金',
|
||||
'order.force_handoff': '强制交接',
|
||||
'order.mark_abnormal': '标记异常订单',
|
||||
'order.offline_settlement': '线下结算',
|
||||
'order.platform_handoff': '平台代交接',
|
||||
'order.platform_checkout_confirm': '平台代确认结账',
|
||||
'order.platform_checkout_counter': '平台修正结账',
|
||||
'order.platform_checkout_dispute': '平台发起结账争议',
|
||||
'order.reset_checkout_confirm': '重置结账确认',
|
||||
'order.reset_handoff': '重置交接状态',
|
||||
'order.reset_renter_confirm': '重置租客确认',
|
||||
'order.reset_return_overdue': '重置归还逾期',
|
||||
'listing.adjust_external': '调整外部同步',
|
||||
'listing.adjust_review_price': '调整审核价',
|
||||
'listing.admin_offline': '后台下架商品',
|
||||
'listing.mark_abnormal': '标记异常商品',
|
||||
'listing.transfer_owner': '转移商品归属',
|
||||
'listing.update_screenshots': '更新商品截图',
|
||||
'dispute.admin_create': '代建申诉',
|
||||
'dispute.arbitrate': '提交仲裁',
|
||||
'admin_user.freeze': '冻结用户',
|
||||
'admin_user.unfreeze': '解冻用户',
|
||||
'admin_user.set_deposit_free_quota': '设置免押额度',
|
||||
'admin_user.adjust_growth_points': '调整成长值',
|
||||
'admin_user.manual_realname': '人工实名',
|
||||
'admin_user.revoke_realname': '撤销实名',
|
||||
'admin_user.wallet_adjust': '调整钱包余额',
|
||||
'system_config.create': '新增系统配置',
|
||||
'system_config.update': '更新系统配置',
|
||||
'payment_config.create': '新增支付配置',
|
||||
'payment_config.update': '更新支付配置',
|
||||
'payment_config.delete': '删除支付配置',
|
||||
'payment_config.export': '导出支付配置',
|
||||
'payment_config.import': '导入支付配置',
|
||||
'payment_config.view_secret': '查看支付密钥',
|
||||
'manual_disbursement_create': '新建人工打款',
|
||||
'manual_disbursement_void': '作废人工打款',
|
||||
'operating_expense_create': '登记运营支出',
|
||||
'operating_expense_void': '作废运营支出',
|
||||
'pickup_create': '创建捡漏订单',
|
||||
'pickup_cancel': '取消捡漏订单',
|
||||
'pickup_complete': '完成捡漏订单',
|
||||
'pickup_profit_update': '更新捡漏利润',
|
||||
'pickup_offline_settlement': '捡漏线下结算',
|
||||
'pickup_financial_adjustment_create': '新建捡漏财务调整',
|
||||
'pickup_financial_adjustment_settle': '结算捡漏财务调整',
|
||||
'pickup_shop_create': '新增捡漏店铺',
|
||||
'pickup_shop_update': '更新捡漏店铺',
|
||||
'pickup_shop_delete': '删除捡漏店铺',
|
||||
'pickup_shop_map_historical': '关联历史捡漏店铺',
|
||||
'pickup_shop_unmap_historical': '解除历史捡漏店铺',
|
||||
'pickup_third_party_order_no_update': '更新第三方单号',
|
||||
'distribution_financial_snapshot_create': '生成分销财务快照',
|
||||
'auth.token_version.bump': '刷新登录令牌版本',
|
||||
}
|
||||
|
||||
export const auditActionOptions = Object.entries(auditActionMap).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
|
||||
export const auditBizTypeOptions = Object.entries(auditBizTypeMap).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
|
||||
@@ -577,3 +577,33 @@ export interface AdminAnnouncementPayload {
|
||||
is_important: boolean
|
||||
is_popup: boolean
|
||||
}
|
||||
|
||||
export interface AdminAuditQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
actor_id?: number
|
||||
action?: string
|
||||
biz_type?: string
|
||||
}
|
||||
|
||||
export interface AdminAuditLog {
|
||||
id: number
|
||||
actor_type: string
|
||||
actor_id: number
|
||||
actor_username: string
|
||||
actor_nickname: string
|
||||
action: string
|
||||
biz_type: string
|
||||
biz_id?: number | null
|
||||
ip: string
|
||||
user_agent: string
|
||||
detail?: unknown
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminAuditPage {
|
||||
items: AdminAuditLog[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ const allNavGroups: NavGroup[] = [
|
||||
icon: <SettingOutlined />,
|
||||
children: [
|
||||
{ key: '/system-configs', label: '系统配置', permission: 'systemconfig:view' },
|
||||
{ key: '/audit-logs', label: '审计日志', permission: 'audit:view' },
|
||||
{ key: '/audit-logs', label: '审计日志', permission: 'audit_log:view' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1469,6 +1469,26 @@ a {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-detail__pre--tall {
|
||||
max-height: 440px;
|
||||
}
|
||||
|
||||
.admin-audit-summary {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.admin-audit-ua {
|
||||
color: var(--ink-muted);
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-detail-drawer .ant-drawer-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user