video-shuoshan/web/src/pages/TeamRecordsPage.tsx
seaislee1209 17fc3e5652
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m18s
feat: 所有任务展示/列表/导出补「分辨率」字段 — 1080P 可见性完善
v0.19.0 上线 1080P 后,多处展示只有"比例"没有"分辨率",用户和财务看到
费用差异却不知道是因为分辨率不同。本次补齐:

- VideoDetailModal 视频详情信息栏:模式·模型·时长·比例·【分辨率】·tokens·费用
- RecordDetailModal 消费记录详情弹窗:基本信息加「分辨率」字段
- RecordsPage 超管消费记录 CSV 导出:比例列后加「分辨率」列
- TeamRecordsPage 团管消费记录 CSV 导出:同上
- ProfilePage 个人中心记录列表:右侧费用旁加分辨率小标签(仅当有值)
- types/index.ts: AdminRecord 加 resolution?: Resolution 字段

后端 API 之前已返回 resolution(v0.19.0 的 5 处手工序列化已覆盖 L1751
admin_records / L1815 team_records / L2704 profile_records / L2837/2919
内容资产),前端只需接住展示即可。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 22:08:06 +08:00

196 lines
8.4 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { teamApi } from '../lib/api';
import type { AdminRecord } from '../types';
import { showToast } from '../components/Toast';
import { DatePicker } from '../components/DatePicker';
import { RecordDetailModal } from '../components/RecordDetailModal';
import styles from './RecordsPage.module.css';
export function TeamRecordsPage() {
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 [loading, setLoading] = useState(true);
const pageSize = 20;
const fetchRecords = useCallback(async () => {
setLoading(true);
try {
const { data } = await teamApi.getRecords({
page, page_size: pageSize, search,
start_date: startDate || undefined,
end_date: endDate || undefined,
});
setRecords(data.results);
setTotal(data.total);
} catch {
showToast('加载消费记录失败');
} finally {
setLoading(false);
}
}, [page, search, startDate, endDate]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
const handleSearch = () => {
setPage(1);
fetchRecords();
};
const handleExportCSV = async () => {
try {
const { data } = await teamApi.getRecords({
page: 1, page_size: 10000, search,
start_date: startDate || undefined,
end_date: endDate || 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 modelLabel = r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama';
const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
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;
const resolutionLabel = r.resolution ? r.resolution.toUpperCase() : '';
return `"${r.ark_task_id || ''}","${new Date(r.created_at).toLocaleString('zh-CN')}","${completedAt}","${elapsed}","${r.username}","${modelLabel}","${r.duration ?? ''}","${modeLabel}","${r.aspect_ratio || ''}","${resolutionLabel}","${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${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()}
/>
<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>Tokens</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: 10 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : records.length === 0 ? (
<tr><td colSpan={10} 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.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 className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td>
<td>{r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'}</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)} />
)}
</>
);
}