video-shuoshan/web/src/pages/AuditLogsPage.tsx
seaislee1209 f3f8d08b56 feat: v0.14.1 视频参考双单价 + Token刷新防抖 + CSV导出上限
- 计费双单价:含视频输入28元/百万tokens,不含视频输入46元/百万tokens
- QuotaConfig 加 base_token_price_video 字段,系统设置页两个并排输入框
- 预估费用和实际结算按参考素材类型自动选择单价
- Token 刷新加锁:同页面内并发 401 共用一次 refresh 请求
- 关闭 BLACKLIST_AFTER_ROTATION:防止快速刷新导致误登出
- ProtectedRoute 容错:请求中断时自动重试,不误跳转
- CSV 导出上限从 100 提升到 10000

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:25:58 +08:00

224 lines
8.2 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' },
{ label: '重置用户密码', value: 'user_password_reset' },
];
const FIELD_LABELS: Record<string, string> = {
default_daily_seconds_limit: '每日限额(秒)',
default_monthly_seconds_limit: '每月限额(秒)',
default_daily_generation_limit: '每日生成次数',
default_monthly_generation_limit: '每月生成次数',
base_token_price: '不含视频输入单价',
base_token_price_video: '含视频输入单价',
announcement: '公告内容',
announcement_enabled: '公告开关',
name: '名称',
monthly_seconds_limit: '月额度(秒)',
monthly_spending_limit: '月消费限额',
total_seconds_pool: '秒数池',
balance: '余额',
markup_percentage: '加价率',
is_active: '状态',
daily_seconds_limit: '每日限额(秒)',
daily_generation_limit: '每日生成次数',
monthly_generation_limit: '每月生成次数',
username: '用户名',
email: '邮箱',
role: '角色',
};
function formatVal(val: unknown): string {
if (val === true) return '开启';
if (val === false) return '关闭';
if (val === '' || val === null || val === undefined) return '(空)';
return String(val);
}
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 '-';
const items: JSX.Element[] = [];
for (const field of fields) {
const oldVal = before?.[field];
const newVal = after?.[field];
const label = FIELD_LABELS[field] || field;
if (oldVal === undefined && newVal !== undefined) {
items.push(
<div key={field} className={styles.changeItem}>
<span className={styles.changeField}>{label}:</span>
<span className={styles.changeNew}>{formatVal(newVal)}</span>
</div>
);
} else if (oldVal !== undefined && newVal !== undefined && formatVal(oldVal) !== formatVal(newVal)) {
items.push(
<div key={field} className={styles.changeItem}>
<span className={styles.changeField}>{label}:</span>
<span className={styles.changeOld}>{formatVal(oldVal)}</span>
<span className={styles.changeArrow}>&rarr;</span>
<span className={styles.changeNew}>{formatVal(newVal)}</span>
</div>
);
}
}
return items.length > 0
? <div className={styles.changeDetail}>{items}</div>
: <span style={{ color: '#8b8ea8' }}></span>;
}
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>
);
}