系统管理员:新增日志管理(全系统操作审计)

- system_logs 表 + 持久化(查询/筛选/分页/动作枚举)
- 全局审计中间件:记录全部写操作(POST/PUT/DELETE)+登录,含操作人(JWT)/角色/中文动作/方法/路径/目标/状态/成功/耗时/IP/查询参数
- 中文动作标签按路由推导,目标ID从路径提取
- GET /api/system-logs(仅系统管理员)支持按动作/关键词/时间/成功失败筛选
- 前端「日志管理」页:筛选+分页+明细展开;导航与路由接入
This commit is contained in:
freedakgmail
2026-06-14 08:49:26 +08:00
parent aff293d40e
commit 1a37daea68
8 changed files with 473 additions and 0 deletions
+2
View File
@@ -13,6 +13,7 @@ import { RedlineManagement } from './pages/RedlineManagement.js';
import { CustomerManagement } from './pages/CustomerManagement.js';
import { UserManagement } from './pages/UserManagement.js';
import { WorkflowManagement } from './pages/WorkflowManagement.js';
import { SystemLogs } from './pages/SystemLogs.js';
/** 路由守卫:未登录重定向到登录页。 */
function ProtectedRoute(): JSX.Element {
@@ -62,6 +63,7 @@ export function App(): JSX.Element {
<Route element={<RoleRoute allow={['系统管理员']} />}>
<Route path="/users" element={<UserManagement />} />
<Route path="/workflow" element={<WorkflowManagement />} />
<Route path="/system-logs" element={<SystemLogs />} />
</Route>
</Route>
</Route>
+42
View File
@@ -982,6 +982,48 @@ export async function fetchDashboardStats(): Promise<DashboardStats> {
return request<DashboardStats>('GET', '/api/dashboard/stats');
}
/** 系统操作审计日志项。 */
export interface SystemLogItem {
id: number;
ts: string;
actorId: string | null;
actorName: string | null;
role: string | null;
action: string;
method: string;
path: string;
targetId: string | null;
status: number | null;
success: boolean | null;
durationMs: number | null;
ip: string | null;
query: string | null;
detail: unknown;
}
export interface SystemLogPage {
items: SystemLogItem[];
total: number;
page: number;
pageSize: number;
actions: string[];
}
/** 查询系统操作审计日志(系统管理员)。 */
export async function fetchSystemLogs(params: {
page: number; pageSize: number; actorId?: string; action?: string; q?: string; from?: string; to?: string; success?: 'true' | 'false';
}): Promise<SystemLogPage> {
const sp = new URLSearchParams();
sp.set('page', String(params.page));
sp.set('pageSize', String(params.pageSize));
if (params.action) sp.set('action', params.action);
if (params.q) sp.set('q', params.q);
if (params.from) sp.set('from', params.from);
if (params.to) sp.set('to', params.to);
if (params.success) sp.set('success', params.success);
return request<SystemLogPage>('GET', `/api/system-logs?${sp.toString()}`);
}
/** 经验库。 */
export interface ExperienceItem {
id: number;
+3
View File
@@ -190,6 +190,9 @@ export function AppShell(): JSX.Element {
<span data-nav-link style={navLinkStyle('/workflow')} onClick={() => navigate('/workflow')}>
</span>
<span data-nav-link style={navLinkStyle('/system-logs')} onClick={() => navigate('/system-logs')}>
</span>
</>
)}
+170
View File
@@ -0,0 +1,170 @@
/**
* 日志管理 — 系统管理员查看全系统操作审计日志(谁/何时/何角色/动作/目标/方法/路径/结果/IP/耗时)。
* 支持按动作、关键词、时间范围、成功/失败筛选与分页,并可展开查看明细。
*/
import { useCallback, useEffect, useState, Fragment } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, space, typographyStyle } from '../design-system/components/styles.js';
import { Card, Icon } from '../design-system/index.js';
import { fetchSystemLogs, type SystemLogItem } from '../api/client.js';
const ROLE_FG: Record<string, string> = {
'商务/销售': '#15803D', '风控': '#B45309', '管理层': '#4F46E5', '系统管理员': '#0891B2',
};
function fmt(ts: string): string {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return ts;
return d.toLocaleString('zh-CN', { year: '2-digit', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
export function SystemLogs(): JSX.Element {
const [items, setItems] = useState<SystemLogItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [actions, setActions] = useState<string[]>([]);
const [action, setAction] = useState('');
const [success, setSuccess] = useState<'' | 'true' | 'false'>('');
const [q, setQ] = useState('');
const [qInput, setQInput] = useState('');
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<number | null>(null);
useEffect(() => {
const t = setTimeout(() => { setQ(qInput); setPage(1); }, 350);
return () => clearTimeout(t);
}, [qInput]);
const load = useCallback(() => {
setLoading(true);
fetchSystemLogs({
page, pageSize,
...(action ? { action } : {}),
...(q ? { q } : {}),
...(from ? { from: new Date(from).toISOString() } : {}),
...(to ? { to: new Date(to).toISOString() } : {}),
...(success ? { success } : {}),
})
.then((res) => { setItems(res.items); setTotal(res.total); setActions(res.actions); setError(null); })
.catch((e: unknown) => setError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, [page, pageSize, action, q, from, to, success]);
useEffect(() => { load(); }, [load]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const input: React.CSSProperties = {
padding: `${space(1)}px ${space(2)}px`, border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`, fontFamily: FONT_FAMILY, ...typographyStyle('caption'),
backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'),
};
const th: React.CSSProperties = { textAlign: 'left', padding: `${space(2)}px ${space(2)}px`, borderBottom: `2px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), fontWeight: 700, whiteSpace: 'nowrap' };
const td: React.CSSProperties = { padding: `${space(2)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, ...typographyStyle('caption'), verticalAlign: 'top' };
return (
<div style={{ fontFamily: FONT_FAMILY, maxWidth: 1240, margin: '0 auto' }}>
<div style={{ marginBottom: `${space(4)}px` }}>
<h1 style={{ ...typographyStyle('heading'), color: colorVar('color.text.primary'), margin: 0 }}></h1>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `${space(1)}px 0 0` }}>
IP
</p>
</div>
<Card title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: `${space(2)}px` }}>
<span>{total}</span>
<div style={{ display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap', alignItems: 'center' }}>
<input style={{ ...input, width: 200 }} placeholder="搜索 操作人/动作/路径/目标" value={qInput} onChange={(e) => setQInput(e.target.value)} />
<select style={input} value={action} onChange={(e) => { setAction(e.target.value); setPage(1); }}>
<option value=""></option>
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<select style={input} value={success} onChange={(e) => { setSuccess(e.target.value as '' | 'true' | 'false'); setPage(1); }}>
<option value=""></option>
<option value="true"></option>
<option value="false"></option>
</select>
<input style={input} type="date" value={from} onChange={(e) => { setFrom(e.target.value); setPage(1); }} title="起始日期" />
<input style={input} type="date" value={to} onChange={(e) => { setTo(e.target.value); setPage(1); }} title="结束日期" />
</div>
</div>
}>
{error !== null && <div style={{ color: colorVar('color.risk.critical'), marginBottom: `${space(2)}px` }}>{error}</div>}
{loading ? <p style={{ color: colorVar('color.text.secondary') }}></p> : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
<th style={th}></th>
<th style={th}></th>
<th style={th}></th>
<th style={th}></th>
<th style={th}></th>
<th style={th}>/</th>
<th style={th}></th>
<th style={th}>IP</th>
<th style={th}></th>
</tr></thead>
<tbody>
{items.map((it) => (
<Fragment key={it.id}>
<tr style={{ cursor: 'pointer' }} onClick={() => setExpanded(expanded === it.id ? null : it.id)}>
<td style={{ ...td, whiteSpace: 'nowrap' }}>{fmt(it.ts)}</td>
<td style={{ ...td, whiteSpace: 'nowrap', fontWeight: 600 }}>{it.actorName ?? '匿名'}</td>
<td style={{ ...td, whiteSpace: 'nowrap', color: it.role ? (ROLE_FG[it.role] ?? colorVar('color.text.primary')) : colorVar('color.text.secondary'), fontWeight: 600 }}>{it.role ?? '—'}</td>
<td style={{ ...td, whiteSpace: 'nowrap', color: colorVar('color.text.primary') }}>{it.action}</td>
<td style={td}><span style={{ fontFamily: 'monospace', fontWeight: 700, color: it.method === 'DELETE' ? '#BE123C' : it.method === 'PUT' ? '#B45309' : '#2563EB' }}>{it.method}</span></td>
<td style={{ ...td, maxWidth: 320, wordBreak: 'break-all', color: colorVar('color.text.secondary') }}>
{it.targetId && <span style={{ color: colorVar('color.text.primary'), fontWeight: 600 }}>{it.targetId}</span>}
<div style={{ fontFamily: 'monospace', fontSize: '11px' }}>{it.path}</div>
</td>
<td style={{ ...td, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: it.success ? '#15803D' : '#BE123C', fontWeight: 600 }}>
<Icon name={it.success ? 'check-circle' : 'alert'} size={13} />{it.status ?? '-'}
</span>
</td>
<td style={{ ...td, whiteSpace: 'nowrap', color: colorVar('color.text.secondary'), fontFamily: 'monospace', fontSize: '11px' }}>{it.ip ?? '—'}</td>
<td style={{ ...td, whiteSpace: 'nowrap', color: colorVar('color.text.secondary') }}>{it.durationMs ?? '-'}ms</td>
</tr>
{expanded === it.id && (
<tr>
<td colSpan={9} style={{ ...td, backgroundColor: colorVar('color.bg.surface') }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, ...typographyStyle('caption') }}>
<span> ID{it.id} ID{it.actorId ?? '—'}</span>
{it.query && <span><span style={{ fontFamily: 'monospace' }}>{it.query}</span></span>}
{it.detail != null && <span><span style={{ fontFamily: 'monospace' }}>{JSON.stringify(it.detail)}</span></span>}
</div>
</td>
</tr>
)}
</Fragment>
))}
{items.length === 0 && <tr><td colSpan={9} style={{ ...td, color: colorVar('color.text.secondary'), textAlign: 'center' }}></td></tr>}
</tbody>
</table>
</div>
)}
{/* 分页 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: `${space(3)}px`, flexWrap: 'wrap', gap: `${space(2)}px` }}>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}> {total} · {page}/{totalPages} </span>
<div style={{ display: 'flex', gap: `${space(2)}px`, alignItems: 'center' }}>
<select style={input} value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}>
{[20, 50, 100].map((s) => <option key={s} value={s}> {s}</option>)}
</select>
<button style={pagerBtn} disabled={page <= 1} onClick={() => setPage(1)}></button>
<button style={pagerBtn} disabled={page <= 1} onClick={() => setPage(page - 1)}></button>
<button style={pagerBtn} disabled={page >= totalPages} onClick={() => setPage(page + 1)}></button>
<button style={pagerBtn} disabled={page >= totalPages} onClick={() => setPage(totalPages)}></button>
</div>
</div>
</Card>
</div>
);
}
const pagerBtn: React.CSSProperties = {
padding: '4px 10px', borderRadius: '6px', border: '1px solid var(--color-border-default)',
background: 'transparent', cursor: 'pointer', fontFamily: FONT_FAMILY, fontSize: '12px', color: 'var(--color-text-primary)',
};