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:
13
src/App.tsx
13
src/App.tsx
@@ -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
27
src/api/audit.test.ts
Normal 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
47
src/api/audit.ts
Normal 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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
223
src/pages/AuditLogs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user