feat(admin): 增加审计与一致性页面
This commit is contained in:
166
apps/web/app/admin/audit/page.tsx
Normal file
166
apps/web/app/admin/audit/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
'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', ' ');
|
||||||
|
}
|
||||||
164
apps/web/app/admin/consistency/page.tsx
Normal file
164
apps/web/app/admin/consistency/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { AlertTriangle, CheckCircle2, Database, RefreshCw } from 'lucide-react';
|
||||||
|
import { RouteGuard } from '@/components/auth/Guard';
|
||||||
|
import {
|
||||||
|
type ConsistencyCheckResult,
|
||||||
|
type ConsistencyResult,
|
||||||
|
getConsistencyReport,
|
||||||
|
summarizeConsistencyResult,
|
||||||
|
} from '@/lib/consistency-api';
|
||||||
|
|
||||||
|
export default function ConsistencyPage() {
|
||||||
|
return (
|
||||||
|
<RouteGuard permission="consistency:view">
|
||||||
|
<ConsistencyPageContent />
|
||||||
|
</RouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConsistencyPageContent() {
|
||||||
|
const [report, setReport] = useState<ConsistencyResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchReport = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setReport(await getConsistencyReport());
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? '读取一致性报告失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { void fetchReport(); }, []);
|
||||||
|
|
||||||
|
const totals = useMemo(() => report ? summarizeConsistencyResult(report) : null, [report]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Database className="h-4 w-4 text-[var(--accent)]" strokeWidth={2} />
|
||||||
|
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">一致性校验</h1>
|
||||||
|
{report && <StatusPill status={report.status} />}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => void fetchReport()}
|
||||||
|
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">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report && totals && (
|
||||||
|
<>
|
||||||
|
<section className="mb-4 grid gap-3 md:grid-cols-4">
|
||||||
|
<Metric label="错误" value={totals.error} tone="error" />
|
||||||
|
<Metric label="告警" value={totals.warn} tone="warn" />
|
||||||
|
<Metric label="通过" value={totals.ok} tone="ok" />
|
||||||
|
<Metric label="检查项" value={totals.total} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-4 overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||||
|
<div className="border-b border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-2 text-[12px] font-semibold text-[var(--ink)]">
|
||||||
|
数据计数
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-px bg-[var(--line)] sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Object.entries(report.counts).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex items-center justify-between bg-[var(--bg-card)] px-3 py-2">
|
||||||
|
<span className="text-[12px] text-[var(--ink-soft)]">{key}</span>
|
||||||
|
<span className="font-mono text-[12px] font-semibold tabular-nums text-[var(--ink)]">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<CheckGroup title="分区键" checks={report.checks.partitionKeys} />
|
||||||
|
<CheckGroup title="孤儿引用" checks={report.checks.orphanReferences} />
|
||||||
|
<CheckGroup title="审计覆盖" checks={report.checks.auditCoverage} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!report && !error && (
|
||||||
|
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-12 text-center text-[13px] text-[var(--ink-muted)]">
|
||||||
|
{loading ? '校验中...' : '暂无校验结果'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value, tone }: { label: string; value: number; tone?: 'ok' | 'warn' | 'error' }) {
|
||||||
|
const toneClass = tone === 'error'
|
||||||
|
? 'text-red-600'
|
||||||
|
: tone === 'warn'
|
||||||
|
? 'text-amber-600'
|
||||||
|
: tone === 'ok'
|
||||||
|
? 'text-emerald-600'
|
||||||
|
: 'text-[var(--ink)]';
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3">
|
||||||
|
<div className={`font-mono text-[20px] font-semibold tabular-nums ${toneClass}`}>{value}</div>
|
||||||
|
<div className="mt-0.5 text-[12px] text-[var(--ink-muted)]">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckGroup({ title, checks }: { title: string; checks: ConsistencyCheckResult[] }) {
|
||||||
|
return (
|
||||||
|
<section className="mb-4 overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-2">
|
||||||
|
<h2 className="text-[12px] font-semibold text-[var(--ink)]">{title}</h2>
|
||||||
|
<span className="text-[11px] text-[var(--ink-muted)]">{checks.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-[var(--line)]">
|
||||||
|
{checks.map((check) => (
|
||||||
|
<div key={check.id} className="grid grid-cols-[120px_1fr_80px] items-center gap-3 px-3 py-2 text-[12px]">
|
||||||
|
<SeverityBadge severity={check.severity} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium text-[var(--ink)]">{check.id}</div>
|
||||||
|
<div className="truncate text-[var(--ink-muted)]">{check.message}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right font-mono text-[12px] tabular-nums text-[var(--ink-soft)]">{check.count}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: ConsistencyResult['status'] }) {
|
||||||
|
const ok = status === 'pass';
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex h-6 items-center gap-1 rounded-md px-2 text-[11px] font-semibold ${ok ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}`}>
|
||||||
|
{ok ? <CheckCircle2 className="h-3.5 w-3.5" /> : <AlertTriangle className="h-3.5 w-3.5" />}
|
||||||
|
{ok ? 'PASS' : 'FAIL'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeverityBadge({ severity }: { severity: ConsistencyCheckResult['severity'] }) {
|
||||||
|
const className = severity === 'error'
|
||||||
|
? 'bg-red-50 text-red-700 border-red-200'
|
||||||
|
: severity === 'warn'
|
||||||
|
? 'bg-amber-50 text-amber-700 border-amber-200'
|
||||||
|
: 'bg-emerald-50 text-emerald-700 border-emerald-200';
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex h-6 w-20 items-center justify-center rounded-md border text-[11px] font-semibold uppercase ${className}`}>
|
||||||
|
{severity}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark } from 'lucide-react';
|
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark, ScrollText, Database } from 'lucide-react';
|
||||||
import { useHasPermission } from '@/components/auth/Guard';
|
import { useHasPermission } from '@/components/auth/Guard';
|
||||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||||
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
||||||
@@ -45,6 +45,8 @@ const NAV_GROUPS = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' },
|
{ label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' },
|
||||||
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' },
|
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' },
|
||||||
|
{ label: '审计', path: '/admin/audit', icon: ScrollText, permission: 'audit:view' },
|
||||||
|
{ label: '一致性', path: '/admin/consistency', icon: Database, permission: 'consistency:view' },
|
||||||
{ label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' },
|
{ label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
17
apps/web/lib/admin-v25-source.test.ts
Normal file
17
apps/web/lib/admin-v25-source.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
test('V2.5 admin pages are routed, guarded, and visible through permissions', () => {
|
||||||
|
const auditPage = readFileSync('app/admin/audit/page.tsx', 'utf8');
|
||||||
|
const consistencyPage = readFileSync('app/admin/consistency/page.tsx', 'utf8');
|
||||||
|
const sidebar = readFileSync('components/layout/Sidebar.tsx', 'utf8');
|
||||||
|
const permissions = readFileSync('lib/permissions.ts', 'utf8');
|
||||||
|
|
||||||
|
assert.match(auditPage, /RouteGuard permission="audit:view"/);
|
||||||
|
assert.match(consistencyPage, /RouteGuard permission="consistency:view"/);
|
||||||
|
assert.ok(sidebar.includes("path: '/admin/audit'"));
|
||||||
|
assert.ok(sidebar.includes("path: '/admin/consistency'"));
|
||||||
|
assert.match(permissions, /permission: 'audit:view'/);
|
||||||
|
assert.match(permissions, /permission: 'consistency:view'/);
|
||||||
|
});
|
||||||
16
apps/web/lib/audit-api.test.ts
Normal file
16
apps/web/lib/audit-api.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { buildAuditQueryPath } from './audit-api';
|
||||||
|
|
||||||
|
test('buildAuditQueryPath keeps only non-empty audit filters', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildAuditQueryPath({
|
||||||
|
actorId: 'm-8',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityId: '',
|
||||||
|
take: '25',
|
||||||
|
}),
|
||||||
|
'/audit?actorId=m-8&entityType=dev_task&take=25',
|
||||||
|
);
|
||||||
|
});
|
||||||
43
apps/web/lib/audit-api.ts
Normal file
43
apps/web/lib/audit-api.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
export interface AuditEvent {
|
||||||
|
id: string;
|
||||||
|
actorId?: string | null;
|
||||||
|
actorName?: string | null;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
productId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
versionId?: string | null;
|
||||||
|
scope?: unknown;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
metadata?: unknown;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditQuery {
|
||||||
|
actorId?: string;
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
productId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
versionId?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
take?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuditQueryPath(query: AuditQuery = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (typeof value === 'string' && value.trim()) params.set(key, value.trim());
|
||||||
|
}
|
||||||
|
const search = params.toString();
|
||||||
|
return search ? `/audit?${search}` : '/audit';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAuditEvents(query: AuditQuery = {}) {
|
||||||
|
return api.get<AuditEvent[]>(buildAuditQueryPath(query));
|
||||||
|
}
|
||||||
25
apps/web/lib/consistency-api.test.ts
Normal file
25
apps/web/lib/consistency-api.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { summarizeConsistencyResult, type ConsistencyResult } from './consistency-api';
|
||||||
|
|
||||||
|
test('summarizeConsistencyResult counts ok warn and error checks', () => {
|
||||||
|
const result: ConsistencyResult = {
|
||||||
|
generatedAt: '2026-07-08T08:00:00.000Z',
|
||||||
|
status: 'fail',
|
||||||
|
counts: { products: 1 },
|
||||||
|
checks: {
|
||||||
|
partitionKeys: [{ id: 'p', label: 'p', severity: 'ok', count: 0, message: 'ok' }],
|
||||||
|
orphanReferences: [{ id: 'o', label: 'o', severity: 'error', count: 2, message: 'bad' }],
|
||||||
|
auditCoverage: [{ id: 'a', label: 'a', severity: 'warn', count: 0, message: 'missing' }],
|
||||||
|
},
|
||||||
|
summary: { errors: 1, warnings: 1, human: 'summary' },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(summarizeConsistencyResult(result), {
|
||||||
|
ok: 1,
|
||||||
|
warn: 1,
|
||||||
|
error: 1,
|
||||||
|
total: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
46
apps/web/lib/consistency-api.ts
Normal file
46
apps/web/lib/consistency-api.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
export type ConsistencySeverity = 'ok' | 'warn' | 'error';
|
||||||
|
export type ConsistencyStatus = 'pass' | 'fail';
|
||||||
|
|
||||||
|
export interface ConsistencyCheckResult {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
severity: ConsistencySeverity;
|
||||||
|
count: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsistencyResult {
|
||||||
|
generatedAt: string;
|
||||||
|
status: ConsistencyStatus;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
checks: {
|
||||||
|
partitionKeys: ConsistencyCheckResult[];
|
||||||
|
orphanReferences: ConsistencyCheckResult[];
|
||||||
|
auditCoverage: ConsistencyCheckResult[];
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
errors: number;
|
||||||
|
warnings: number;
|
||||||
|
human: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConsistencyReport() {
|
||||||
|
return api.get<ConsistencyResult>('/consistency');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeConsistencyResult(result: ConsistencyResult) {
|
||||||
|
const checks = [
|
||||||
|
...result.checks.partitionKeys,
|
||||||
|
...result.checks.orphanReferences,
|
||||||
|
...result.checks.auditCoverage,
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
ok: checks.filter((item) => item.severity === 'ok').length,
|
||||||
|
warn: checks.filter((item) => item.severity === 'warn').length,
|
||||||
|
error: checks.filter((item) => item.severity === 'error').length,
|
||||||
|
total: checks.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -54,6 +54,8 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
},
|
},
|
||||||
{ module: 'member', moduleLabel: '成员', category: 'main', actions: std4('member') },
|
{ module: 'member', moduleLabel: '成员', category: 'main', actions: std4('member') },
|
||||||
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
|
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
|
||||||
|
{ module: 'audit', moduleLabel: '审计', category: 'main', actions: [{ action: 'view', label: '查看', permission: 'audit:view' }] },
|
||||||
|
{ module: 'consistency', moduleLabel: '一致性', category: 'main', actions: [{ action: 'view', label: '查看', permission: 'consistency:view' }] },
|
||||||
{ module: 'version.req', moduleLabel: '需求 Tab', category: 'version_tab', actions: stdTab('version.req') },
|
{ module: 'version.req', moduleLabel: '需求 Tab', category: 'version_tab', actions: stdTab('version.req') },
|
||||||
{ module: 'version.research', moduleLabel: '调研 Tab', category: 'version_tab', actions: stdTab('version.research') },
|
{ module: 'version.research', moduleLabel: '调研 Tab', category: 'version_tab', actions: stdTab('version.research') },
|
||||||
{ module: 'version.product_plan', moduleLabel: '产品方案 Tab', category: 'version_tab', actions: stdTab('version.product_plan') },
|
{ module: 'version.product_plan', moduleLabel: '产品方案 Tab', category: 'version_tab', actions: stdTab('version.product_plan') },
|
||||||
|
|||||||
Reference in New Issue
Block a user