Add audit log UI and backend API seeded E2E

- Add Auth Context route role guards so doctors cannot directly enter template management, user management, or audit logs.

- Add Audit Logs page, sidebar entry, frontend audit API client, and API client test.

- Add backend audit log query endpoint with super/admin visibility rules and query filtering.

- Extend PostgreSQL integration tests to cover audit log query permissions.

- Move Playwright E2E away from localStorage seed data to real backend API login and seed helpers.

- Add E2E coverage for route guards and audit log visibility.

- Run Playwright backend on port 3100 and proxy Vite API requests there to avoid local port conflicts.

- Make server:dev use the compiled NestJS server path, avoiding tsx parameter-property injection issues.

- Update README, AGENTS, feature, testing, security, deployment, progress, API, backendization, and auth/user module docs.
This commit is contained in:
2026-05-02 02:04:56 +08:00
parent a16f522a4b
commit 750cf4129d
31 changed files with 719 additions and 261 deletions

View File

@@ -8,9 +8,11 @@ import ReportView from './pages/ReportView';
import TemplateManage from './pages/TemplateManage';
import UserManage from './pages/UserManage';
import SystemSettings from './pages/SystemSettings';
import AuditLogs from './pages/AuditLogs';
import { AuthProvider, useAuth } from './auth/AuthContext';
import type { User } from './types';
function RequireAuth({ children }: { children: ReactElement }) {
function RequireAuth({ children, roles }: { children: ReactElement; roles?: User['role'][] }) {
const { user, isLoading } = useAuth();
if (isLoading && !user) {
@@ -21,6 +23,10 @@ function RequireAuth({ children }: { children: ReactElement }) {
return <Navigate to="/" replace />;
}
if (roles && !roles.includes(user.role)) {
return <Navigate to="/dashboard" replace />;
}
return children;
}
@@ -34,8 +40,9 @@ export default function App() {
<Route path="/report-editor" element={<RequireAuth><ReportEditor /></RequireAuth>} />
<Route path="/report-manage" element={<RequireAuth><ReportManage /></RequireAuth>} />
<Route path="/report-view/:id" element={<RequireAuth><ReportView /></RequireAuth>} />
<Route path="/template-manage" element={<RequireAuth><TemplateManage /></RequireAuth>} />
<Route path="/user-manage" element={<RequireAuth><UserManage /></RequireAuth>} />
<Route path="/template-manage" element={<RequireAuth roles={['super', 'admin']}><TemplateManage /></RequireAuth>} />
<Route path="/user-manage" element={<RequireAuth roles={['super', 'admin']}><UserManage /></RequireAuth>} />
<Route path="/audit-logs" element={<RequireAuth roles={['super', 'admin']}><AuditLogs /></RequireAuth>} />
<Route path="/system-settings" element={<RequireAuth><SystemSettings /></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

27
src/api/audit.test.ts Normal file
View File

@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from 'vitest';
import { listAuditLogs } from './audit';
describe('audit api', () => {
it('loads audit logs with query params', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve({
data: {
items: [{ id: 'log-1', action: 'report.create', targetType: 'Report', createdAt: '2026-05-02T00:00:00.000Z' }],
total: 1,
page: 2,
pageSize: 20,
},
}),
text: () => Promise.resolve(''),
} as Response);
await expect(listAuditLogs({ page: 2, pageSize: 20, action: 'report.create' })).resolves.toMatchObject({
total: 1,
page: 2,
});
expect(fetch).toHaveBeenCalledWith('/api/audit-logs?page=2&pageSize=20&action=report.create', expect.objectContaining({ credentials: 'include' }));
});
});

47
src/api/audit.ts Normal file
View File

@@ -0,0 +1,47 @@
import { apiRequest } from './client';
export interface AuditLogItem {
id: string;
actorUserId?: string | null;
actorUsername?: string | null;
actorName?: string | null;
actorRole?: string | null;
action: string;
targetType: string;
targetId?: string | null;
departmentId?: string | null;
ip?: string | null;
userAgent?: string | null;
metadata?: unknown;
createdAt: string;
}
export interface AuditLogListResponse {
items: AuditLogItem[];
total: number;
page: number;
pageSize: number;
}
export interface AuditLogQuery {
page?: number;
pageSize?: number;
action?: string;
targetType?: string;
actor?: string;
}
export const listAuditLogs = async (query: AuditLogQuery = {}) => {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
params.set(key, String(value));
}
});
const suffix = params.toString() ? `?${params}` : '';
const response = await apiRequest<AuditLogListResponse>(`/api/audit-logs${suffix}`);
if (!response || !Array.isArray(response.items)) {
throw new Error('Invalid audit logs response');
}
return response;
};

View File

@@ -23,6 +23,7 @@ describe('Sidebar permissions', () => {
expect(screen.getByText('模板管理')).toBeInTheDocument();
expect(screen.getByText('用户管理')).toBeInTheDocument();
expect(screen.getByText('审计日志')).toBeInTheDocument();
expect(screen.getByText('系统设置')).toBeInTheDocument();
});
@@ -32,6 +33,7 @@ describe('Sidebar permissions', () => {
expect(screen.getByText('工作台')).toBeInTheDocument();
expect(screen.queryByText('模板管理')).not.toBeInTheDocument();
expect(screen.queryByText('用户管理')).not.toBeInTheDocument();
expect(screen.queryByText('审计日志')).not.toBeInTheDocument();
expect(screen.getByText('系统设置')).toBeInTheDocument();
});
});

View File

@@ -7,7 +7,8 @@ import {
Layout,
Users,
Settings,
LogOut
LogOut,
ShieldCheck,
} from 'lucide-react';
import { User } from '../types';
import { storage } from '../utils/storage';
@@ -35,6 +36,7 @@ export default function Sidebar() {
{ path: '/report-manage', icon: <FileText size={18} />, title: '报告管理', roles: ['super', 'admin', 'user'] },
{ path: '/template-manage', icon: <Layout size={18} />, title: '模板管理', roles: ['super', 'admin'] },
{ path: '/user-manage', icon: <Users size={18} />, title: '用户管理', roles: ['super', 'admin'] },
{ path: '/audit-logs', icon: <ShieldCheck size={18} />, title: '审计日志', roles: ['super', 'admin'] },
{ path: '/system-settings', icon: <Settings size={18} />, title: '系统设置', roles: ['super', 'admin', 'user'] },
];

223
src/pages/AuditLogs.tsx Normal file
View File

@@ -0,0 +1,223 @@
import React, { useEffect, useMemo, useState } from 'react';
import Sidebar from '../components/Sidebar';
import { listAuditLogs, type AuditLogItem } from '../api/audit';
import { Search, ShieldCheck } from 'lucide-react';
const actionLabels: Record<string, string> = {
'auth.login': '登录',
'auth.logout': '退出',
'report.create': '创建报告',
'report.complete': '完成报告',
'report.update': '更新报告',
'report.delete': '删除报告',
'template.create': '创建模板',
'template.update': '更新模板',
'template.delete': '删除模板',
'user.create': '创建用户',
'user.update': '更新用户',
'user.delete': '删除用户',
'department.create': '创建部门',
'department.update': '更新部门',
'department.delete': '删除部门',
'department.template_permissions.update': '更新部门模板授权',
'settings.system.update': '更新系统设置',
'settings.default_template.update': '更新默认模板',
'settings.system.reset': '重置系统设置',
'file.upload': '上传文件',
'file.delete': '删除文件',
'user.signature.upload': '上传签名',
'user.signature.delete': '删除签名',
};
const targetLabels: Record<string, string> = {
User: '用户',
Report: '报告',
Template: '模板',
Department: '部门',
SystemSetting: '系统设置',
FileResource: '文件',
};
const formatTime = (iso: string) => {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const describeMetadata = (metadata: unknown) => {
if (!metadata || typeof metadata !== 'object') return '-';
const entries = Object.entries(metadata as Record<string, unknown>)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.slice(0, 4);
if (!entries.length) return '-';
return entries.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : String(value)}`).join('');
};
export default function AuditLogs() {
const [items, setItems] = useState<AuditLogItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [action, setAction] = useState('');
const [targetType, setTargetType] = useState('');
const [actor, setActor] = useState('');
const [pendingActor, setPendingActor] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const pageSize = 20;
useEffect(() => {
setIsLoading(true);
setError('');
void listAuditLogs({ page, pageSize, action, targetType, actor })
.then((response) => {
setItems(response.items);
setTotal(response.total);
})
.catch((err) => {
setError(err instanceof Error ? err.message : '审计日志加载失败');
setItems([]);
setTotal(0);
})
.finally(() => setIsLoading(false));
}, [action, actor, page, targetType]);
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / pageSize)), [total]);
const submitActorSearch = (event: React.FormEvent) => {
event.preventDefault();
setPage(1);
setActor(pendingActor.trim());
};
const resetFilters = () => {
setAction('');
setTargetType('');
setActor('');
setPendingActor('');
setPage(1);
};
return (
<div className="flex min-h-screen bg-bg">
<Sidebar />
<main className="flex-1 p-10 overflow-y-auto">
<header className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-text-main"></h1>
<p className="text-text-muted text-sm mt-1"></p>
</div>
<div className="inline-flex items-center gap-2 rounded-lg bg-white border border-border px-4 py-2 text-sm font-semibold text-text-main shadow-sm">
<ShieldCheck size={17} className="text-accent" />
{total}
</div>
</header>
<section className="mb-5 flex flex-wrap items-center gap-3">
<select
value={action}
onChange={(event) => { setAction(event.target.value); setPage(1); }}
className="input-field max-w-[220px]"
>
<option value=""></option>
{Object.entries(actionLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<select
value={targetType}
onChange={(event) => { setTargetType(event.target.value); setPage(1); }}
className="input-field max-w-[180px]"
>
<option value=""></option>
{Object.entries(targetLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<form onSubmit={submitActorSearch} className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
value={pendingActor}
onChange={(event) => setPendingActor(event.target.value)}
placeholder="搜索操作者"
className="input-field pl-9 w-56"
/>
</form>
<button onClick={resetFilters} className="btn-secondary"></button>
</section>
<section className="bg-white border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-text-muted">
<tr>
<th className="text-left font-semibold px-4 py-3"></th>
<th className="text-left font-semibold px-4 py-3"></th>
<th className="text-left font-semibold px-4 py-3"></th>
<th className="text-left font-semibold px-4 py-3"></th>
<th className="text-left font-semibold px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item) => (
<tr key={item.id} className="hover:bg-slate-50">
<td className="px-4 py-3 whitespace-nowrap text-text-muted">{formatTime(item.createdAt)}</td>
<td className="px-4 py-3">
<div className="font-semibold text-text-main">{item.actorName || item.actorUsername || '系统'}</div>
<div className="text-xs text-text-muted">{item.actorUsername || item.actorRole || '-'}</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex rounded-md bg-blue-50 text-blue-700 px-2 py-1 text-xs font-semibold">
{actionLabels[item.action] || item.action}
</span>
</td>
<td className="px-4 py-3">
<div className="text-text-main">{targetLabels[item.targetType] || item.targetType}</div>
<div className="text-xs text-text-muted">{item.targetId || '-'}</div>
</td>
<td className="px-4 py-3 text-text-muted max-w-[360px] truncate" title={describeMetadata(item.metadata)}>
{describeMetadata(item.metadata)}
</td>
</tr>
))}
{!isLoading && items.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-12 text-center text-text-muted">
{error || '暂无审计日志'}
</td>
</tr>
)}
{isLoading && (
<tr>
<td colSpan={5} className="px-4 py-12 text-center text-text-muted">...</td>
</tr>
)}
</tbody>
</table>
</section>
<footer className="mt-5 flex items-center justify-between text-sm text-text-muted">
<span> {page} / {totalPages} </span>
<div className="flex gap-2">
<button
onClick={() => setPage((value) => Math.max(1, value - 1))}
disabled={page <= 1}
className="btn-secondary disabled:opacity-50"
>
</button>
<button
onClick={() => setPage((value) => Math.min(totalPages, value + 1))}
disabled={page >= totalPages}
className="btn-secondary disabled:opacity-50"
>
</button>
</div>
</footer>
</main>
</div>
);
}