video-shuoshan/web/src/pages/AuditLogsPage.tsx
seaislee1209 85f76d8543 feat: v0.8.2~v0.8.4 — 管理后台 UI 修复 + 团队详情重构 + 审计日志系统
v0.8.2: DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip
v0.8.3: 团队详情抽屉→弹窗重构 + 修改秒数池功能 + member_count 修复
v0.8.4: AdminAuditLog 模型 + 12 处管理操作埋点 + 日志查询页面(/admin/logs)

审计日志覆盖所有管理员 mutation 操作(充值、修改额度、创建/禁用用户等),
记录操作人、变更前后值、IP 地址,支持按操作类型/操作人/日期筛选。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 01:18:44 +08:00

200 lines
7.5 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { adminApi } from '../lib/api';
import type { AuditLog } from '../types';
import { showToast } from '../components/Toast';
import { DatePicker } from '../components/DatePicker';
import { Select } from '../components/Select';
import styles from './AuditLogsPage.module.css';
const ACTION_OPTIONS = [
{ label: '全部操作', value: '' },
{ label: '创建团队', value: 'team_create' },
{ label: '更新团队', value: 'team_update' },
{ label: '团队充值', value: 'team_topup' },
{ label: '设置额度池', value: 'team_set_pool' },
{ label: '创建团队管理员', value: 'team_create_admin' },
{ label: '创建用户', value: 'user_create' },
{ label: '更新用户额度', value: 'user_quota_update' },
{ label: '切换用户状态', value: 'user_status_toggle' },
{ label: '更新系统设置', value: 'settings_update' },
{ label: '创建团队成员', value: 'member_create' },
{ label: '更新成员额度', value: 'member_quota_update' },
{ label: '切换成员状态', value: 'member_status_toggle' },
];
function renderChanges(before: Record<string, unknown> | null, after: Record<string, unknown> | null) {
if (!before && !after) return '-';
const fields = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]);
if (fields.size === 0) return '-';
return (
<div className={styles.changeDetail}>
{[...fields].map((field) => {
const oldVal = before?.[field];
const newVal = after?.[field];
if (oldVal === undefined && newVal !== undefined) {
return (
<div key={field} className={styles.changeItem}>
<span className={styles.changeField}>{field}:</span>
<span className={styles.changeNew}>{String(newVal)}</span>
</div>
);
}
if (oldVal !== undefined && newVal !== undefined && String(oldVal) !== String(newVal)) {
return (
<div key={field} className={styles.changeItem}>
<span className={styles.changeField}>{field}:</span>
<span className={styles.changeOld}>{String(oldVal)}</span>
<span className={styles.changeArrow}>&rarr;</span>
<span className={styles.changeNew}>{String(newVal)}</span>
</div>
);
}
if (oldVal === undefined && newVal === undefined) return null;
// Same value, show as-is for create actions
if (oldVal === undefined) {
return (
<div key={field} className={styles.changeItem}>
<span className={styles.changeField}>{field}:</span>
<span className={styles.changeNew}>{String(newVal)}</span>
</div>
);
}
return null;
})}
</div>
);
}
export function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [actionFilter, setActionFilter] = useState('');
const [operatorSearch, setOperatorSearch] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [loading, setLoading] = useState(true);
const pageSize = 20;
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const { data } = await adminApi.getAuditLogs({
page, page_size: pageSize,
action: actionFilter || undefined,
operator: operatorSearch || undefined,
start_date: startDate || undefined,
end_date: endDate || undefined,
});
setLogs(data.results);
setTotal(data.total);
} catch {
showToast('加载审计日志失败');
} finally {
setLoading(false);
}
}, [page, actionFilter, operatorSearch, startDate, endDate]);
useEffect(() => { fetchLogs(); }, [fetchLogs]);
const handleSearch = () => {
setPage(1);
fetchLogs();
};
const totalPages = Math.ceil(total / pageSize);
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.filters}>
<Select
value={actionFilter}
onChange={(v) => { setActionFilter(v); setPage(1); }}
placeholder="全部操作"
options={ACTION_OPTIONS}
/>
<input
type="text"
className={styles.searchInput}
placeholder="按操作人搜索..."
value={operatorSearch}
onChange={(e) => setOperatorSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
<span className={styles.dateSep}>~</span>
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
<button className={styles.searchBtn} onClick={handleSearch}></button>
<button className={styles.refreshBtn} onClick={fetchLogs}></button>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>IP</th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 6 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : logs.length === 0 ? (
<tr><td colSpan={6} className={styles.empty}></td></tr>
) : (
logs.map((log) => (
<tr key={log.id}>
<td className={styles.timeCell}>{new Date(log.created_at).toLocaleString('zh-CN')}</td>
<td>{log.operator_name}</td>
<td><span className={styles.actionBadge}>{log.action_display}</span></td>
<td className={styles.targetCell}>
{log.target_name || '-'}
{log.target_type && <span style={{ color: '#8b8ea8', fontSize: 11, marginLeft: 4 }}>({log.target_type})</span>}
</td>
<td>{renderChanges(log.before, log.after)}</td>
<td className={styles.ipCell}>{log.ip_address || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<span className={styles.pageInfo}> {total} </span>
<div className={styles.pageButtons}>
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>&lt;</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let p: number;
if (totalPages <= 5) p = i + 1;
else if (page <= 3) p = i + 1;
else if (page >= totalPages - 2) p = totalPages - 4 + i;
else p = page - 2 + i;
return (
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
{p}
</button>
);
})}
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>&gt;</button>
</div>
</div>
)}
</div>
);
}