feat(admin): 增加审计与一致性页面

This commit is contained in:
2026-07-08 17:05:16 +08:00
parent ea0b631a83
commit 988d659fcc
9 changed files with 482 additions and 1 deletions

View 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'/);
});

View 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
View 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));
}

View 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,
});
});

View 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,
};
}

View File

@@ -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') },