Files
order_site/apps/frontend/src/pages/admin/AdminAuditLogsPage.tsx
T

241 lines
7.6 KiB
TypeScript

import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { Alert, Button, Card, Empty, Form, Input, Select, Space, Table, Typography } from 'antd'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useSearchParams } from 'react-router'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import PageHeader from '@/components/admin/PageHeader'
import StatusTag from '@/components/admin/StatusTag'
import dayjs from '@/lib/dayjs'
import { fetchAdminAuditLogs } from '@/services/admin'
import type { AdminAuditLogItem } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { formatAuditAction, formatAuditTargetType } from '@/utils/admin-display'
import { adminAuditActionOptions, adminAuditTargetTypeOptions } from '@/utils/admin-options'
import {
ADMIN_DEFAULT_PAGE_SIZE,
buildAdminTablePagination,
resolveAdminPageSize,
} from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { stringifyDisplayJson } from '@/utils/date-time'
type AuditFilterForm = {
actorUsername?: string
action?: string
targetType?: string
dateRange?: unknown
}
export default function AdminAuditLogsPage() {
const isAdmin = hasAdminRole('admin')
const [searchParams, setSearchParams] = useSearchParams()
const page = Number(searchParams.get('page') || 1) || 1
const pageSize = resolveAdminPageSize(searchParams.get('pageSize'), ADMIN_DEFAULT_PAGE_SIZE)
const filters = {
actorUsername: searchParams.get('actorUsername') || '',
action: searchParams.get('action') || '',
targetType: searchParams.get('targetType') || '',
dateFrom: searchParams.get('dateFrom') || '',
dateTo: searchParams.get('dateTo') || '',
}
const queryParams = useMemo(
() => ({ page, pageSize, ...filters }),
[
filters.action,
filters.actorUsername,
filters.dateFrom,
filters.dateTo,
filters.targetType,
page,
pageSize,
],
)
const query = useQuery({
queryKey: ['admin-audit-logs', queryParams],
enabled: isAdmin,
queryFn: () => fetchAdminAuditLogs(queryParams),
})
const data = query.data?.data
const columns: TableColumnsType<AdminAuditLogItem> = [
{
title: '时间',
dataIndex: 'createdAt',
width: 180,
render: (value) => formatAdminDateTime(value),
},
{
title: '操作人',
minWidth: 140,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>{row.actorUsername || '-'}</Typography.Text>
<StatusTag value={row.actorRole} />
</div>
),
},
{
title: '动作',
dataIndex: 'action',
minWidth: 180,
render: (value) => <Typography.Text strong>{formatAuditAction(value)}</Typography.Text>,
},
{
title: '目标',
minWidth: 180,
render: (_, row) => (
<Typography.Text strong>
{formatAuditTargetType(row.targetType)} #{row.targetId || '-'}
</Typography.Text>
),
},
{
title: '摘要',
minWidth: 260,
render: (_, row) => <span className="payload-text">{formatPayload(row.payload)}</span>,
},
]
function applyFilters(values: AuditFilterForm) {
const [dateFrom, dateTo] = Array.isArray(values.dateRange)
? values.dateRange.map((item: { format?: (pattern: string) => string }) =>
item?.format ? item.format('YYYY-MM-DD') : '',
)
: ['', '']
setSearchParams(
cleanParams({
actorUsername: values.actorUsername,
action: values.action,
targetType: values.targetType,
dateFrom,
dateTo,
page: 1,
pageSize,
}),
)
}
function resetFilters() {
setSearchParams(cleanParams({ page: 1, pageSize }))
}
return (
<section className="page-stack">
<PageHeader
title="操作审计"
description="集中查看高风险后台动作,便于排查谁在什么时间改了什么。"
extra={
<Typography.Text type="secondary">
{data?.pagination.total || 0} 条审计记录
</Typography.Text>
}
/>
{!isAdmin ? (
<Alert type="warning" showIcon message="仅管理员可以访问操作审计" />
) : (
<>
<Card title="筛选审计日志">
<Form<AuditFilterForm>
layout="inline"
initialValues={{
actorUsername: filters.actorUsername,
action: filters.action,
targetType: filters.targetType,
dateRange:
filters.dateFrom && filters.dateTo
? [dayjs(filters.dateFrom), dayjs(filters.dateTo)]
: undefined,
}}
onFinish={applyFilters}
className="filter-form"
>
<Form.Item name="actorUsername">
<Input allowClear placeholder="操作账号" />
</Form.Item>
<Form.Item name="action">
<Select
allowClear
placeholder="动作"
options={adminAuditActionOptions}
style={{ minWidth: 180 }}
/>
</Form.Item>
<Form.Item name="targetType">
<Select
allowClear
placeholder="目标类型"
options={adminAuditTargetTypeOptions}
style={{ minWidth: 150 }}
/>
</Form.Item>
<Form.Item name="dateRange">
<AdminRangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
查询
</Button>
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
重置
</Button>
</Space>
</Form.Item>
</Form>
</Card>
{query.error ? (
<Alert
type="error"
showIcon
message={query.error instanceof Error ? query.error.message : '读取审计日志失败'}
/>
) : null}
<Card>
<Table<AdminAuditLogItem>
rowKey="logId"
loading={query.isLoading || query.isFetching}
columns={columns}
dataSource={data?.items || []}
locale={{ emptyText: <Empty description="暂无审计记录" /> }}
scroll={{ x: 980 }}
pagination={buildAdminTablePagination({
current: data?.pagination.page || page,
pageSize: data?.pagination.pageSize || pageSize,
total: data?.pagination.total || 0,
onChange: (nextPage, nextPageSize) => {
setSearchParams(
cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }),
)
},
})}
/>
</Card>
</>
)}
</section>
)
}
function formatPayload(payload: Record<string, unknown>) {
const text = stringifyDisplayJson(payload, 0)
return text.length > 140 ? `${text.slice(0, 140)}...` : text
}
function cleanParams(params: Record<string, unknown>) {
const next = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
const normalized = String(value ?? '').trim()
if (normalized) {
next.set(key, normalized)
}
})
return next
}