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) 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 { return [ { title: '时间 / 编号', dataIndex: 'created_at', width: 180, fixed: 'left', render: (_, row) => (
{formatDateTime(row.created_at)} #{row.id}
), }, { title: '操作人', key: 'actor', width: 210, render: (_, row) => (
{actorTypeLabel(row.actor_type)} {row.actor_username ? ` · ${row.actor_username}` : ''} ID {row.actor_id}
), }, { title: '动作', dataIndex: 'action', width: 230, render: (_, row) => (
{auditActionMap[row.action] ?? row.action} {row.action}
), }, { title: '业务对象', key: 'biz', width: 150, render: (_, row) => (
{bizTypeLabel(row.biz_type)} {row.biz_id != null ? `#${row.biz_id}` : '无关联 ID'}
), }, { title: 'IP', dataIndex: 'ip', width: 140, render: value => {formatText(value)}, }, { title: '变更摘要', key: 'summary', width: 320, render: (_, row) => ( {summarizeDetail(row.detail)} ), }, { title: '操作', key: 'actions', width: 92, fixed: 'right', render: (_, row) => ( ), }, ] } function buildSections(row: AdminAuditLog): DetailSection[] { return [ { key: 'actor', title: '操作信息', icon: , fields: [ { key: 'time', label: '发生时间', value: formatDateTime(row.created_at), }, { key: 'log_id', label: '日志编号', value: #{row.id}, }, { key: 'actor', label: '操作人', value: (
{actorName(row)} {actorTypeLabel(row.actor_type)}
), }, { key: 'actor_id', label: '操作人 ID', value: {row.actor_id}, }, { key: 'action', label: '动作', value: (
{auditActionMap[row.action] ?? row.action} {row.action}
), }, { key: 'biz', label: '业务对象', value: (
{bizTypeLabel(row.biz_type)} {row.biz_id != null ? `#${row.biz_id}` : '—'}
), }, ], }, { key: 'request', title: '请求信息', fields: [ { key: 'ip', label: '来源 IP', value: {formatText(row.ip)}, }, { key: 'user_agent', label: 'User-Agent', span: 2, value: {formatText(row.user_agent)}, }, ], }, { key: 'detail', title: '变更明细', fields: [ { key: 'json', label: '原始 JSON', span: 2, value:
{normalizeDetail(row.detail)}
, }, ], }, ] } export function AdminAuditLogsPage() { const [draft, setDraft] = useState(emptyFilters) const [applied, setApplied] = useState(emptyFilters) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) const [selected, setSelected] = useState(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) => { 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 (
共 {data?.total ?? 0} 条 {isFetching && !isPending ? 正在更新 : null} 只读 } extra={ } /> {!canView ? ( ) : null} patchFilter({ actor_id: typeof value === 'number' ? value : undefined }) } /> String(option?.value ?? '').includes(input) || String(option?.label ?? '').includes(input) } onChange={value => patchFilter({ action: value ?? '' })} /> String(option?.value ?? '').includes(input) || String(option?.label ?? '').includes(input) } onChange={value => patchFilter({ biz_type: value ?? '' })} /> {isError ? ( void refetch()}> 重试 } /> ) : null} title={操作记录} extra={ {data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'} } 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 ? '没有匹配的审计记录' : '当前账号没有查看权限'} /> setDetailOpen(false)} title={selected ? auditActionMap[selected.action] ?? selected.action : '审计详情'} subtitle={ selected ? `#${selected.id} · ${formatDateTime(selected.created_at)} · ${actorName(selected)}` : undefined } tags={ selected ? ( {bizTypeLabel(selected.biz_type)} {actorTypeLabel(selected.actor_type)} ) : undefined } sections={selected ? buildSections(selected) : []} width={820} emptyDescription="没有可展示的审计详情" />
) }