// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除 // v2: 虚拟滚动渲染,支持万级日志流畅查看 import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Search, RefreshCw, Download, Eye, X, ClipboardList, } from 'lucide-react'; import { PageShell } from '@/components/layout/PageShell'; import { ContentPanel } from '@/components/layout/ContentPanel'; import { PageEmpty } from '@/components/layout/PageEmpty'; import { StatStrip } from '@/components/ui/stat-strip'; import { auditApi } from '@/api'; import { useAuditLogs } from '@/hooks/api'; import { useConfirm } from '@/components/ui/confirm-dialog'; import type { OperationLog, OptionItem } from '@/types'; // Normalize backend log to frontend type function normalizeLog(raw: Record): OperationLog { return { id: String(raw.id ?? ''), userId: String(raw.user_id ?? raw.userId ?? ''), username: String(raw.username ?? ''), module: String(raw.module ?? raw.resource ?? ''), action: String(raw.action ?? ''), resource: String(raw.resource ?? ''), ip: String(raw.ip ?? ''), userAgent: String(raw.user_agent ?? raw.userAgent ?? ''), result: raw.result === 'failure' ? 'failure' : 'success', message: String(raw.message ?? raw.detail ?? ''), createdAt: String(raw.createdAt ?? raw.created_at ?? ''), }; } interface AuditStatistics { total_logs?: number; success_count?: number; failure_count?: number; today_count?: number; top_modules?: { module: string; count: number }[]; top_actions?: { action: string; count: number }[]; top_users?: { username: string; count: number }[]; } export function AuditOperationsTab() { const { t } = useTranslation(); const _confirm = useConfirm(); // Filters const [keyword, setKeyword] = useState(''); const [moduleFilter, setModuleFilter] = useState(''); const [actionFilter, setActionFilter] = useState(''); const auditParams = useMemo(() => ({ page: 1, page_size: 100, ...(moduleFilter ? { module: moduleFilter } : {}), ...(actionFilter ? { action: actionFilter } : {}), }), [moduleFilter, actionFilter]); const { data: auditData, isLoading: loading, isFetching, refetch: refetchLogs } = useAuditLogs(auditParams); const logs = useMemo( () => (auditData?.list || []).map((raw) => normalizeLog(raw)), [auditData], ); const [modules, setModules] = useState([]); const [actions, setActions] = useState([]); // Detail modal const [detailLog, setDetailLog] = useState(null); // Statistics modal const [statsOpen, setStatsOpen] = useState(false); const [statistics, setStatistics] = useState(null); const [statsLoading, setStatsLoading] = useState(false); const fetchOptions = useCallback(async () => { try { const [modRes, actRes] = await Promise.all([auditApi.getModules(), auditApi.getActions()]); const modData = modRes as { list?: OptionItem[] } | OptionItem[]; const actData = actRes as { list?: OptionItem[] } | OptionItem[]; setModules(Array.isArray(modData) ? modData : (modData?.list ?? [])); setActions(Array.isArray(actData) ? actData : (actData?.list ?? [])); } catch { /* noop */ } }, []); useEffect(() => { fetchOptions(); }, [fetchOptions]); const filteredLogs = logs.filter((log) => { const kw = keyword.toLowerCase(); return ( !kw || log.username.toLowerCase().includes(kw) || log.module.toLowerCase().includes(kw) || log.action.toLowerCase().includes(kw) || log.resource.toLowerCase().includes(kw) ); }); const getLogResultBadge = (result: OperationLog['result']) => { return result === 'success' ? ( {t('common.success')} ) : ( {t('common.failed')} ); }; const handleExport = async () => { try { const params: Record = {}; if (moduleFilter) params.module = moduleFilter; if (actionFilter) params.action = actionFilter; await auditApi.exportLogs(params); } catch { // noop } }; const _handleShowStats = async () => { setStatsOpen(true); setStatsLoading(true); try { const data = (await auditApi.getStatistics()) as AuditStatistics; setStatistics(data); } catch { setStatistics(null); } finally { setStatsLoading(false); } }; const viewDetail = (log: OperationLog) => { setDetailLog(log); }; // 虚拟滚动 const scrollRef = useRef(null); const GRID_COLS = 'minmax(140px,1fr) minmax(80px,1fr) minmax(100px,1fr) minmax(100px,1fr) minmax(140px,1fr) minmax(100px,1fr) minmax(80px,0.8fr) 48px'; const virtualizer = useVirtualizer({ count: filteredLogs.length, getScrollElement: () => scrollRef.current, estimateSize: () => 52, overscan: 10, }); const virtualRows = virtualizer.getVirtualItems(); const totalHeight = virtualizer.getTotalSize(); const successCount = filteredLogs.filter(l => l.result === 'success').length; const failCount = filteredLogs.filter(l => l.result === 'failure').length; const toolbar = (
setKeyword(e.target.value)} className="pl-9 h-9 dg-input" />
); return ( } loading={loading} metrics={( )} toolbar={toolbar} actions={( <> )} >
{!loading && filteredLogs.length === 0 ? ( } title={t('audit.operations.noData')} /> ) : ( <> {/* 表头 */}
{t('audit.operations.col.time')} {t('audit.operations.col.user')} {t('audit.operations.col.module')} {t('audit.operations.col.actionType')} {t('audit.operations.col.resource')} {t('audit.operations.col.ip')} {t('audit.operations.col.result')} {t('audit.operations.col.detail')}
{/* 虚拟滚动列表 */}
{virtualRows.map((vr) => { const log = filteredLogs[vr.index]; if (!log) return null; return (
{log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'} {log.username || '--'} {log.module || '--'}
{log.action || '--'}
{log.resource || '--'} {log.ip || '--'}
{getLogResultBadge(log.result)}
); })}
{/* 行数统计 */}
{t('audit.operations.totalLogs', { count: filteredLogs.length })} {filteredLogs.length > 200 && `· ${t('audit.operations.virtualScroll')}`}
)}
{/* Detail Modal */} setDetailLog(null)} /> {/* Statistics Modal */} setStatsOpen(false)} loading={statsLoading} stats={statistics} />
); } // ========================================================================= // Log Detail Dialog // ========================================================================= function LogDetailDialog({ log, onClose }: { log: OperationLog | null; onClose: () => void }) { const { t } = useTranslation(); if (!log) return null; return (

{t('audit.operations.detailTitle')}

{log.result === 'success' ? t('common.success') : t('common.failed')} } /> {log.message && }
); } function DetailRow({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) { return (
{label} {value}
); } // ========================================================================= // Statistics Dialog // ========================================================================= function StatisticsDialog({ open, onClose, loading, stats, }: { open: boolean; onClose: () => void; loading: boolean; stats: AuditStatistics | null; }) { const { t } = useTranslation(); if (!open) return null; return (

{t('audit.operations.statsTitle')}

{loading ? (
) : stats ? (
{stats.top_modules && stats.top_modules.length > 0 && (

{t('audit.operations.topModules')}

{stats.top_modules.slice(0, 5).map((m, i) => (
{m.module} {m.count}
))}
)} {stats.top_users && stats.top_users.length > 0 && (

{t('audit.operations.topUsers')}

{stats.top_users.slice(0, 5).map((u, i) => (
{u.username} {u.count}
))}
)}
) : (

{t('audit.operations.noStats')}

)}
); } function StatCard({ label, value, color }: { label: string; value: string; color: 'blue' | 'emerald' | 'red' }) { const colorMap = { blue: 'border-blue-500/20 bg-blue-500/5 dg-text-blue', emerald: 'border-emerald-500/20 bg-emerald-500/5 dg-text-green', red: 'border-red-500/20 bg-red-500/5 dg-text-red', }; return (
{label}
{value}
); }