122 lines
6.5 KiB
TypeScript
122 lines
6.5 KiB
TypeScript
'use client'
|
|
// src/app/admin/audit-logs/page.tsx — 审计日志
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { RotateCw } from 'lucide-react'
|
|
|
|
interface AuditLog {
|
|
id: number; username: string | null; action: string; entity_type: string
|
|
details: string | null; ip_address: string | null; created_at: string
|
|
}
|
|
|
|
export default function AuditLogsPage() {
|
|
const [logs, setLogs] = useState<AuditLog[]>([])
|
|
const [total, setTotal] = useState(0)
|
|
const [page, setPage] = useState(1)
|
|
const [action, setAction] = useState('')
|
|
const [entityType, setEntityType] = useState('')
|
|
const [loading, setLoading] = useState(true)
|
|
const pageSize = 20
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
|
if (action) params.set('action', action)
|
|
if (entityType) params.set('entity_type', entityType)
|
|
const res = await fetch(`/api/admin/audit-logs?${params}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setLogs(data.data)
|
|
setTotal(data.total)
|
|
}
|
|
setLoading(false)
|
|
}, [page, action, entityType])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
const totalPages = Math.ceil(total / pageSize)
|
|
|
|
const renderDetails = (details: string | null) => {
|
|
if (!details) return '—'
|
|
try { return JSON.stringify(JSON.parse(details)).slice(0, 80) }
|
|
catch { return details.slice(0, 80) }
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">审计日志</h1>
|
|
<p className="text-sm text-slate-400 mt-1">系统操作与事件记录</p>
|
|
</div>
|
|
|
|
<div className="flex gap-4 flex-wrap">
|
|
<select value={action} onChange={e => { setAction(e.target.value); setPage(1) }}
|
|
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm">
|
|
<option value="">全部操作</option>
|
|
<option value="login">登录</option><option value="login_failed">登录失败</option><option value="logout">登出</option>
|
|
<option value="create_service">创建服务</option><option value="update_service">更新服务</option><option value="delete_service">删除服务</option>
|
|
<option value="create_channel">创建渠道</option><option value="update_channel">更新渠道</option><option value="delete_channel">删除渠道</option>
|
|
<option value="update_user">更新用户</option><option value="update_role">更新角色</option>
|
|
</select>
|
|
<select value={entityType} onChange={e => { setEntityType(e.target.value); setPage(1) }}
|
|
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm">
|
|
<option value="">全部对象</option>
|
|
<option value="auth">认证</option><option value="service">服务</option><option value="alert_channel">告警渠道</option>
|
|
<option value="user">用户</option><option value="role">角色</option>
|
|
</select>
|
|
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
|
|
<RotateCw size={14} /> 刷新
|
|
</button>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-slate-200 dark:border-slate-700">
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">时间</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">用户</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">操作</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">对象</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">详情</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">IP</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">加载中...</td></tr>
|
|
) : logs.length === 0 ? (
|
|
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">暂无数据</td></tr>
|
|
) : logs.map(log => (
|
|
<tr key={log.id} className="border-b border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
|
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 whitespace-nowrap">{log.created_at}</td>
|
|
<td className="px-4 py-3 text-sm">{log.username || '—'}</td>
|
|
<td className="px-4 py-3 text-sm">
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
log.action.includes('login') ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' :
|
|
log.action.includes('failed') ? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400' :
|
|
log.action.includes('create') ? 'bg-green-100 text-green-700 dark:bg-green-500/10 dark:text-green-400' :
|
|
log.action.includes('delete') ? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400' :
|
|
'bg-slate-100 text-slate-700 dark:bg-slate-500/10 dark:text-slate-400'
|
|
}`}>{log.action}</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm">{log.entity_type}</td>
|
|
<td className="px-4 py-3 text-sm text-slate-500 font-mono text-xs">{renderDetails(log.details)}</td>
|
|
<td className="px-4 py-3 text-sm text-slate-400">{log.ip_address || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-center gap-2">
|
|
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
|
|
className="px-3 py-1.5 text-sm border rounded-lg disabled:opacity-40">上一页</button>
|
|
<span className="text-sm text-slate-500">第 {page}/{totalPages} 页 (共 {total} 条)</span>
|
|
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}
|
|
className="px-3 py-1.5 text-sm border rounded-lg disabled:opacity-40">下一页</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|