feat(logs): 日志管理增加筛选维度(操作人/角色/请求方法)
- 后端 querySystemLogs 支持 role/method 过滤;新增 distinctRoles/distinctActors - 关键词搜索补充 target_name 匹配 - /api/system-logs 返回 roles 与 actors 供前端下拉 - 前端独立筛选工具条:操作人/角色/动作/方法/结果/日期范围 + 清除筛选 - 结束日期改为当日 23:59:59 含当天
This commit is contained in:
@@ -43,6 +43,8 @@ export interface SystemLogQuery {
|
|||||||
offset: number;
|
offset: number;
|
||||||
actorId?: string;
|
actorId?: string;
|
||||||
action?: string;
|
action?: string;
|
||||||
|
role?: string;
|
||||||
|
method?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -58,13 +60,15 @@ export async function querySystemLogs(
|
|||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
if (opts.actorId !== undefined && opts.actorId !== '') { params.push(opts.actorId); where.push(`actor_id = $${params.length}`); }
|
if (opts.actorId !== undefined && opts.actorId !== '') { params.push(opts.actorId); where.push(`actor_id = $${params.length}`); }
|
||||||
if (opts.action !== undefined && opts.action !== '') { params.push(opts.action); where.push(`action = $${params.length}`); }
|
if (opts.action !== undefined && opts.action !== '') { params.push(opts.action); where.push(`action = $${params.length}`); }
|
||||||
|
if (opts.role !== undefined && opts.role !== '') { params.push(opts.role); where.push(`role = $${params.length}`); }
|
||||||
|
if (opts.method !== undefined && opts.method !== '') { params.push(opts.method); where.push(`method = $${params.length}`); }
|
||||||
if (opts.success !== undefined) { params.push(opts.success); where.push(`success = $${params.length}`); }
|
if (opts.success !== undefined) { params.push(opts.success); where.push(`success = $${params.length}`); }
|
||||||
if (opts.from !== undefined && opts.from !== '') { params.push(opts.from); where.push(`ts >= $${params.length}`); }
|
if (opts.from !== undefined && opts.from !== '') { params.push(opts.from); where.push(`ts >= $${params.length}`); }
|
||||||
if (opts.to !== undefined && opts.to !== '') { params.push(opts.to); where.push(`ts <= $${params.length}`); }
|
if (opts.to !== undefined && opts.to !== '') { params.push(opts.to); where.push(`ts <= $${params.length}`); }
|
||||||
if (opts.q !== undefined && opts.q.trim() !== '') {
|
if (opts.q !== undefined && opts.q.trim() !== '') {
|
||||||
params.push(`%${opts.q.trim()}%`);
|
params.push(`%${opts.q.trim()}%`);
|
||||||
const i = params.length;
|
const i = params.length;
|
||||||
where.push(`(path ILIKE $${i} OR actor_name ILIKE $${i} OR action ILIKE $${i} OR target_id ILIKE $${i})`);
|
where.push(`(path ILIKE $${i} OR actor_name ILIKE $${i} OR action ILIKE $${i} OR target_id ILIKE $${i} OR target_name ILIKE $${i})`);
|
||||||
}
|
}
|
||||||
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
|
||||||
@@ -105,3 +109,20 @@ export async function distinctActions(pool: pg.Pool): Promise<string[]> {
|
|||||||
const res = await pool.query('SELECT DISTINCT action FROM system_logs ORDER BY action');
|
const res = await pool.query('SELECT DISTINCT action FROM system_logs ORDER BY action');
|
||||||
return (res.rows as Array<{ action: string }>).map((r) => String(r.action));
|
return (res.rows as Array<{ action: string }>).map((r) => String(r.action));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 已出现过的角色(供筛选下拉)。 */
|
||||||
|
export async function distinctRoles(pool: pg.Pool): Promise<string[]> {
|
||||||
|
const res = await pool.query("SELECT DISTINCT role FROM system_logs WHERE role IS NOT NULL AND role <> '' ORDER BY role");
|
||||||
|
return (res.rows as Array<{ role: string }>).map((r) => String(r.role));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已出现过的操作人(id+姓名,供筛选下拉)。 */
|
||||||
|
export async function distinctActors(pool: pg.Pool): Promise<Array<{ id: string; name: string }>> {
|
||||||
|
const res = await pool.query(
|
||||||
|
"SELECT actor_id, actor_name FROM system_logs WHERE actor_id IS NOT NULL AND actor_name IS NOT NULL GROUP BY actor_id, actor_name ORDER BY actor_name",
|
||||||
|
);
|
||||||
|
return (res.rows as Array<{ actor_id: string; actor_name: string }>).map((r) => ({
|
||||||
|
id: String(r.actor_id),
|
||||||
|
name: String(r.actor_name),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
+13
-3
@@ -77,6 +77,8 @@ import {
|
|||||||
insertSystemLog,
|
insertSystemLog,
|
||||||
querySystemLogs,
|
querySystemLogs,
|
||||||
distinctActions,
|
distinctActions,
|
||||||
|
distinctRoles,
|
||||||
|
distinctActors,
|
||||||
getApprovalConfig,
|
getApprovalConfig,
|
||||||
saveApprovalConfig,
|
saveApprovalConfig,
|
||||||
ensureApprovalConfig,
|
ensureApprovalConfig,
|
||||||
@@ -1425,12 +1427,14 @@ app.delete('/api/users/:id', requireRole('系统管理员'), async (c) => {
|
|||||||
|
|
||||||
/** 分页查询系统操作日志(系统管理员)。 */
|
/** 分页查询系统操作日志(系统管理员)。 */
|
||||||
app.get('/api/system-logs', requireRole('系统管理员'), async (c) => {
|
app.get('/api/system-logs', requireRole('系统管理员'), async (c) => {
|
||||||
if (pool === null) return c.json({ items: [], total: 0, page: 1, pageSize: 20, actions: [] });
|
if (pool === null) return c.json({ items: [], total: 0, page: 1, pageSize: 20, actions: [], roles: [], actors: [] });
|
||||||
const page = Math.max(1, Number(c.req.query('page') ?? 1) || 1);
|
const page = Math.max(1, Number(c.req.query('page') ?? 1) || 1);
|
||||||
const pageSize = Math.max(1, Math.min(Number(c.req.query('pageSize') ?? 20) || 20, 200));
|
const pageSize = Math.max(1, Math.min(Number(c.req.query('pageSize') ?? 20) || 20, 200));
|
||||||
const successQ = c.req.query('success');
|
const successQ = c.req.query('success');
|
||||||
const fActor = c.req.query('actorId');
|
const fActor = c.req.query('actorId');
|
||||||
const fAction = c.req.query('action');
|
const fAction = c.req.query('action');
|
||||||
|
const fRole = c.req.query('role');
|
||||||
|
const fMethod = c.req.query('method');
|
||||||
const fQ = c.req.query('q');
|
const fQ = c.req.query('q');
|
||||||
const fFrom = c.req.query('from');
|
const fFrom = c.req.query('from');
|
||||||
const fTo = c.req.query('to');
|
const fTo = c.req.query('to');
|
||||||
@@ -1439,13 +1443,19 @@ app.get('/api/system-logs', requireRole('系统管理员'), async (c) => {
|
|||||||
offset: (page - 1) * pageSize,
|
offset: (page - 1) * pageSize,
|
||||||
...(fActor ? { actorId: fActor } : {}),
|
...(fActor ? { actorId: fActor } : {}),
|
||||||
...(fAction ? { action: fAction } : {}),
|
...(fAction ? { action: fAction } : {}),
|
||||||
|
...(fRole ? { role: fRole } : {}),
|
||||||
|
...(fMethod ? { method: fMethod } : {}),
|
||||||
...(fQ ? { q: fQ } : {}),
|
...(fQ ? { q: fQ } : {}),
|
||||||
...(fFrom ? { from: fFrom } : {}),
|
...(fFrom ? { from: fFrom } : {}),
|
||||||
...(fTo ? { to: fTo } : {}),
|
...(fTo ? { to: fTo } : {}),
|
||||||
...(successQ === 'true' || successQ === 'false' ? { success: successQ === 'true' } : {}),
|
...(successQ === 'true' || successQ === 'false' ? { success: successQ === 'true' } : {}),
|
||||||
});
|
});
|
||||||
const actions = await distinctActions(pool).catch(() => []);
|
const [actions, roles, actors] = await Promise.all([
|
||||||
return c.json({ items, total, page, pageSize, actions });
|
distinctActions(pool).catch(() => []),
|
||||||
|
distinctRoles(pool).catch(() => []),
|
||||||
|
distinctActors(pool).catch(() => []),
|
||||||
|
]);
|
||||||
|
return c.json({ items, total, page, pageSize, actions, roles, actors });
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ *
|
/* ------------------------------------------------------------------ *
|
||||||
|
|||||||
@@ -1008,16 +1008,21 @@ export interface SystemLogPage {
|
|||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
actions: string[];
|
actions: string[];
|
||||||
|
roles: string[];
|
||||||
|
actors: Array<{ id: string; name: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询系统操作审计日志(系统管理员)。 */
|
/** 查询系统操作审计日志(系统管理员)。 */
|
||||||
export async function fetchSystemLogs(params: {
|
export async function fetchSystemLogs(params: {
|
||||||
page: number; pageSize: number; actorId?: string; action?: string; q?: string; from?: string; to?: string; success?: 'true' | 'false';
|
page: number; pageSize: number; actorId?: string; action?: string; role?: string; method?: string; q?: string; from?: string; to?: string; success?: 'true' | 'false';
|
||||||
}): Promise<SystemLogPage> {
|
}): Promise<SystemLogPage> {
|
||||||
const sp = new URLSearchParams();
|
const sp = new URLSearchParams();
|
||||||
sp.set('page', String(params.page));
|
sp.set('page', String(params.page));
|
||||||
sp.set('pageSize', String(params.pageSize));
|
sp.set('pageSize', String(params.pageSize));
|
||||||
if (params.action) sp.set('action', params.action);
|
if (params.action) sp.set('action', params.action);
|
||||||
|
if (params.actorId) sp.set('actorId', params.actorId);
|
||||||
|
if (params.role) sp.set('role', params.role);
|
||||||
|
if (params.method) sp.set('method', params.method);
|
||||||
if (params.q) sp.set('q', params.q);
|
if (params.q) sp.set('q', params.q);
|
||||||
if (params.from) sp.set('from', params.from);
|
if (params.from) sp.set('from', params.from);
|
||||||
if (params.to) sp.set('to', params.to);
|
if (params.to) sp.set('to', params.to);
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ export function SystemLogs(): JSX.Element {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
const [actions, setActions] = useState<string[]>([]);
|
const [actions, setActions] = useState<string[]>([]);
|
||||||
|
const [roles, setRoles] = useState<string[]>([]);
|
||||||
|
const [actors, setActors] = useState<Array<{ id: string; name: string }>>([]);
|
||||||
const [action, setAction] = useState('');
|
const [action, setAction] = useState('');
|
||||||
|
const [role, setRole] = useState('');
|
||||||
|
const [method, setMethod] = useState('');
|
||||||
|
const [actorId, setActorId] = useState('');
|
||||||
const [success, setSuccess] = useState<'' | 'true' | 'false'>('');
|
const [success, setSuccess] = useState<'' | 'true' | 'false'>('');
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [qInput, setQInput] = useState('');
|
const [qInput, setQInput] = useState('');
|
||||||
@@ -43,17 +48,26 @@ export function SystemLogs(): JSX.Element {
|
|||||||
fetchSystemLogs({
|
fetchSystemLogs({
|
||||||
page, pageSize,
|
page, pageSize,
|
||||||
...(action ? { action } : {}),
|
...(action ? { action } : {}),
|
||||||
|
...(role ? { role } : {}),
|
||||||
|
...(method ? { method } : {}),
|
||||||
|
...(actorId ? { actorId } : {}),
|
||||||
...(q ? { q } : {}),
|
...(q ? { q } : {}),
|
||||||
...(from ? { from: new Date(from).toISOString() } : {}),
|
...(from ? { from: new Date(from).toISOString() } : {}),
|
||||||
...(to ? { to: new Date(to).toISOString() } : {}),
|
...(to ? { to: new Date(`${to}T23:59:59`).toISOString() } : {}),
|
||||||
...(success ? { success } : {}),
|
...(success ? { success } : {}),
|
||||||
})
|
})
|
||||||
.then((res) => { setItems(res.items); setTotal(res.total); setActions(res.actions); setError(null); })
|
.then((res) => { setItems(res.items); setTotal(res.total); setActions(res.actions); setRoles(res.roles); setActors(res.actors); setError(null); })
|
||||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : '加载失败'))
|
.catch((e: unknown) => setError(e instanceof Error ? e.message : '加载失败'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [page, pageSize, action, q, from, to, success]);
|
}, [page, pageSize, action, role, method, actorId, q, from, to, success]);
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const hasFilter = action !== '' || role !== '' || method !== '' || actorId !== '' || success !== '' || qInput !== '' || from !== '' || to !== '';
|
||||||
|
const resetFilters = (): void => {
|
||||||
|
setAction(''); setRole(''); setMethod(''); setActorId(''); setSuccess('');
|
||||||
|
setQInput(''); setQ(''); setFrom(''); setTo(''); setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
const input: React.CSSProperties = {
|
const input: React.CSSProperties = {
|
||||||
padding: `${space(1)}px ${space(2)}px`, border: `1px solid ${colorVar('color.border.default')}`,
|
padding: `${space(1)}px ${space(2)}px`, border: `1px solid ${colorVar('color.border.default')}`,
|
||||||
@@ -72,25 +86,43 @@ export function SystemLogs(): JSX.Element {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card title={
|
<Card title={`操作日志(${total})`}>
|
||||||
<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', marginBottom: `${space(3)}px`, paddingBottom: `${space(3)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}` }}>
|
||||||
<div style={{ display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap', alignItems: 'center' }}>
|
<input style={{ ...input, width: 220 }} placeholder="搜索 操作人/动作/路径/目标" value={qInput} onChange={(e) => setQInput(e.target.value)} />
|
||||||
<input style={{ ...input, width: 200 }} placeholder="搜索 操作人/动作/路径/目标" value={qInput} onChange={(e) => setQInput(e.target.value)} />
|
<select style={input} value={actorId} onChange={(e) => { setActorId(e.target.value); setPage(1); }} title="按操作人筛选">
|
||||||
<select style={input} value={action} onChange={(e) => { setAction(e.target.value); setPage(1); }}>
|
<option value="">全部操作人</option>
|
||||||
<option value="">全部动作</option>
|
{actors.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
|
</select>
|
||||||
</select>
|
<select style={input} value={role} onChange={(e) => { setRole(e.target.value); setPage(1); }} title="按角色筛选">
|
||||||
<select style={input} value={success} onChange={(e) => { setSuccess(e.target.value as '' | 'true' | 'false'); setPage(1); }}>
|
<option value="">全部角色</option>
|
||||||
<option value="">全部结果</option>
|
{roles.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||||
<option value="true">成功</option>
|
</select>
|
||||||
<option value="false">失败</option>
|
<select style={input} value={action} onChange={(e) => { setAction(e.target.value); setPage(1); }} title="按动作筛选">
|
||||||
</select>
|
<option value="">全部动作</option>
|
||||||
<input style={input} type="date" value={from} onChange={(e) => { setFrom(e.target.value); setPage(1); }} title="起始日期" />
|
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
<input style={input} type="date" value={to} onChange={(e) => { setTo(e.target.value); setPage(1); }} title="结束日期" />
|
</select>
|
||||||
</div>
|
<select style={input} value={method} onChange={(e) => { setMethod(e.target.value); setPage(1); }} title="按请求方法筛选">
|
||||||
|
<option value="">全部方法</option>
|
||||||
|
{['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].map((m) => <option key={m} value={m}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
<select style={input} value={success} onChange={(e) => { setSuccess(e.target.value as '' | 'true' | 'false'); setPage(1); }} title="按结果筛选">
|
||||||
|
<option value="">全部结果</option>
|
||||||
|
<option value="true">成功</option>
|
||||||
|
<option value="false">失败</option>
|
||||||
|
</select>
|
||||||
|
<label style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
从 <input style={input} type="date" value={from} onChange={(e) => { setFrom(e.target.value); setPage(1); }} title="起始日期" />
|
||||||
|
</label>
|
||||||
|
<label style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
至 <input style={input} type="date" value={to} onChange={(e) => { setTo(e.target.value); setPage(1); }} title="结束日期" />
|
||||||
|
</label>
|
||||||
|
{hasFilter && (
|
||||||
|
<button type="button" onClick={resetFilters} style={{ ...pagerBtn, display: 'inline-flex', alignItems: 'center', gap: 4, color: colorVar('color.brand.primary'), borderColor: colorVar('color.brand.primary') }}>
|
||||||
|
<Icon name="close" size={12} /> 清除筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}>
|
|
||||||
{error !== null && <div style={{ color: colorVar('color.risk.critical'), marginBottom: `${space(2)}px` }}>{error}</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> : (
|
{loading ? <p style={{ color: colorVar('color.text.secondary') }}>加载中…</p> : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user