241 lines
7.6 KiB
TypeScript
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
|
|
}
|