feat(admin): upgrade admin console UI widgets, add plan & status management, and redesign system status dashboard

This commit is contained in:
Blizzard
2026-07-18 22:18:12 +08:00
parent 197531ea33
commit 0d2929931f
16 changed files with 1979 additions and 503 deletions
+195 -85
View File
@@ -1,33 +1,60 @@
import { useEffect, useState } from "react";
import { listAudit, listGuardrailEvents, type AuditEntry, type GuardrailEventItem } from "../api";
import { listAudit, type AuditEntry } from "../api";
// 操作方法配色
// 操作方法配色
const ACTION_STYLE: Record<string, string> = {
POST: "bg-emerald-50 text-emerald-600",
PUT: "bg-amber-50 text-amber-600",
DELETE: "bg-rose-50 text-rose-600",
PATCH: "bg-violet-50 text-violet-600",
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");
// uid 太长,展示时截断(保留首尾,hover 看全)。
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 [events, setEvents] = useState<GuardrailEventItem[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const [err, setErr] = useState("");
const load = async () => {
setRefreshing(true);
// 筛选器状态
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 [a, e] = await Promise.all([listAudit(80), listGuardrailEvents(80)]);
setAudit(a);
setEvents(e);
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) {
@@ -39,36 +66,47 @@ export function AuditPage() {
};
useEffect(() => {
void load();
const t = setInterval(() => void load(), 30000);
return () => clearInterval(t);
}, []);
void load(page);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
if (loading) return <div className="text-sm text-gray-400"></div>;
if (err) return <div className="text-sm text-rose-500">{err}</div>;
// 前端过滤(配合后端分页后的过滤,或按需做简单的前端实时匹配)
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 blocked = events.filter((e) => e.kind === "blocked").length;
const suspect = events.filter((e) => e.kind === "suspect").length;
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 gap-4 rounded-2xl border border-gray-200/70 bg-white p-5">
<div className="mr-auto">
<h3 className="text-sm font-semibold tracking-tight text-gray-900"> & </h3>
<p className="text-xs text-gray-400"> + · </p>
<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="grid grid-cols-3 divide-x divide-gray-100">
<Stat value={audit.length} label="操作留痕" />
<Stat value={blocked} label="护栏拦截" bad={blocked > 0} />
<Stat value={suspect} label="灰区放行" warn={suspect > 0} />
</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
{updatedAt && <span> {updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}</span>}
<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()}
onClick={() => void load(page, true)}
disabled={refreshing}
className="flex items-center gap-1 rounded border border-gray-200 px-2.5 py-1 text-gray-500 hover:bg-gray-50 disabled:opacity-40"
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" />
@@ -78,54 +116,134 @@ export function AuditPage() {
</div>
</div>
{/* 安全事件(护栏命中) */}
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
<div className="mb-3 flex items-baseline gap-2.5">
<h3 className="text-sm font-semibold tracking-tight text-gray-900"></h3>
<span className="text-xs text-gray-400"> / {events.length}</span>
</div>
{events.length === 0 ? (
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400"> 🎉</div>
) : (
<div className="divide-y divide-gray-100">
{events.map((e) => (
<div key={e.id} className="flex items-center gap-3 py-2.5 text-xs">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${e.kind === "blocked" ? "bg-rose-50 text-rose-600" : "bg-amber-50 text-amber-600"}`}>
{e.kind === "blocked" ? "拦截" : "灰区"}
</span>
<span className="min-w-0 flex-1 truncate text-gray-700" title={e.reason}>{e.reason || "(无原因)"}</span>
<code className="hidden shrink-0 font-mono text-[10px] text-gray-400 sm:inline">{e.method} {e.path}</code>
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={e.actor}>{shortId(e.actor)}</span>
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{e.ip}</span>
<span className="shrink-0 text-[10px] text-gray-300">{fmt(e.at)}</span>
</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>
)}
</section>
{/* 操作审计 */}
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
<div className="mb-3 flex items-baseline gap-2.5">
<h3 className="text-sm font-semibold tracking-tight text-gray-900"></h3>
<span className="text-xs text-gray-400"> / / / {audit.length}</span>
<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>
{audit.length === 0 ? (
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400"></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="divide-y divide-gray-100">
{audit.map((a) => (
<div key={a.id} className="flex items-center gap-3 py-2.5 text-xs">
<span className={`w-14 shrink-0 rounded px-1.5 py-0.5 text-center text-[10px] font-semibold ${ACTION_STYLE[a.action] ?? "bg-gray-100 text-gray-500"}`}>
{a.action}
</span>
<code className="min-w-0 flex-1 truncate font-mono text-[11px] text-gray-600" title={a.path}>{a.path}</code>
<span className={`shrink-0 font-mono text-[10px] ${statusTone(a.status)}`}>{a.status}</span>
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={a.actor}>{shortId(a.actor)}</span>
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{a.ip}</span>
<span className="shrink-0 text-[10px] text-gray-300">{fmt(a.at)}</span>
<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>
@@ -133,11 +251,3 @@ export function AuditPage() {
);
}
function Stat({ value, label, bad, warn }: { value: number; label: string; bad?: boolean; warn?: boolean }) {
return (
<div className="px-4 first:pl-0">
<div className={`text-xl font-semibold tracking-tight ${bad ? "text-rose-500" : warn ? "text-amber-600" : "text-gray-900"}`}>{value}</div>
<div className="text-[10px] text-gray-400">{label}</div>
</div>
);
}