Files
hfb_jysh/frontend/src/features/admin/AdminAuditLogsPage.tsx
T

458 lines
13 KiB
TypeScript

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>
)
}