141 lines
8.3 KiB
TypeScript
141 lines
8.3 KiB
TypeScript
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<string, string> = {
|
|
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<string, string> = {
|
|
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<AuditItem[]>([]);
|
|
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) => <span className="tn-code" title={row.action}>{ACTION_LABELS[row.action] || row.action}</span> },
|
|
{ colKey: "target", title: "目标", cell: ({ row }: any) => <span className="tn-code" title={row.targetId ? `${row.targetType} / ${row.targetId}` : row.targetType}>{TARGET_LABELS[row.targetType] || row.targetType}{row.targetId ? ` / ${row.targetId.slice(0, 8)}` : ""}</span> },
|
|
{ colKey: "outcome", title: "结果", cell: ({ row }: any) => <Tag theme={row.outcome === "success" || !row.outcome ? "success" : row.outcome === "denied" ? "warning" : "danger"}>{outcomeLabel(row.outcome)}</Tag> },
|
|
];
|
|
|
|
return <Page title="审计日志" subtitle="记录登录、账目、附件、导出、管理员和更新操作。日志只读,永久删除也不会清除它。" actions={<Button variant="outline" onClick={() => void load()} disabled={loading || loadingMore} icon={<RotateCcw size={15} />}>刷新</Button>}>
|
|
<form className="tn-toolbar tn-audit-toolbar" onSubmit={e => { e.preventDefault(); applyFilters(); }}><AccessibleInput inputAriaLabel="动作筛选" value={actionDraft} onChange={setActionDraft} maxlength={100} placeholder="动作,例如 expense.created" prefixIcon={<Search size={16} />} /><Select aria-label="目标类型" value={targetDraft} onChange={v => setTargetDraft(String(v))} options={[{ label: "全部目标", value: "" }, { label: "账目", value: "expense" }, { label: "管理员", value: "admin" }, { label: "导出", value: "export" }, { label: "会话", value: "session" }, { label: "更新任务", value: "update" }, { label: "系统检查", value: "system" }]} /><Button theme="primary" type="submit" icon={<Search size={15} />}>筛选</Button></form>
|
|
<AsyncState loading={loading} error={error} empty={items.length === 0 ? <div className="tn-empty"><Archive size={30} /><p>{action || targetType ? "没有符合当前筛选条件的审计记录" : "暂无审计记录"}</p>{(action || targetType) && <Button variant="outline" onClick={() => setSearchParams(new URLSearchParams())}>清除筛选</Button>}</div> : undefined} onRetry={() => void load()}><div className="tn-table-wrap tn-audit-table" role="region" aria-label="审计日志列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} hover stripe /></div>{hasMore && <div className="tn-table-more"><Button variant="outline" onClick={() => void load(true)} disabled={loadingMore} icon={<RotateCcw size={15} />}>{loadingMore ? "加载中…" : "加载更早记录"}</Button></div>}</AsyncState>
|
|
</Page>;
|
|
}
|