All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m18s
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>
220 lines
9.7 KiB
TypeScript
220 lines
9.7 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 modelLabel = r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama';
|
|
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;
|
|
const resolutionLabel = r.resolution ? r.resolution.toUpperCase() : '';
|
|
return `"${r.ark_task_id || ''}","${new Date(r.created_at).toLocaleString('zh-CN')}","${completedAt}","${elapsed}","${r.team_name || '-'}","${r.username}","${modelLabel}","${r.duration ?? ''}","${modeLabel}","${r.aspect_ratio || ''}","${resolutionLabel}","${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>
|
|
<th>状态</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
Array.from({ length: 5 }).map((_, i) => (
|
|
<tr key={i}>
|
|
{Array.from({ length: 13 }).map((_, j) => (
|
|
<td key={j}><div className={styles.skeletonCell} /></td>
|
|
))}
|
|
</tr>
|
|
))
|
|
) : records.length === 0 ? (
|
|
<tr><td colSpan={13} 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.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)}><</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>
|
|
{detailRecord && (
|
|
<RecordDetailModal record={detailRecord} onClose={() => setDetailRecord(null)} showTeam showCost />
|
|
)}
|
|
</>
|
|
);
|
|
}
|