Files
sundynix-agentix/sundynix-admin/src/pages/AuditPage.tsx
T

254 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}