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>
200 lines
7.5 KiB
TypeScript
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}>→</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)}><</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)}>></button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|