diff --git a/apps/web/app/admin/audit/page.tsx b/apps/web/app/admin/audit/page.tsx new file mode 100644 index 0000000..3fa38e9 --- /dev/null +++ b/apps/web/app/admin/audit/page.tsx @@ -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 ( + + + + ); +} + +function AuditPageContent() { + const [query, setQuery] = useState(EMPTY_QUERY); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+ +

审计事件

+ + {events.length} + +
+ +
+ +
+
+ setQuery((prev) => ({ ...prev, actorId }))} /> + setQuery((prev) => ({ ...prev, entityType }))} /> + setQuery((prev) => ({ ...prev, entityId }))} /> + setQuery((prev) => ({ ...prev, productId }))} /> + setQuery((prev) => ({ ...prev, projectId }))} /> + setQuery((prev) => ({ ...prev, versionId }))} /> + setQuery((prev) => ({ ...prev, take }))} /> + + + {entityTypes.map((type) => +
+ + {error && ( +
{error}
+ )} + +
+ + + + + + + + + + + + + {events.map((event) => ( + + + + + + + + + ))} + {events.length === 0 && ( + + + + )} + +
时间操作实体操作人作用域结果
{event.action}{event.entityType}{event.entityId}{event.actorName || event.actorId || '-'}{formatScope(event)} +
+ 查看 JSON +
{JSON.stringify({ before: event.before, after: event.after, metadata: event.metadata }, null, 2)}
+
+
+ {loading ? '加载中...' : '暂无审计事件'} +
+
+
+
+ ); +} + +function FilterInput({ label, value, onChange, list }: { label: string; value?: string; onChange: (value: string) => void; list?: string }) { + return ( + + ); +} + +function TimeLabel({ value }: { value: string }) { + return ( + + + {formatDateTime(value)} + + ); +} + +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', ' '); +} diff --git a/apps/web/app/admin/consistency/page.tsx b/apps/web/app/admin/consistency/page.tsx new file mode 100644 index 0000000..b64f056 --- /dev/null +++ b/apps/web/app/admin/consistency/page.tsx @@ -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 ( + + + + ); +} + +function ConsistencyPageContent() { + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+ +

一致性校验

+ {report && } +
+ +
+ +
+ {error && ( +
{error}
+ )} + + {report && totals && ( + <> +
+ + + + +
+ +
+
+ 数据计数 +
+
+ {Object.entries(report.counts).map(([key, value]) => ( +
+ {key} + {value} +
+ ))} +
+
+ + + + + + )} + + {!report && !error && ( +
+ {loading ? '校验中...' : '暂无校验结果'} +
+ )} +
+
+ ); +} + +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 ( +
+
{value}
+
{label}
+
+ ); +} + +function CheckGroup({ title, checks }: { title: string; checks: ConsistencyCheckResult[] }) { + return ( +
+
+

{title}

+ {checks.length} +
+
+ {checks.map((check) => ( +
+ +
+
{check.id}
+
{check.message}
+
+
{check.count}
+
+ ))} +
+
+ ); +} + +function StatusPill({ status }: { status: ConsistencyResult['status'] }) { + const ok = status === 'pass'; + return ( + + {ok ? : } + {ok ? 'PASS' : 'FAIL'} + + ); +} + +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 ( + + {severity} + + ); +} diff --git a/apps/web/components/layout/Sidebar.tsx b/apps/web/components/layout/Sidebar.tsx index 4f052d1..ce39c79 100644 --- a/apps/web/components/layout/Sidebar.tsx +++ b/apps/web/components/layout/Sidebar.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; 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 { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems'; @@ -45,6 +45,8 @@ const NAV_GROUPS = [ items: [ { label: '成员', path: '/admin/members', icon: Users, permission: 'member: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: '*' }, ], }, diff --git a/apps/web/lib/admin-v25-source.test.ts b/apps/web/lib/admin-v25-source.test.ts new file mode 100644 index 0000000..11cb494 --- /dev/null +++ b/apps/web/lib/admin-v25-source.test.ts @@ -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'/); +}); diff --git a/apps/web/lib/audit-api.test.ts b/apps/web/lib/audit-api.test.ts new file mode 100644 index 0000000..6eb0506 --- /dev/null +++ b/apps/web/lib/audit-api.test.ts @@ -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', + ); +}); diff --git a/apps/web/lib/audit-api.ts b/apps/web/lib/audit-api.ts new file mode 100644 index 0000000..256e2a7 --- /dev/null +++ b/apps/web/lib/audit-api.ts @@ -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(buildAuditQueryPath(query)); +} diff --git a/apps/web/lib/consistency-api.test.ts b/apps/web/lib/consistency-api.test.ts new file mode 100644 index 0000000..4a7d505 --- /dev/null +++ b/apps/web/lib/consistency-api.test.ts @@ -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, + }); +}); diff --git a/apps/web/lib/consistency-api.ts b/apps/web/lib/consistency-api.ts new file mode 100644 index 0000000..1533b9d --- /dev/null +++ b/apps/web/lib/consistency-api.ts @@ -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; + checks: { + partitionKeys: ConsistencyCheckResult[]; + orphanReferences: ConsistencyCheckResult[]; + auditCoverage: ConsistencyCheckResult[]; + }; + summary: { + errors: number; + warnings: number; + human: string; + }; +} + +export function getConsistencyReport() { + return api.get('/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, + }; +} diff --git a/apps/web/lib/permissions.ts b/apps/web/lib/permissions.ts index 9bf95af..ddebddf 100644 --- a/apps/web/lib/permissions.ts +++ b/apps/web/lib/permissions.ts @@ -54,6 +54,8 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [ }, { module: 'member', moduleLabel: '成员', category: 'main', actions: std4('member') }, { 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.research', moduleLabel: '调研 Tab', category: 'version_tab', actions: stdTab('version.research') }, { module: 'version.product_plan', moduleLabel: '产品方案 Tab', category: 'version_tab', actions: stdTab('version.product_plan') },