video-shuoshan/web/src/pages/RecordsPage.tsx
seaislee1209 911f3c158b
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
feat: v0.13.3 消费记录详情弹窗 + 参考素材预览下载 + CSV 全量导出
- 消费记录点击行弹出任务详情弹窗(任务ID、状态、错误原因+原始错误、基本信息、完整提示词、参考素材)
- ReferenceList 共用组件:图片点击大图、视频/音频点击播放、下载按钮
- VideoDetailModal 参考素材加播放和下载按钮
- 素材库引用图片修复:用 thumb_url 替代 asset:// 显示,轮询时也更新 references
- raw_error 字段:存储火山原始错误信息,仅管理员弹窗可见
- CSV 导出扩充至 21 列(超管)/ 17 列(团管):新增任务ID、完成时间、视频时长、比例、种子值、原始错误、参考素材数

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

216 lines
9.3 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { adminApi } from '../lib/api';
import type { AdminRecord, Team } from '../types';
import { showToast } from '../components/Toast';
import { DatePicker } from '../components/DatePicker';
import { Select } from '../components/Select';
import { RecordDetailModal } from '../components/RecordDetailModal';
import styles from './RecordsPage.module.css';
export function RecordsPage() {
const [records, setRecords] = useState<AdminRecord[]>([]);
const [detailRecord, setDetailRecord] = useState<AdminRecord | null>(null);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const pageSize = 20;
// Load teams for filter dropdown
useEffect(() => {
adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {});
}, []);
const fetchRecords = useCallback(async () => {
setLoading(true);
try {
const { data } = await adminApi.getRecords({
page, page_size: pageSize, search,
start_date: startDate || undefined,
end_date: endDate || undefined,
team_id: teamFilter ? Number(teamFilter) : undefined,
});
setRecords(data.results);
setTotal(data.total);
} catch {
showToast('加载消费记录失败');
} finally {
setLoading(false);
}
}, [page, search, startDate, endDate, teamFilter]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
const handleSearch = () => {
setPage(1);
fetchRecords();
};
const handleExportCSV = async () => {
try {
// Fetch all records matching current filters (up to 10000)
const { data } = await adminApi.getRecords({
page: 1, page_size: 10000, search,
start_date: startDate || undefined,
end_date: endDate || undefined,
team_id: teamFilter ? Number(teamFilter) : undefined,
});
const header = '任务ID,提交时间,完成时间,耗时,团队,用户名,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),成本(元),利润(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n';
const rows = data.results.map((r) => {
const esc = (s: string) => s.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧';
const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
const profit = ((r.cost_amount || 0) - (r.base_cost_amount || 0)).toFixed(2);
const elapsed = r.completed_at ? Math.round((new Date(r.completed_at).getTime() - new Date(r.created_at).getTime()) / 1000) + '秒' : '';
const completedAt = r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '';
const refCount = (r.reference_urls || []).length;
return `"${r.ark_task_id || ''}","${new Date(r.created_at).toLocaleString('zh-CN')}","${completedAt}","${elapsed}","${r.team_name || '-'}","${r.username}","${r.duration ?? ''}","${modeLabel}","${r.aspect_ratio || ''}","${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${(r.base_cost_amount || 0).toFixed(2)}","${profit}","${r.seed != null && r.seed !== -1 ? r.seed : ''}","${statusLabel}","${esc(r.prompt || '')}","${esc(r.error_message || '')}","${esc(r.raw_error || '')}","${refCount}"`;
}).join('\n');
const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `消费记录_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast('导出成功');
} catch {
showToast('导出失败');
}
};
const totalPages = Math.ceil(total / pageSize);
const statusMap: Record<string, string> = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' };
const formatElapsed = (r: AdminRecord) => {
if (!r.completed_at) return '-';
const ms = new Date(r.completed_at).getTime() - new Date(r.created_at).getTime();
if (ms < 0) return '-';
const totalSec = Math.round(ms / 1000);
if (totalSec < 60) return `${totalSec}`;
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}${sec > 0 ? sec + '秒' : ''}`;
};
return (<>
<div className={styles.page}>
<div className={styles.header}>
<h1 className={styles.title}></h1>
<button className={styles.exportBtn} onClick={handleExportCSV}> CSV</button>
</div>
<div className={styles.filters}>
<input
type="text"
className={styles.searchInput}
placeholder="按用户名搜索..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<Select
value={teamFilter}
onChange={(v) => { setTeamFilter(v); setPage(1); }}
placeholder="全部团队"
options={[{ label: '全部团队', value: '' }, ...teams.map((t) => ({ label: t.name, value: String(t.id) }))]}
/>
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
<span className={styles.dateSep}>~</span>
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
<button className={styles.searchBtn} onClick={handleSearch}></button>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>Tokens</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 12 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : records.length === 0 ? (
<tr><td colSpan={12} className={styles.empty}></td></tr>
) : (
records.map((r) => (
<tr key={r.id} onClick={() => setDetailRecord(r)} style={{ cursor: 'pointer' }}>
<td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td>
<td>{formatElapsed(r)}</td>
<td>{r.team_name || '-'}</td>
<td>{r.username}</td>
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>
<td>{(r.tokens_consumed || 0).toLocaleString()}</td>
<td>¥{(r.cost_amount || 0).toFixed(2)}</td>
<td>¥{(r.base_cost_amount || 0).toFixed(2)}</td>
<td>¥{((r.cost_amount || 0) - (r.base_cost_amount || 0)).toFixed(2)}</td>
<td className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td>
<td>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td>
<td className={r.status === 'failed' && r.error_message ? styles.statusCell : undefined}>
<span className={`${styles.statusBadge} ${styles[r.status]}`}>
{statusMap[r.status]}
</span>
{r.status === 'failed' && r.error_message && (
<span className={styles.errorTooltip}>{r.error_message}</span>
)}
</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>
{detailRecord && (
<RecordDetailModal record={detailRecord} onClose={() => setDetailRecord(null)} showTeam showCost />
)}
</>
);
}