167 lines
7.6 KiB
TypeScript
167 lines
7.6 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { Clock3, RefreshCw, Search, ShieldCheck } from 'lucide-react';
|
|
import { RouteGuard } from '@/components/auth/Guard';
|
|
import { type AuditEvent, type AuditQuery, listAuditEvents } from '@/lib/audit-api';
|
|
|
|
const EMPTY_QUERY: AuditQuery = { take: '50' };
|
|
|
|
export default function AuditPage() {
|
|
return (
|
|
<RouteGuard permission="audit:view">
|
|
<AuditPageContent />
|
|
</RouteGuard>
|
|
);
|
|
}
|
|
|
|
function AuditPageContent() {
|
|
const [query, setQuery] = useState<AuditQuery>(EMPTY_QUERY);
|
|
const [events, setEvents] = useState<AuditEvent[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchEvents = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
setEvents(await listAuditEvents(query));
|
|
} catch (e: any) {
|
|
setError(e?.message ?? '读取审计失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { void fetchEvents(); }, []);
|
|
|
|
const entityTypes = useMemo(
|
|
() => Array.from(new Set(events.map((event) => event.entityType))).sort(),
|
|
[events],
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col bg-[var(--bg)]">
|
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
|
<div className="flex items-center gap-2.5">
|
|
<ShieldCheck className="h-4 w-4 text-[var(--accent)]" strokeWidth={2} />
|
|
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">审计事件</h1>
|
|
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
|
|
{events.length}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => void fetchEvents()}
|
|
disabled={loading}
|
|
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:opacity-50"
|
|
>
|
|
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
|
|
刷新
|
|
</button>
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
|
<section className="mb-3 grid gap-2 border-b border-[var(--line)] pb-3 lg:grid-cols-[repeat(7,minmax(0,1fr))_auto]">
|
|
<FilterInput label="操作人" value={query.actorId} onChange={(actorId) => setQuery((prev) => ({ ...prev, actorId }))} />
|
|
<FilterInput label="实体类型" value={query.entityType} list="audit-entity-types" onChange={(entityType) => setQuery((prev) => ({ ...prev, entityType }))} />
|
|
<FilterInput label="实体 ID" value={query.entityId} onChange={(entityId) => setQuery((prev) => ({ ...prev, entityId }))} />
|
|
<FilterInput label="产品 ID" value={query.productId} onChange={(productId) => setQuery((prev) => ({ ...prev, productId }))} />
|
|
<FilterInput label="项目 ID" value={query.projectId} onChange={(projectId) => setQuery((prev) => ({ ...prev, projectId }))} />
|
|
<FilterInput label="版本 ID" value={query.versionId} onChange={(versionId) => setQuery((prev) => ({ ...prev, versionId }))} />
|
|
<FilterInput label="数量" value={query.take} onChange={(take) => setQuery((prev) => ({ ...prev, take }))} />
|
|
<button
|
|
onClick={() => void fetchEvents()}
|
|
className="mt-5 flex h-8 items-center justify-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[12px] font-medium text-white hover:bg-[var(--accent-hover)]"
|
|
>
|
|
<Search className="h-3.5 w-3.5" />
|
|
查询
|
|
</button>
|
|
<datalist id="audit-entity-types">
|
|
{entityTypes.map((type) => <option key={type} value={type} />)}
|
|
</datalist>
|
|
</section>
|
|
|
|
{error && (
|
|
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-700">{error}</div>
|
|
)}
|
|
|
|
<div className="overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
|
<table className="w-full table-fixed text-left text-[12px]">
|
|
<thead className="bg-[var(--bg-subtle)] text-[11px] uppercase text-[var(--ink-muted)]">
|
|
<tr>
|
|
<th className="w-40 px-3 py-2 font-semibold">时间</th>
|
|
<th className="w-36 px-3 py-2 font-semibold">操作</th>
|
|
<th className="w-32 px-3 py-2 font-semibold">实体</th>
|
|
<th className="w-32 px-3 py-2 font-semibold">操作人</th>
|
|
<th className="px-3 py-2 font-semibold">作用域</th>
|
|
<th className="w-32 px-3 py-2 font-semibold">结果</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[var(--line)]">
|
|
{events.map((event) => (
|
|
<tr key={`${event.id}-${event.createdAt}`} className="hover:bg-[var(--bg-subtle)]/70">
|
|
<td className="px-3 py-2 text-[var(--ink-soft)]"><TimeLabel value={event.createdAt} /></td>
|
|
<td className="truncate px-3 py-2 font-medium text-[var(--ink)]">{event.action}</td>
|
|
<td className="truncate px-3 py-2 text-[var(--ink-soft)]">{event.entityType}<span className="ml-1 text-[var(--ink-muted)]">{event.entityId}</span></td>
|
|
<td className="truncate px-3 py-2 text-[var(--ink-soft)]">{event.actorName || event.actorId || '-'}</td>
|
|
<td className="truncate px-3 py-2 text-[var(--ink-soft)]">{formatScope(event)}</td>
|
|
<td className="px-3 py-2">
|
|
<details>
|
|
<summary className="cursor-pointer text-[var(--accent)]">查看 JSON</summary>
|
|
<pre className="mt-2 max-h-48 overflow-auto rounded-md bg-zinc-950 p-2 text-[10px] leading-4 text-zinc-100">{JSON.stringify({ before: event.before, after: event.after, metadata: event.metadata }, null, 2)}</pre>
|
|
</details>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{events.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="px-3 py-10 text-center text-[13px] text-[var(--ink-muted)]">
|
|
{loading ? '加载中...' : '暂无审计事件'}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FilterInput({ label, value, onChange, list }: { label: string; value?: string; onChange: (value: string) => void; list?: string }) {
|
|
return (
|
|
<label className="block">
|
|
<span className="mb-1 block text-[11px] font-medium text-[var(--ink-muted)]">{label}</span>
|
|
<input
|
|
value={value ?? ''}
|
|
list={list}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
className="h-8 w-full rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[12px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
|
/>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function TimeLabel({ value }: { value: string }) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Clock3 className="h-3 w-3 text-[var(--ink-muted)]" />
|
|
{formatDateTime(value)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function formatScope(event: AuditEvent) {
|
|
return [
|
|
event.productId ? `P:${event.productId}` : '',
|
|
event.projectId ? `J:${event.projectId}` : '',
|
|
event.versionId ? `V:${event.versionId}` : '',
|
|
].filter(Boolean).join(' / ') || '-';
|
|
}
|
|
|
|
function formatDateTime(value: string) {
|
|
const date = new Date(value);
|
|
if (!Number.isFinite(date.getTime())) return value;
|
|
return date.toISOString().slice(0, 16).replace('T', ' ');
|
|
}
|