系统管理员:新增日志管理(全系统操作审计)
- system_logs 表 + 持久化(查询/筛选/分页/动作枚举) - 全局审计中间件:记录全部写操作(POST/PUT/DELETE)+登录,含操作人(JWT)/角色/中文动作/方法/路径/目标/状态/成功/耗时/IP/查询参数 - 中文动作标签按路由推导,目标ID从路径提取 - GET /api/system-logs(仅系统管理员)支持按动作/关键词/时间/成功失败筛选 - 前端「日志管理」页:筛选+分页+明细展开;导航与路由接入
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* 系统操作审计日志:记录全系统的写操作(谁/何时/何角色/动作/目标/方法/路径/结果/IP/耗时)。
|
||||
* 供系统管理员审计。仅记录状态变更类请求(POST/PUT/DELETE)与登录。
|
||||
*/
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
CREATE TABLE IF NOT EXISTS system_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
actor_id TEXT,
|
||||
actor_name TEXT,
|
||||
role TEXT,
|
||||
action TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
status INTEGER,
|
||||
success BOOLEAN,
|
||||
duration_ms INTEGER,
|
||||
ip TEXT,
|
||||
query TEXT,
|
||||
detail JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_system_logs_ts ON system_logs(ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_system_logs_actor ON system_logs(actor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_system_logs_action ON system_logs(action);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`DROP TABLE IF EXISTS system_logs;`);
|
||||
};
|
||||
@@ -22,6 +22,7 @@ export * from './drafts.js';
|
||||
export * from './users.js';
|
||||
export * from './approvalConfig.js';
|
||||
export * from './assignments.js';
|
||||
export * from './systemLogs.js';
|
||||
export * from './settings.js';
|
||||
export * from './regionRates.js';
|
||||
export * from './rejectReasons.js';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 系统操作审计日志持久化。
|
||||
*/
|
||||
import type pg from 'pg';
|
||||
|
||||
export interface SystemLogEntry {
|
||||
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 SystemLogRow extends SystemLogEntry {
|
||||
id: number;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
/** 写入一条系统审计日志。 */
|
||||
export async function insertSystemLog(pool: pg.Pool, e: SystemLogEntry): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO system_logs(actor_id, actor_name, role, action, method, path, target_id, status, success, duration_ms, ip, query, detail)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
|
||||
[
|
||||
e.actorId, e.actorName, e.role, e.action, e.method, e.path, e.targetId,
|
||||
e.status, e.success, e.durationMs, e.ip, e.query,
|
||||
e.detail !== undefined ? JSON.stringify(e.detail) : null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export interface SystemLogQuery {
|
||||
limit: number;
|
||||
offset: number;
|
||||
actorId?: string;
|
||||
action?: string;
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
/** 分页查询系统审计日志(按时间倒序)。 */
|
||||
export async function querySystemLogs(
|
||||
pool: pg.Pool,
|
||||
opts: SystemLogQuery,
|
||||
): Promise<{ items: SystemLogRow[]; total: number }> {
|
||||
const where: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
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.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.to !== undefined && opts.to !== '') { params.push(opts.to); where.push(`ts <= $${params.length}`); }
|
||||
if (opts.q !== undefined && opts.q.trim() !== '') {
|
||||
params.push(`%${opts.q.trim()}%`);
|
||||
const i = params.length;
|
||||
where.push(`(path ILIKE $${i} OR actor_name ILIKE $${i} OR action ILIKE $${i} OR target_id ILIKE $${i})`);
|
||||
}
|
||||
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
||||
|
||||
const countRes = await pool.query(`SELECT count(*)::int AS n FROM system_logs ${whereSql}`, params);
|
||||
const total = Number((countRes.rows[0] as { n: number }).n);
|
||||
|
||||
const dataParams = [...params, opts.limit, opts.offset];
|
||||
const res = await pool.query(
|
||||
`SELECT id, ts, actor_id, actor_name, role, action, method, path, target_id, status, success, duration_ms, ip, query, detail
|
||||
FROM system_logs ${whereSql}
|
||||
ORDER BY id DESC
|
||||
LIMIT $${dataParams.length - 1} OFFSET $${dataParams.length}`,
|
||||
dataParams,
|
||||
);
|
||||
const items = (res.rows as Array<Record<string, unknown>>).map((r) => ({
|
||||
id: Number(r.id),
|
||||
ts: r.ts instanceof Date ? r.ts.toISOString() : String(r.ts),
|
||||
actorId: r.actor_id != null ? String(r.actor_id) : null,
|
||||
actorName: r.actor_name != null ? String(r.actor_name) : null,
|
||||
role: r.role != null ? String(r.role) : null,
|
||||
action: String(r.action),
|
||||
method: String(r.method),
|
||||
path: String(r.path),
|
||||
targetId: r.target_id != null ? String(r.target_id) : null,
|
||||
status: r.status != null ? Number(r.status) : null,
|
||||
success: r.success != null ? Boolean(r.success) : null,
|
||||
durationMs: r.duration_ms != null ? Number(r.duration_ms) : null,
|
||||
ip: r.ip != null ? String(r.ip) : null,
|
||||
query: r.query != null ? String(r.query) : null,
|
||||
detail: r.detail ?? null,
|
||||
}));
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/** 已出现过的动作类型(供筛选下拉)。 */
|
||||
export async function distinctActions(pool: pg.Pool): Promise<string[]> {
|
||||
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));
|
||||
}
|
||||
@@ -73,6 +73,9 @@ import {
|
||||
USER_ROLES,
|
||||
type UserRole,
|
||||
getUserById,
|
||||
insertSystemLog,
|
||||
querySystemLogs,
|
||||
distinctActions,
|
||||
getApprovalConfig,
|
||||
saveApprovalConfig,
|
||||
ensureApprovalConfig,
|
||||
@@ -139,6 +142,90 @@ app.use(cors({ origin: '*' }));
|
||||
// 认证中间件:AUTH_SECRET 配置后启用 JWT 校验;未配置时为演示模式(不校验)。
|
||||
app.use('/api/*', authMiddleware());
|
||||
|
||||
/** 由 方法+路径 推导中文操作名称(用于审计日志可读性)。 */
|
||||
function deriveActionLabel(method: string, path: string): string {
|
||||
const p = path.replace(/\/+$/, '');
|
||||
const rules: ReadonlyArray<[RegExp, string]> = [
|
||||
[/^\/api\/auth\/login$/, '登录'],
|
||||
[/^\/api\/assessments\/run$/, '运行评估(创建/重评)'],
|
||||
[/^\/api\/assessments\/[^/]+\/submit$/, '申报报送风控'],
|
||||
[/^\/api\/assessments\/[^/]+\/resubmit$/, '驳回后重新提交'],
|
||||
[/^\/api\/assessments\/[^/]+\/review$/, '风控审核'],
|
||||
[/^\/api\/assessments\/[^/]+\/approve$/, '管理层审批'],
|
||||
[/^\/api\/assessments\/[^/]+\/override$/, '管理层调整状态'],
|
||||
[/^\/api\/assessments\/[^/]+\/archive$/, '归档/取消归档'],
|
||||
[/^\/api\/assessments\/[^/]+\/redline-verdict$/, '红线人工裁定'],
|
||||
[/^\/api\/assessments\/[^/]+\/reject-reason$/, '登记驳回原因'],
|
||||
[/^\/api\/assessments\/[^/]+\/actuals$/, '回填运营实际值'],
|
||||
[/^\/api\/assessments\/[^/]+\/scenarios/, '报价方案变更'],
|
||||
[/^\/api\/assessments\/[^/]+\/synthesis$/, '生成综合研判'],
|
||||
[/^\/api\/assessments\/[^/]+\/report/, '报告生成/导出'],
|
||||
[/^\/api\/assessments\/[^/]+\/recommendation$/, '重算承接建议'],
|
||||
[/^\/api\/assessments\/[^/]+\/attachments/, '附件变更'],
|
||||
[/^\/api\/assessments\/[^/]+$/, method === 'PUT' ? '编辑评估资料' : '评估变更'],
|
||||
[/^\/api\/users\/[^/]+\/password$/, '重置用户密码'],
|
||||
[/^\/api\/users\/[^/]+$/, method === 'DELETE' ? '删除用户' : '修改用户'],
|
||||
[/^\/api\/users$/, '新增用户'],
|
||||
[/^\/api\/approval-config$/, '修改审批流程配置'],
|
||||
[/^\/api\/customers\/[^/]+\/payments/, '客户回款记录变更'],
|
||||
[/^\/api\/customers/, '客户档案变更'],
|
||||
[/^\/api\/redline-rules/, '红线规则变更'],
|
||||
[/^\/api\/rates/, '费率变更'],
|
||||
[/^\/api\/region-rates/, '地域费率变更'],
|
||||
[/^\/api\/min-wages/, '最低工资标准变更'],
|
||||
[/^\/api\/calibration\/apply$/, '应用预测校准'],
|
||||
[/^\/api\/drafts/, '草稿变更'],
|
||||
[/^\/api\/embeddings\/rebuild$/, '重建向量索引'],
|
||||
];
|
||||
for (const [re, label] of rules) {
|
||||
if (re.test(p)) return label;
|
||||
}
|
||||
return `${method} ${p}`;
|
||||
}
|
||||
|
||||
/** 从路径提取目标实体 ID(取 /api/<res>/<id> 的 id 段)。 */
|
||||
function deriveTargetId(path: string): string | null {
|
||||
const m = path.match(/^\/api\/[^/]+\/([^/?]+)/);
|
||||
return m && m[1] !== undefined ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
// 系统操作审计:记录全部写操作(POST/PUT/DELETE)+ 登录,供系统管理员审计。
|
||||
app.use('/api/*', async (c, next) => {
|
||||
const method = c.req.method;
|
||||
const start = Date.now();
|
||||
await next();
|
||||
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return;
|
||||
if (pool === null) return;
|
||||
const path = c.req.path;
|
||||
if (path === '/api/system-logs') return; // 不记录查询日志自身
|
||||
const payload = (c as import('hono').Context).get('user') as AuthPayload | undefined;
|
||||
const actorId = payload?.uid ?? null;
|
||||
let actorName = payload?.username ?? null;
|
||||
const role = payload?.role ?? null;
|
||||
// 登录成功:从响应体补充操作人(此时尚无 JWT)。
|
||||
if (path === '/api/auth/login' && actorName === null) {
|
||||
actorName = (await c.req.json<{ username?: string }>().catch(() => ({} as { username?: string }))).username ?? null;
|
||||
}
|
||||
const status = c.res.status;
|
||||
const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? c.req.header('x-real-ip') ?? null;
|
||||
const queryStr = (() => { const i = c.req.url.indexOf('?'); return i >= 0 ? c.req.url.slice(i + 1) : null; })();
|
||||
const entry = {
|
||||
actorId,
|
||||
actorName,
|
||||
role,
|
||||
action: deriveActionLabel(method, path),
|
||||
method,
|
||||
path,
|
||||
targetId: deriveTargetId(path),
|
||||
status,
|
||||
success: status < 400,
|
||||
durationMs: Date.now() - start,
|
||||
ip,
|
||||
query: queryStr,
|
||||
};
|
||||
void insertSystemLog(pool, entry).catch(() => undefined);
|
||||
});
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
||||
|
||||
/**
|
||||
@@ -1300,6 +1387,35 @@ app.delete('/api/users/:id', requireRole('系统管理员'), async (c) => {
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 系统操作审计日志(系统管理员)。
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 分页查询系统操作日志(系统管理员)。 */
|
||||
app.get('/api/system-logs', requireRole('系统管理员'), async (c) => {
|
||||
if (pool === null) return c.json({ items: [], total: 0, page: 1, pageSize: 20, actions: [] });
|
||||
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 successQ = c.req.query('success');
|
||||
const fActor = c.req.query('actorId');
|
||||
const fAction = c.req.query('action');
|
||||
const fQ = c.req.query('q');
|
||||
const fFrom = c.req.query('from');
|
||||
const fTo = c.req.query('to');
|
||||
const { items, total } = await querySystemLogs(pool, {
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
...(fActor ? { actorId: fActor } : {}),
|
||||
...(fAction ? { action: fAction } : {}),
|
||||
...(fQ ? { q: fQ } : {}),
|
||||
...(fFrom ? { from: fFrom } : {}),
|
||||
...(fTo ? { to: fTo } : {}),
|
||||
...(successQ === 'true' || successQ === 'false' ? { success: successQ === 'true' } : {}),
|
||||
});
|
||||
const actions = await distinctActions(pool).catch(() => []);
|
||||
return c.json({ items, total, page, pageSize, actions });
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 审批流程配置(系统管理员):规则 + SLA。
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)',
|
||||
};
|
||||
Reference in New Issue
Block a user