Add admin dashboard filtering and sorting
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Provide stable server-side list queries and focused chart controls so operators can inspect large datasets without misleading partial-page ordering.
This commit is contained in:
Rocky
2026-08-20 18:05:49 +08:00
parent 034a3e8745
commit 74c3fcd45f
39 changed files with 2990 additions and 431 deletions
+110 -42
View File
@@ -1,9 +1,16 @@
import { FileCheck2, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type { AuditLogEntry } from "../../api/types";
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,
@@ -12,48 +19,56 @@ import {
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 [items, setItems] = useState<AuditLogEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
try {
const page = await adminApi.auditLogs();
setItems(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
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(() => {
void load();
}, [load]);
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = await adminApi.auditLogs(nextCursor);
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "加载审计记录失败",
);
} finally {
setLoadingMore(false);
}
}
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>[]>(
() => [
@@ -140,9 +155,55 @@ export function AuditPage() {
</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={() => void load()} />
<ErrorState error={error} retry={reload} />
</div>
) : loading ? (
<LoadingState label="加载审计日志" />
@@ -151,7 +212,14 @@ export function AuditPage() {
data={items}
columns={columns}
caption="管理员审计事件"
emptyTitle="暂无审计记录"
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">