- 计费双单价:含视频输入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>
224 lines
8.2 KiB
TypeScript
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}>→</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)}><</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>
|
|
);
|
|
}
|