import { useEffect, useRef, useState } from "react"; import { Archive, RotateCcw, Search } from "lucide-react"; import { Button, Select, Space, Table, Tag } from "tdesign-react"; import AccessibleInput from "../../components/AccessibleInput"; import { useSearchParams } from "react-router-dom"; import { api } from "../../services/api"; import { dateText } from "../expenses/date"; import { AsyncState, ErrorBanner, Page } from "../common"; type AuditItem = { id: number; occurredAt: number; requestId: string; actorUsername?: string | null; action: string; targetType: string; targetId?: string | null; outcome?: string }; function outcomeLabel(outcome?: string): string { if (!outcome || outcome === "success") return "成功"; if (outcome === "denied") return "已拒绝"; if (outcome === "failure") return "失败"; return outcome; } const ACTION_LABELS: Record = { deny: "拒绝请求", "auth.login": "登录", "auth.logout": "退出登录", "auth.login_failed": "登录失败", "expense.created": "创建账目", "expense.updated": "更新账目", "expense.status_changed": "更新报销状态", "expense.trashed": "移入回收站", "expense.restored": "恢复账目", "expense.purged": "永久删除账目", "expense.attachments_added": "添加附件", "attachment.added": "添加附件", "attachment.deleted": "删除附件", "attachment.previewed": "预览附件", "attachment.downloaded": "下载附件", "export.created": "创建导出任务", "export.downloaded": "下载导出文件", "admin.created": "创建管理员", "admin.updated": "更新管理员", "admin.status_changed": "切换管理员状态", "admin.password_changed": "修改管理员密码", "admin.password_reset": "重置管理员密码", "expense.attachment_deleted": "删除附件", "expense.attachment_read": "读取附件", "expense.purge": "永久删除账目", "update.checked": "检查更新", "update.apply_requested": "提交更新", }; const TARGET_LABELS: Record = { expense: "账目", admin: "管理员", export: "导出任务", session: "会话", update: "更新任务", system: "系统检查", }; const ALLOWED_TARGETS = new Set(["", "expense", "admin", "export", "session", "update", "system"]); export default function AuditPage({ timezone = "Asia/Shanghai" }: { timezone?: string }) { const [searchParams, setSearchParams] = useSearchParams(); const [items, setItems] = useState([]); const rawAction = searchParams.get("action") || ""; const action = rawAction.trim().slice(0, 100); const targetParam = searchParams.get("targetType") || ""; const targetType = ALLOWED_TARGETS.has(targetParam) ? targetParam : ""; const [actionDraft, setActionDraft] = useState(action); const [targetDraft, setTargetDraft] = useState(targetType); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(""); const [hasMore, setHasMore] = useState(false); const requestSequence = useRef(0); const offsetRef = useRef(0); const appendInFlight = useRef(false); const pageSize = 100; const load = async (append = false) => { if (append && appendInFlight.current) return; if (append) appendInFlight.current = true; const sequence = ++requestSequence.current; if (append) setLoadingMore(true); else { setLoading(true); setLoadingMore(false); } setError(""); try { const nextOffset = append ? offsetRef.current : 0; const params = new URLSearchParams({ limit: String(pageSize), offset: String(nextOffset) }); if (action.trim()) params.set("action", action.trim()); if (targetType) params.set("targetType", targetType); const result = await api<{ items: AuditItem[] }>(`/api/audit?${params}`); if (sequence !== requestSequence.current) return; setItems(current => { if (!append) return result.items; const existingIds = new Set(current.map(item => item.id)); return [...current, ...result.items.filter(item => !existingIds.has(item.id))]; }); offsetRef.current = nextOffset + result.items.length; setHasMore(result.items.length === pageSize); } catch (e) { if (sequence === requestSequence.current) setError((e as Error).message); } finally { if (append) appendInFlight.current = false; if (sequence === requestSequence.current) { if (append) setLoadingMore(false); else setLoading(false); } } }; useEffect(() => { const params = new URLSearchParams(searchParams); let changed = false; if (rawAction !== action) { if (action) params.set("action", action); else params.delete("action"); changed = true; } if (targetParam !== targetType) { params.delete("targetType"); changed = true; } if (changed) setSearchParams(params, { replace: true }); }, [action, rawAction, searchParams, setSearchParams, targetParam, targetType]); useEffect(() => { setActionDraft(action); setTargetDraft(targetType); }, [action, targetType]); useEffect(() => { void load(); }, [action, targetType]); const applyFilters = () => { const params = new URLSearchParams(searchParams); const nextAction = actionDraft.trim().slice(0, 100); if (nextAction) params.set("action", nextAction); else params.delete("action"); if (targetDraft) params.set("targetType", targetDraft); else params.delete("targetType"); if (nextAction === action && targetDraft === targetType) void load(); else setSearchParams(params); }; const columns = [ { colKey: "occurredAt", title: "时间", cell: ({ row }: any) => dateText(row.occurredAt, timezone) }, { colKey: "actorUsername", title: "操作者", cell: ({ row }: any) => row.actorUsername || "系统" }, { colKey: "action", title: "动作", cell: ({ row }: any) => {ACTION_LABELS[row.action] || row.action} }, { colKey: "target", title: "目标", cell: ({ row }: any) => {TARGET_LABELS[row.targetType] || row.targetType}{row.targetId ? ` / ${row.targetId.slice(0, 8)}` : ""} }, { colKey: "outcome", title: "结果", cell: ({ row }: any) => {outcomeLabel(row.outcome)} }, ]; return void load()} disabled={loading || loadingMore} icon={}>刷新}>
{ e.preventDefault(); applyFilters(); }}>} />