assets-ai/src/app/(app)/settings/audit-logs/page.tsx

583 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect } from 'react'
import Table, { Column } from '@/components/ui/Table'
import Button from '@/components/ui/Button'
import Modal from '@/components/ui/Modal'
import Badge from '@/components/ui/Badge'
interface AuditLog {
id: number
user_id: number | null
api_key_id: number | null
action: string
entity_type: string
entity_id: number | null
details: string | null
ip_address: string | null
created_at: string
username: string | null
api_key_name: string | null
}
const ACTION_LABELS: Record<string, { label: string; color: 'green' | 'blue' | 'red' | 'gray' }> = {
create: { label: '创建', color: 'green' },
update: { label: '更新', color: 'blue' },
delete: { label: '删除', color: 'red' },
batch_update: { label: '批量更新', color: 'blue' },
import: { label: '导入', color: 'blue' },
export: { label: '导出', color: 'gray' },
login: { label: '登录', color: 'gray' },
logout: { label: '登出', color: 'gray' },
sync_emails: { label: '同步邮箱', color: 'blue' },
}
const ENTITY_LABELS: Record<string, string> = {
asset: '资产',
user: '用户',
role: '角色',
api_key: 'API Key',
audit_log: '审计日志',
auth: '认证',
}
export default function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
// 过滤器
const [actionFilter, setActionFilter] = useState('')
const [entityFilter, setEntityFilter] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
// 保留天数设置
const [retentionSaved, setRetentionSaved] = useState(180)
const [retentionInput, setRetentionInput] = useState('180')
const [retentionSaving, setRetentionSaving] = useState(false)
const [retentionSavedFeedback, setRetentionSavedFeedback] = useState(false)
const [retentionError, setRetentionError] = useState(false)
const retentionDirty = retentionInput !== String(retentionSaved)
// 详情 Modal
const [detailLog, setDetailLog] = useState<AuditLog | null>(null)
async function fetchLogs() {
setLoading(true)
const params = new URLSearchParams()
params.set('page', String(page))
params.set('pageSize', String(pageSize))
if (actionFilter) params.set('action', actionFilter)
if (entityFilter) params.set('entity_type', entityFilter)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
try {
const res = await fetch(`/api/audit-logs?${params}`)
if (res.ok) {
const data = await res.json()
setLogs(data.logs)
setTotal(data.total)
}
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => { fetchLogs() }, [page, actionFilter, entityFilter, dateFrom, dateTo])
async function fetchRetentionDays() {
try {
const res = await fetch('/api/audit-logs/cleanup')
if (res.ok) {
const data = await res.json()
setRetentionSaved(data.days)
setRetentionInput(String(data.days))
}
} catch { /* ignore */ }
}
async function saveRetentionDays() {
const days = parseInt(retentionInput, 10)
if (isNaN(days) || days < 30 || days > 365) {
setRetentionError(true)
setTimeout(() => setRetentionError(false), 2000)
return
}
setRetentionSaving(true)
setRetentionError(false)
try {
const res = await fetch('/api/audit-logs/cleanup', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days })
})
if (res.ok) {
setRetentionSaved(days)
setRetentionSavedFeedback(true)
setTimeout(() => setRetentionSavedFeedback(false), 2000)
} else {
setRetentionError(true)
setTimeout(() => setRetentionError(false), 2000)
}
} catch {
setRetentionError(true)
setTimeout(() => setRetentionError(false), 2000)
}
setRetentionSaving(false)
}
useEffect(() => { fetchRetentionDays() }, [])
function resetFilters() {
setActionFilter('')
setEntityFilter('')
setDateFrom('')
setDateTo('')
setPage(1)
}
function setQuickDate(days: number) {
const today = new Date()
const from = new Date(today)
from.setDate(from.getDate() - days)
const fromStr = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String(from.getDate()).padStart(2, '0')}`
const toStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
setDateFrom(fromStr)
setDateTo(toStr)
}
async function handleExport() {
const params = new URLSearchParams()
if (actionFilter) params.set('action', actionFilter)
if (entityFilter) params.set('entity_type', entityFilter)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
const res = await fetch(`/api/audit-logs/export?${params}`)
if (res.ok) {
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const now = new Date()
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
a.download = `audit-logs-${dateStr}.csv`
a.click()
URL.revokeObjectURL(url)
}
}
function formatDetails(log: AuditLog): string {
if (!log.details) return '-'
try {
const details = JSON.parse(log.details)
if (details.changes) {
const keys = Object.keys(details.changes)
return keys.slice(0, 2).join(', ') + (keys.length > 2 ? '...' : '')
}
if (details.created) return '新建记录'
if (details.deleted) return '删除记录'
if (details.export) return `导出 ${details.export.count}`
if (details.batch_update) return `批量更新 ${details.batch_update.count}`
if (details.import) return `导入 ${details.import.created} 新 / ${details.import.updated} 更新`
if (details.message) return details.message
if (details.keys) return details.keys.slice(0, 2).join(', ') + (details.keys.length > 2 ? '...' : '')
if (details.username) return `用户 ${details.username} 登录`
if (details.retention_days !== undefined) return `保留 ${details.retention_days}`
if (details.cleanup) return `清理 ${details.cleanup.deleted}`
return '-'
} catch { return '-' }
}
// 字段名汉化映射
const FIELD_LABELS: Record<string, string> = {
name: '名称', status: '状态', type: '类型', category: '分类',
model: '型号', sn: '序列号', location: '位置', department: '部门',
ip: 'IP', mac: 'MAC', os: '操作系统', cpu: 'CPU', memory: '内存',
disk: '硬盘', purchase_date: '采购日期', warranty_date: '质保到期',
description: '描述', notes: '备注', email: '邮箱', role: '角色',
username: '用户名', permissions: '权限', count: '数量',
}
function formatValue(val: unknown): string {
if (val === null || val === undefined) return '-'
if (typeof val === 'object') return JSON.stringify(val)
return String(val)
}
function renderDetails(details: Record<string, unknown>) {
// 字段变更
if (details.changes && typeof details.changes === 'object') {
const changes = details.changes as Record<string, { from: unknown; to: unknown }>
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">📋 </p>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-slate-100 dark:bg-slate-700">
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{Object.entries(changes).map(([key, val]) => (
<tr key={key} className="border-t border-slate-200 dark:border-slate-700">
<td className="px-3 py-2 text-slate-500">{FIELD_LABELS[key] || key}</td>
<td className="px-3 py-2 text-slate-400 line-through">{formatValue(val.from)}</td>
<td className="px-3 py-2 text-green-600 dark:text-green-400 font-medium">{formatValue(val.to)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// 新建记录
if (details.created && typeof details.created === 'object') {
const data = details.created as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-green-600 dark:text-green-400 mb-2">🟢 </p>
<div className="grid grid-cols-2 gap-2">
{Object.entries(data).map(([key, val]) => (
<div key={key} className="flex gap-2 text-sm">
<span className="text-slate-500">{FIELD_LABELS[key] || key}</span>
<span>{formatValue(val)}</span>
</div>
))}
</div>
</div>
)
}
// 删除记录
if (details.deleted && typeof details.deleted === 'object') {
const data = details.deleted as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-red-600 dark:text-red-400 mb-2">🔴 </p>
<div className="grid grid-cols-2 gap-2">
{Object.entries(data).map(([key, val]) => (
<div key={key} className="flex gap-2 text-sm">
<span className="text-slate-500">{FIELD_LABELS[key] || key}</span>
<span>{formatValue(val)}</span>
</div>
))}
</div>
</div>
)
}
// 批量更新
if (details.batch_update && typeof details.batch_update === 'object') {
const data = details.batch_update as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📦 {String(data.count)} </p>
{data.ids ? <p className="text-sm text-slate-500"> ID{String(data.ids)}</p> : null}
</div>
)
}
// 导入
if (details.import && typeof details.import === 'object') {
const data = details.import as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📥 </p>
<div className="flex gap-4 text-sm">
<span>{String(data.created)} </span>
<span>{String(data.updated)} </span>
</div>
</div>
)
}
// 导出
if (details.export && typeof details.export === 'object') {
const data = details.export as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">📤 {String(data.count)} </p>
</div>
)
}
// 登录信息
if (details.username && typeof details.username === 'string') {
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">👤 </p>
<div className="grid grid-cols-2 gap-2">
<div className="flex gap-2 text-sm">
<span className="text-slate-500"></span>
<span>{details.username}</span>
</div>
{details.role && (
<div className="flex gap-2 text-sm">
<span className="text-slate-500"></span>
<span>{String(details.role)}</span>
</div>
)}
{details.displayName && (
<div className="flex gap-2 text-sm">
<span className="text-slate-500"></span>
<span>{String(details.displayName)}</span>
</div>
)}
</div>
</div>
)
}
// 保留天数设置
if (details.retention_days !== undefined) {
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2"> </p>
<p className="text-sm text-slate-600 dark:text-slate-400"> <span className="font-medium">{String(details.retention_days)}</span> </p>
</div>
)
}
// 清理操作
if (details.cleanup) {
const data = details.cleanup as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">🧹 </p>
<p className="text-sm text-slate-600 dark:text-slate-400"> {String(data.deleted)} </p>
</div>
)
}
// 兜底:格式化 JSON
return (
<pre className="mt-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg text-xs font-mono overflow-x-auto">
{JSON.stringify(details, null, 2)}
</pre>
)
}
const columns: Column<AuditLog>[] = [
{
key: 'created_at',
title: '时间',
width: '170px',
render: (r) => <span className="text-sm whitespace-nowrap">{r.created_at}</span>
},
{
key: 'username',
title: '用户',
width: '100px',
render: (r) => (
<div className="flex items-center gap-1 whitespace-nowrap">
<span>{r.username || '-'}</span>
{r.api_key_id && <Badge color="yellow">[API]</Badge>}
</div>
)
},
{
key: 'action',
title: '操作',
width: '70px',
render: (r) => {
const info = ACTION_LABELS[r.action] || { label: r.action, color: 'gray' as const }
return <span className="whitespace-nowrap"><Badge color={info.color}>{info.label}</Badge></span>
}
},
{
key: 'entity_type',
title: '对象',
width: '80px',
render: (r) => <span className="whitespace-nowrap">{ENTITY_LABELS[r.entity_type] || r.entity_type}</span>
},
{
key: 'details',
title: '详情',
render: (r) => (
<button
onClick={() => setDetailLog(r)}
className="text-left text-sm text-slate-600 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 truncate max-w-[200px]"
>
{formatDetails(r)}
</button>
)
},
{
key: 'ip_address',
title: 'IP',
width: '120px',
render: (r) => <span className="text-xs font-mono">{r.ip_address || '-'}</span>
},
]
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={handleExport}></Button>
</div>
</div>
{/* 保留天数设置 */}
<div className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800/50 rounded-lg text-sm">
<span className="text-slate-600 dark:text-slate-400"></span>
<input
type="number"
min={30}
max={365}
value={retentionInput}
onChange={(e) => setRetentionInput(e.target.value)}
className="w-20 px-2 py-1 text-sm border border-slate-200 dark:border-slate-700 rounded bg-white dark:bg-slate-900"
/>
<span className="text-xs text-slate-400">30-365 180 </span>
{retentionSavedFeedback ? (
<span className="text-xs font-medium text-green-600 dark:text-green-400"> </span>
) : retentionError ? (
<span className="text-xs font-medium text-red-600 dark:text-red-400"> </span>
) : retentionDirty ? (
<button
className="px-3 py-1 text-xs font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
onClick={saveRetentionDays}
disabled={retentionSaving}
>
{retentionSaving ? '保存中...' : '保存'}
</button>
) : null}
</div>
{/* 过滤器 */}
<div className="flex flex-wrap items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
>
<option value=""></option>
{Object.entries(ACTION_LABELS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<select
value={entityFilter}
onChange={(e) => { setEntityFilter(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
>
<option value=""></option>
{Object.entries(ENTITY_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<input
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
/>
<span className="text-slate-400">~</span>
<input
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
/>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => setQuickDate(7)}>7</Button>
<Button size="sm" variant="ghost" onClick={() => setQuickDate(30)}>30</Button>
<Button size="sm" variant="ghost" onClick={() => setQuickDate(180)}>180</Button>
</div>
<Button size="sm" variant="ghost" onClick={resetFilters}></Button>
</div>
{/* 表格 */}
{loading ? (
<div className="py-12 text-center text-slate-500 dark:text-slate-400">...</div>
) : (
<Table columns={columns} data={logs} rowKey={(r) => String(r.id)} />
)}
{/* 分页 */}
{total > pageSize && (
<div className="flex items-center justify-center gap-2">
<Button
size="sm"
variant="ghost"
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
>
</Button>
<span className="text-sm text-slate-500">
{page} / {Math.ceil(total / pageSize)}
</span>
<Button
size="sm"
variant="ghost"
disabled={page >= Math.ceil(total / pageSize)}
onClick={() => setPage(p => p + 1)}
>
</Button>
</div>
)}
{/* 详情 Modal */}
<Modal
open={!!detailLog}
onClose={() => setDetailLog(null)}
title="审计日志详情"
>
{detailLog && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-500"></span>
<span>{detailLog.created_at}</span>
</div>
<div>
<span className="text-slate-500"></span>
<span>{detailLog.username || '-'}</span>
{detailLog.api_key_id && <span className="ml-1"><Badge color="yellow">[API]</Badge></span>}
</div>
<div>
<span className="text-slate-500"></span>
<Badge color={ACTION_LABELS[detailLog.action]?.color || 'gray'}>
{ACTION_LABELS[detailLog.action]?.label || detailLog.action}
</Badge>
</div>
<div>
<span className="text-slate-500"></span>
<span>{ENTITY_LABELS[detailLog.entity_type] || detailLog.entity_type}</span>
{detailLog.entity_id && <span className="ml-1">#{detailLog.entity_id}</span>}
</div>
<div>
<span className="text-slate-500">IP</span>
<span className="font-mono">{detailLog.ip_address || '-'}</span>
</div>
</div>
{detailLog.details && (
<div className="mt-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
{renderDetails(JSON.parse(detailLog.details))}
</div>
)}
</div>
)}
</Modal>
</div>
)
}