Add StoreKit history and modernize admin console
Expose ledger-backed cross-device purchase history while shipping the tested React admin redesign in the same reproducible deployment revision.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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 { DataTable, type DataColumn } from "../../components/data-table";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
} from "../../components/primitives";
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 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">
|
||||
{error ? (
|
||||
<div className="p-6">
|
||||
<ErrorState error={error} retry={() => void load()} />
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingState label="加载审计日志" />
|
||||
) : (
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
caption="管理员审计事件"
|
||||
emptyTitle="暂无审计记录"
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user