458 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|