254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { listAudit, type AuditEntry } from "../api";
|
||
|
||
// 操作方法配色
|
||
const ACTION_STYLE: Record<string, string> = {
|
||
POST: "bg-emerald-50 text-emerald-600 border border-emerald-100",
|
||
PUT: "bg-amber-50 text-amber-600 border border-amber-100",
|
||
DELETE: "bg-rose-50 text-rose-600 border border-rose-100",
|
||
PATCH: "bg-violet-50 text-violet-600 border border-violet-100",
|
||
GET: "bg-gray-50 text-gray-600 border border-gray-100",
|
||
};
|
||
|
||
const statusTone = (s: number) => (s < 300 ? "text-emerald-600" : s < 500 ? "text-amber-600" : "text-rose-500");
|
||
const shortId = (id: string) => (id && id.length > 10 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id || "系统");
|
||
const fmt = (at: string) => new Date(at).toLocaleString("zh-CN", { hour12: false });
|
||
|
||
const LIMIT = 20;
|
||
|
||
const ROUTE_OPTS = [
|
||
{ value: "", label: "所有路由" },
|
||
{ value: "/api/v1/admin", label: "管理员接口 (/admin)" },
|
||
{ value: "/api/v1/prompts", label: "提示词接口 (/prompts)" },
|
||
{ value: "/api/v1/auth", label: "鉴权接口 (/auth)" },
|
||
{ value: "/api/v1/spaces", label: "空间接口 (/spaces)" },
|
||
];
|
||
|
||
export function AuditPage() {
|
||
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
||
const [err, setErr] = useState("");
|
||
|
||
// 筛选器状态
|
||
const [methodFilter, setMethodFilter] = useState<string>(""); // 空 = 全部
|
||
const [routeFilter, setRouteFilter] = useState<string>("");
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
|
||
// 分页状态
|
||
const [page, setPage] = useState(1);
|
||
const [hasMore, setHasMore] = useState(true);
|
||
|
||
const load = async (pageNum = page, showRef = false) => {
|
||
if (showRef) setRefreshing(true);
|
||
else setLoading(true);
|
||
|
||
try {
|
||
const offset = (pageNum - 1) * LIMIT;
|
||
// 从后端载入比 LIMIT 稍微多一条,以此判断是否有下一页
|
||
const data = await listAudit(LIMIT + 1, offset);
|
||
if (data.length > LIMIT) {
|
||
setAudit(data.slice(0, LIMIT));
|
||
setHasMore(true);
|
||
} else {
|
||
setAudit(data);
|
||
setHasMore(false);
|
||
}
|
||
setUpdatedAt(new Date());
|
||
setErr("");
|
||
} catch (er) {
|
||
setErr((er as Error).message);
|
||
} finally {
|
||
setLoading(false);
|
||
setRefreshing(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
void load(page);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [page]);
|
||
|
||
// 前端过滤(配合后端分页后的过滤,或按需做简单的前端实时匹配)
|
||
const filteredAudit = audit.filter((a) => {
|
||
if (methodFilter && a.action !== methodFilter) return false;
|
||
if (routeFilter && !a.path.startsWith(routeFilter)) return false;
|
||
if (searchQuery) {
|
||
const q = searchQuery.toLowerCase();
|
||
const matchActor = a.actor.toLowerCase().includes(q);
|
||
const matchIp = a.ip.toLowerCase().includes(q);
|
||
const matchDetail = a.detail?.toLowerCase().includes(q) ?? false;
|
||
const matchPath = a.path.toLowerCase().includes(q);
|
||
if (!matchActor && !matchIp && !matchDetail && !matchPath) return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
const handlePrevPage = () => {
|
||
if (page > 1) setPage(page - 1);
|
||
};
|
||
|
||
const handleNextPage = () => {
|
||
if (hasMore) setPage(page + 1);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* 顶栏 */}
|
||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-gray-150 bg-white p-5 shadow-sm">
|
||
<div>
|
||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">敏感操作审计</h3>
|
||
<p className="text-xs text-gray-400">敏感操作全链路留痕 · 只增不改,供合规审计与故障溯源</p>
|
||
</div>
|
||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||
{updatedAt && <span>最近更新:{updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}</span>}
|
||
<button
|
||
onClick={() => void load(page, true)}
|
||
disabled={refreshing}
|
||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||
>
|
||
<svg viewBox="0 0 24 24" className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||
</svg>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 筛选工具栏 */}
|
||
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm space-y-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
{/* HTTP Method Button Group */}
|
||
<div className="flex items-center gap-1.5 rounded-lg border border-gray-150 bg-gray-50 p-1 text-xs">
|
||
{["", "GET", "POST", "PUT", "DELETE"].map((m) => (
|
||
<button
|
||
key={m}
|
||
onClick={() => { setMethodFilter(m); setPage(1); }}
|
||
className={`rounded px-3 py-1 font-semibold transition-all ${
|
||
methodFilter === m
|
||
? "bg-violet-600 text-white shadow-sm"
|
||
: "text-gray-500 hover:text-gray-700"
|
||
}`}
|
||
>
|
||
{m || "全部"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
{/* Route prefix select */}
|
||
<select
|
||
value={routeFilter}
|
||
onChange={(e) => { setRouteFilter(e.target.value); setPage(1); }}
|
||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-700 focus:outline-none focus:border-violet-400 shadow-sm"
|
||
>
|
||
{ROUTE_OPTS.map((o) => (
|
||
<option key={o.value} value={o.value}>{o.label}</option>
|
||
))}
|
||
</select>
|
||
|
||
{/* Search Input */}
|
||
<input
|
||
type="text"
|
||
placeholder="搜索操作者 ID / IP / 详情…"
|
||
value={searchQuery}
|
||
onChange={(e) => { setSearchQuery(e.target.value); setPage(1); }}
|
||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs focus:outline-none focus:border-violet-400 w-56 shadow-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 审计日志列表 */}
|
||
<section className="rounded-2xl border border-gray-200/70 bg-white p-5 shadow-sm">
|
||
<div className="mb-4 flex items-baseline justify-between border-b pb-3">
|
||
<div className="flex items-baseline gap-2">
|
||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">操作日志明细</h3>
|
||
<span className="text-xs text-gray-400">当前页展示 {filteredAudit.length} 条</span>
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="py-12 text-center text-xs text-gray-400 animate-pulse">正在载入审计记录…</div>
|
||
) : err ? (
|
||
<div className="py-12 text-center text-xs text-rose-500">加载失败:{err}</div>
|
||
) : filteredAudit.length === 0 ? (
|
||
<div className="py-12 text-center text-xs text-gray-400">没有匹配的审计记录</div>
|
||
) : (
|
||
<div className="space-y-1.5">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||
<th className="py-2 pr-3 font-medium w-16">方法</th>
|
||
<th className="py-2 pr-3 font-medium">路由路径</th>
|
||
<th className="py-2 pr-3 font-medium w-16">状态</th>
|
||
<th className="py-2 pr-3 font-medium">详情</th>
|
||
<th className="py-2 pr-3 font-medium w-24">操作人</th>
|
||
<th className="py-2 pr-3 font-medium w-28">客户端 IP</th>
|
||
<th className="py-2 font-medium w-36">触发时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredAudit.map((a) => (
|
||
<tr key={a.id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50/40 text-xs">
|
||
<td className="py-2.5 pr-3">
|
||
<span className={`inline-block w-14 rounded px-1.5 py-0.5 text-center text-[10px] font-bold ${ACTION_STYLE[a.action] ?? "bg-gray-100 text-gray-500"}`}>
|
||
{a.action}
|
||
</span>
|
||
</td>
|
||
<td className="py-2.5 pr-3 max-w-[12rem] truncate font-mono text-[11px] text-gray-600" title={a.path}>
|
||
{a.path}
|
||
</td>
|
||
<td className={`py-2.5 pr-3 font-mono font-semibold ${statusTone(a.status)}`}>
|
||
{a.status}
|
||
</td>
|
||
<td className="py-2.5 pr-3 text-gray-500 text-[11px] max-w-[20rem] truncate" title={a.detail}>
|
||
{a.detail || "—"}
|
||
</td>
|
||
<td className="py-2.5 pr-3 font-mono text-gray-400" title={a.actor}>
|
||
{shortId(a.actor)}
|
||
</td>
|
||
<td className="py-2.5 pr-3 text-gray-400 font-mono">
|
||
{a.ip}
|
||
</td>
|
||
<td className="py-2.5 text-gray-400">
|
||
{fmt(a.at)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination controls */}
|
||
<div className="flex items-center justify-between border-t border-gray-100 pt-4 mt-2">
|
||
<span className="text-xs text-gray-400">
|
||
当前第 <span className="font-semibold text-gray-700">{page}</span> 页
|
||
</span>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={handlePrevPage}
|
||
disabled={page === 1}
|
||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||
>
|
||
上一页
|
||
</button>
|
||
<button
|
||
onClick={handleNextPage}
|
||
disabled={!hasMore}
|
||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|