Files
TallyNote/web-next/src/pages/audit/AuditPage.tsx
T
Qiufeng 12495fb6a4
TallyNote release / linux-x64 (push) Successful in 6m24s
release: 1.1.0
2026-08-31 20:11:34 +08:00

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>;
}