583 lines
21 KiB
TypeScript
583 lines
21 KiB
TypeScript
'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>
|
||
)
|
||
}
|