74c3fcd45f
Provide stable server-side list queries and focused chart controls so operators can inspect large datasets without misleading partial-page ordering.
238 lines
8.0 KiB
TypeScript
238 lines
8.0 KiB
TypeScript
import { FileCheck2, ShieldCheck } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { adminApi } from "../../api/client";
|
|
import type {
|
|
AdminAuditAction,
|
|
AuditLogEntry,
|
|
AuditQuery,
|
|
SortOrder,
|
|
} from "../../api/types";
|
|
import { DataTable, type DataColumn } from "../../components/data-table";
|
|
import { DateRangeControl } from "../../components/date-range-control";
|
|
import { FilterControl } from "../../components/filter-control";
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
ErrorState,
|
|
LoadingState,
|
|
PageHeader,
|
|
} from "../../components/primitives";
|
|
import { TableToolbar } from "../../components/table-toolbar";
|
|
import { useCursorPage } from "../../hooks/use-cursor-page";
|
|
import { formatDateTime, statusLabel } from "../../lib/format";
|
|
|
|
export function AuditPage() {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
|
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
|
const [action, setAction] = useState<"" | AdminAuditAction>(
|
|
(searchParams.get("action") as AdminAuditAction | null) ?? "",
|
|
);
|
|
const [result, setResult] = useState<"" | AuditLogEntry["result"]>(
|
|
(searchParams.get("result") as AuditLogEntry["result"] | null) ?? "",
|
|
);
|
|
const [order, setOrder] = useState<SortOrder>(
|
|
searchParams.get("order") === "asc" ? "asc" : "desc",
|
|
);
|
|
const auditQuery = useMemo<AuditQuery>(
|
|
() => ({
|
|
from: from || undefined,
|
|
until: until || undefined,
|
|
action: action || undefined,
|
|
result: result || undefined,
|
|
sort: "createdAt",
|
|
order,
|
|
limit: 50,
|
|
}),
|
|
[action, from, order, result, until],
|
|
);
|
|
const fetchAudit = useCallback((value: AuditQuery) => adminApi.auditLogs(value), []);
|
|
const {
|
|
items,
|
|
nextCursor,
|
|
loading,
|
|
loadingMore,
|
|
error,
|
|
loadMore,
|
|
reload,
|
|
} = useCursorPage(auditQuery, fetchAudit);
|
|
|
|
useEffect(() => {
|
|
const params = new URLSearchParams();
|
|
if (from) params.set("from", from);
|
|
if (until) params.set("until", until);
|
|
if (action) params.set("action", action);
|
|
if (result) params.set("result", result);
|
|
params.set("sort", "createdAt");
|
|
params.set("order", order);
|
|
setSearchParams(params, { replace: true });
|
|
}, [action, from, order, result, setSearchParams, until]);
|
|
|
|
const columns = useMemo<DataColumn<AuditLogEntry>[]>(
|
|
() => [
|
|
{
|
|
accessorKey: "createdAt",
|
|
header: "时间",
|
|
cell: ({ getValue }) => (
|
|
<span className="whitespace-nowrap text-xs text-muted">
|
|
{formatDateTime(String(getValue()))}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "operatorName",
|
|
header: "操作员",
|
|
cell: ({ getValue }) => <span className="font-semibold">{String(getValue())}</span>,
|
|
},
|
|
{
|
|
accessorKey: "action",
|
|
header: "操作",
|
|
cell: ({ getValue }) => (
|
|
<span className="rounded-lg bg-primary-soft px-2 py-1 font-mono text-[11px] text-primary">
|
|
{String(getValue())}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: "target",
|
|
header: "目标",
|
|
cell: ({ row }) => (
|
|
<span>
|
|
<span className="block text-xs font-medium">{row.original.targetType}</span>
|
|
<span className="mt-1 block max-w-48 truncate font-mono text-[11px] text-muted">
|
|
{row.original.targetId}
|
|
</span>
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "requestId",
|
|
header: "请求 ID",
|
|
cell: ({ getValue }) => (
|
|
<span className="font-mono text-[11px] text-muted">{String(getValue() || "—")}</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "result",
|
|
header: "结果",
|
|
cell: ({ getValue }) => {
|
|
const result = String(getValue());
|
|
return (
|
|
<Badge tone={result === "success" ? "success" : "danger"}>
|
|
{statusLabel(result)}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
eyebrow="安全与合规"
|
|
title="审计日志"
|
|
description="追踪管理员操作、目标与执行结果,形成完整可追溯链路。"
|
|
/>
|
|
|
|
<Card className="flex flex-col gap-4 border-primary/15 bg-gradient-to-r from-primary-soft/80 to-surface p-5 sm:flex-row sm:items-center sm:p-6">
|
|
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-surface text-primary shadow-sm">
|
|
<ShieldCheck className="size-5" aria-hidden />
|
|
</span>
|
|
<div>
|
|
<h2 className="text-sm font-semibold">不可变审计保护</h2>
|
|
<p className="mt-1 text-xs leading-5 text-muted">
|
|
管理员变更、积分赠送和安全操作都会记录请求标识与处理结果。
|
|
</p>
|
|
</div>
|
|
<Badge className="sm:ml-auto" tone="info">
|
|
<FileCheck2 className="size-3.5" aria-hidden />
|
|
已加载 {items.length} 条
|
|
</Badge>
|
|
</Card>
|
|
|
|
<Card className="overflow-hidden">
|
|
<TableToolbar
|
|
active={Boolean(from || until || action || result || order !== "desc")}
|
|
onClear={() => {
|
|
setFrom("");
|
|
setUntil("");
|
|
setAction("");
|
|
setResult("");
|
|
setOrder("desc");
|
|
}}
|
|
>
|
|
<DateRangeControl
|
|
value={{ from: from || undefined, until: until || undefined }}
|
|
onChange={(value) => {
|
|
setFrom(value.from ?? "");
|
|
setUntil(value.until ?? "");
|
|
}}
|
|
/>
|
|
<FilterControl
|
|
label="操作类型"
|
|
value={action}
|
|
options={[
|
|
{ value: "", label: "全部操作" },
|
|
{ value: "LOGIN_SUCCEEDED", label: "登录成功" },
|
|
{ value: "LOGIN_FAILED", label: "登录失败" },
|
|
{ value: "SESSION_REVOKED", label: "会话撤销" },
|
|
{ value: "OPERATOR_CREATED", label: "创建管理员" },
|
|
{ value: "OPERATOR_ENABLED", label: "启用管理员" },
|
|
{ value: "OPERATOR_DISABLED", label: "停用管理员" },
|
|
{ value: "OPERATOR_UNLOCKED", label: "解锁管理员" },
|
|
{ value: "OPERATOR_CREDENTIALS_RESET", label: "重置管理员凭据" },
|
|
{ value: "OPERATOR_SESSIONS_REVOKED", label: "撤销管理员会话" },
|
|
{ value: "MANUAL_CREDIT_GRANTED", label: "人工赠送积分" },
|
|
]}
|
|
onChange={setAction}
|
|
/>
|
|
<FilterControl
|
|
label="执行结果"
|
|
value={result}
|
|
options={[
|
|
{ value: "", label: "全部结果" },
|
|
{ value: "success", label: "成功" },
|
|
{ value: "rejected", label: "已拒绝" },
|
|
]}
|
|
onChange={setResult}
|
|
/>
|
|
</TableToolbar>
|
|
{error ? (
|
|
<div className="p-6">
|
|
<ErrorState error={error} retry={reload} />
|
|
</div>
|
|
) : loading ? (
|
|
<LoadingState label="加载审计日志" />
|
|
) : (
|
|
<DataTable
|
|
data={items}
|
|
columns={columns}
|
|
caption="管理员审计事件"
|
|
emptyTitle={
|
|
from || until || action || result
|
|
? "没有符合当前筛选条件的审计记录"
|
|
: "暂无审计记录"
|
|
}
|
|
sort={{ key: "createdAt", order }}
|
|
sortableColumns={{ createdAt: "createdAt" }}
|
|
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
|
footer={
|
|
nextCursor ? (
|
|
<div className="flex justify-center border-t border-border p-5">
|
|
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
|
|
加载更早记录
|
|
</Button>
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|