feat: v0.13.3 消费记录详情弹窗 + 参考素材预览下载 + CSV 全量导出
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

- 消费记录点击行弹出任务详情弹窗(任务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>
This commit is contained in:
seaislee1209 2026-03-25 13:10:28 +08:00
parent 49616128da
commit 911f3c158b
10 changed files with 450 additions and 38 deletions

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-25 02:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generation', '0011_add_completed_at'),
]
operations = [
migrations.AddField(
model_name='generationrecord',
name='raw_error',
field=models.TextField(blank=True, default='', verbose_name='原始错误信息'),
),
]

View File

@ -43,6 +43,7 @@ class GenerationRecord(models.Model):
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', verbose_name='状态')
result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL')
error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
raw_error = models.TextField(blank=True, default='', verbose_name='原始错误信息')
reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息')
is_favorited = models.BooleanField(default=False, verbose_name='已收藏')
seed = models.BigIntegerField(default=-1, verbose_name='种子值')

View File

@ -370,9 +370,11 @@ def video_generate_view(request):
from utils.airdrama_client import AirDramaAPIError
if isinstance(e, AirDramaAPIError):
record.error_message = e.user_message
record.raw_error = f'{e.code}: {e.api_message}' if hasattr(e, 'code') else str(e)
else:
record.error_message = str(e)
record.save(update_fields=['status', 'completed_at', 'error_message'])
record.error_message = '生成失败,请重试'
record.raw_error = str(e)
record.save(update_fields=['status', 'completed_at', 'error_message', 'raw_error'])
# API 调用失败,释放冻结
_release_freeze(record)
else:
@ -522,6 +524,7 @@ def video_task_detail_view(request, task_id):
raw_msg = error.get('message', '') if isinstance(error, dict) else str(error)
from utils.airdrama_client import ERROR_MESSAGES
record.error_message = ERROR_MESSAGES.get(code, raw_msg)
record.raw_error = f'{code}: {raw_msg}' if code else raw_msg
# 失败时检查是否产生了 token 消耗
usage = ark_resp.get('usage', {})
total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0
@ -534,7 +537,7 @@ def video_task_detail_view(request, task_id):
if new_status in ('completed', 'failed'):
record.completed_at = timezone.now()
record.save(update_fields=['status', 'result_url', 'error_message', 'seed', 'completed_at'])
record.save(update_fields=['status', 'result_url', 'error_message', 'raw_error', 'seed', 'completed_at'])
except Exception as e:
logger.exception('AirDrama API query failed for %s', ark_task_id)
@ -1623,6 +1626,11 @@ def admin_records_view(request):
'aspect_ratio': r.aspect_ratio,
'status': r.status,
'error_message': r.error_message or '',
'raw_error': r.raw_error or '',
'reference_urls': r.reference_urls or [],
'duration': r.duration,
'seed': r.seed,
'ark_task_id': r.ark_task_id or '',
})
return Response({
@ -1680,6 +1688,11 @@ def team_records_view(request):
'aspect_ratio': r.aspect_ratio,
'status': r.status,
'error_message': r.error_message or '',
'raw_error': r.raw_error or '',
'reference_urls': r.reference_urls or [],
'duration': r.duration,
'seed': r.seed,
'ark_task_id': r.ark_task_id or '',
})
return Response({

View File

@ -0,0 +1,146 @@
import type { AdminRecord } from '../types';
import { ReferenceList } from './ReferenceList';
const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> = {
completed: { label: '已完成', color: '#00b894', bg: 'rgba(0,184,148,0.15)' },
failed: { label: '失败', color: '#e74c3c', bg: 'rgba(231,76,60,0.15)' },
processing: { label: '生成中', color: '#00b8e6', bg: 'rgba(0,184,230,0.15)' },
queued: { label: '排队中', color: '#00b8e6', bg: 'rgba(0,184,230,0.15)' },
};
const MODE_MAP: Record<string, string> = { universal: '全能参考', keyframe: '首尾帧' };
interface Props {
record: AdminRecord;
onClose: () => void;
showTeam?: boolean;
showCost?: boolean;
}
export function RecordDetailModal({ record: r, onClose, showTeam, showCost }: Props) {
const st = STATUS_MAP[r.status] || STATUS_MAP.processing;
const elapsed = (() => {
if (!r.completed_at) return '-';
const ms = new Date(r.completed_at).getTime() - new Date(r.created_at).getTime();
if (ms < 0) return '-';
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}`;
const min = Math.floor(sec / 60);
const s = sec % 60;
return `${min}${s > 0 ? s + '秒' : ''}`;
})();
const refs = r.reference_urls || [];
return (
<>
<div style={overlay} onClick={onClose}>
<div style={modal} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div style={header}>
<span style={{ fontSize: 16, fontWeight: 600, color: '#e2e2ea' }}></span>
<button style={closeBtn} onClick={onClose}></button>
</div>
<div style={body}>
{/* Status */}
<div style={{ marginBottom: 16 }}>
<span style={{ ...statusBadge, color: st.color, background: st.bg }}>{st.label}</span>
</div>
{/* Error */}
{r.status === 'failed' && r.error_message && (
<div style={errorBox}>
<div style={{ fontWeight: 500, marginBottom: 4 }}></div>
<div>{r.error_message}</div>
{r.raw_error && r.raw_error !== r.error_message && (
<div style={{ marginTop: 8, fontSize: 11, color: '#888', fontFamily: 'monospace', wordBreak: 'break-all' }}>
{r.raw_error}
</div>
)}
</div>
)}
{/* Info Grid */}
<div style={sectionTitle}></div>
<div style={infoGrid}>
{r.ark_task_id && <InfoItem label="任务ID" value={r.ark_task_id} />}
{r.username && <InfoItem label="用户" value={r.username} />}
{showTeam && r.team_name && <InfoItem label="团队" value={r.team_name} />}
<InfoItem label="提交时间" value={new Date(r.created_at).toLocaleString('zh-CN')} />
<InfoItem label="耗时" value={elapsed} />
<InfoItem label="模式" value={MODE_MAP[r.mode] || r.mode} />
<InfoItem label="比例" value={r.aspect_ratio || '-'} />
<InfoItem label="时长" value={r.duration != null ? `${r.duration}` : '-'} />
<InfoItem label="Tokens" value={(r.tokens_consumed || 0).toLocaleString()} />
{showCost && <InfoItem label="费用" value={`¥${(r.cost_amount || 0).toFixed(2)}`} />}
{r.seed != null && r.seed !== -1 && <InfoItem label="种子值" value={String(r.seed)} />}
</div>
{/* Prompt */}
<div style={sectionTitle}></div>
<div style={promptBox}>{r.prompt || '(无提示词)'}</div>
{/* References */}
{refs.length > 0 && (
<>
<div style={sectionTitle}>{refs.length}</div>
<ReferenceList references={refs} />
</>
)}
</div>
</div>
</div>
</>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 11, color: '#888', marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 13, color: '#e2e2ea', wordBreak: 'break-all' }}>{value}</div>
</div>
);
}
// Styles
const overlay: React.CSSProperties = {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 10000,
};
const modal: React.CSSProperties = {
background: '#111118', border: '1px solid #2a2a38', borderRadius: 12,
width: 560, maxHeight: '80vh', display: 'flex', flexDirection: 'column',
};
const header: React.CSSProperties = {
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '16px 20px', borderBottom: '1px solid #2a2a38',
};
const closeBtn: React.CSSProperties = {
background: 'none', border: 'none', color: '#888', fontSize: 16, cursor: 'pointer',
padding: '4px 8px', borderRadius: 4,
};
const body: React.CSSProperties = {
padding: 20, overflowY: 'auto', flex: 1,
};
const statusBadge: React.CSSProperties = {
padding: '4px 12px', borderRadius: 6, fontSize: 13, fontWeight: 500,
};
const errorBox: React.CSSProperties = {
background: 'rgba(231,76,60,0.08)', border: '1px solid rgba(231,76,60,0.2)',
borderRadius: 8, padding: 12, marginBottom: 16, fontSize: 13, color: '#e74c3c',
};
const sectionTitle: React.CSSProperties = {
fontSize: 12, color: '#888', fontWeight: 500, marginBottom: 8, marginTop: 16,
textTransform: 'uppercase', letterSpacing: 1,
};
const infoGrid: React.CSSProperties = {
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px 16px',
};
const promptBox: React.CSSProperties = {
background: '#0a0a0f', borderRadius: 8, padding: 12, fontSize: 13,
color: '#ccc', lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
maxHeight: 150, overflowY: 'auto',
};

View File

@ -0,0 +1,159 @@
import { useState } from 'react';
interface RefItem {
type?: string;
url?: string;
name?: string;
label?: string;
thumb_url?: string;
role?: string;
}
interface Props {
references: RefItem[];
}
export function ReferenceList({ references }: Props) {
const [lightboxUrl, setLightboxUrl] = useState<string | null>(null);
const [playingMedia, setPlayingMedia] = useState<{ url: string; type: 'video' | 'audio' } | null>(null);
if (references.length === 0) return null;
const handleDownload = (url: string, label: string) => {
const a = document.createElement('a');
a.href = url;
a.download = label;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.click();
};
return (
<>
<div style={refsGrid}>
{references.map((ref, i) => {
const thumbUrl = ref.thumb_url || ref.url || '';
const fullUrl = ref.url || '';
const isAudio = ref.type === 'audio';
const isVideo = ref.type === 'video';
const label = ref.label || ref.name || ref.type || `素材${i + 1}`;
const hasUrl = fullUrl && !fullUrl.startsWith('asset://');
return (
<div key={i} style={refItem}>
{/* Thumbnail area */}
<div style={thumbWrap}>
{isAudio ? (
<div
style={{ ...placeholder, cursor: hasUrl ? 'pointer' : 'default' }}
onClick={() => hasUrl && setPlayingMedia({ url: fullUrl, type: 'audio' })}
></div>
) : isVideo ? (
<div
style={{ ...placeholder, cursor: hasUrl ? 'pointer' : 'default' }}
onClick={() => hasUrl && setPlayingMedia({ url: fullUrl, type: 'video' })}
></div>
) : thumbUrl && !thumbUrl.startsWith('asset://') ? (
<img
src={thumbUrl}
alt=""
style={refImgStyle}
onClick={() => thumbUrl && !thumbUrl.startsWith('asset://') && setLightboxUrl(thumbUrl)}
/>
) : (
<div style={placeholder}>?</div>
)}
{/* Download button */}
{hasUrl && (
<button
style={downloadBtn}
onClick={(e) => { e.stopPropagation(); handleDownload(fullUrl, label); }}
title="下载"
></button>
)}
</div>
<div style={refLabel}>{label}</div>
</div>
);
})}
</div>
{/* Image lightbox */}
{lightboxUrl && (
<div style={overlay} onClick={() => setLightboxUrl(null)}>
<img src={lightboxUrl} alt="" style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8 }} />
</div>
)}
{/* Video/Audio player modal */}
{playingMedia && (
<div style={overlay} onClick={() => setPlayingMedia(null)}>
<div style={playerWrap} onClick={(e) => e.stopPropagation()}>
<button style={playerClose} onClick={() => setPlayingMedia(null)}></button>
{playingMedia.type === 'video' ? (
<video
src={playingMedia.url}
controls
autoPlay
style={{ maxWidth: '80vw', maxHeight: '70vh', borderRadius: 8 }}
/>
) : (
<div style={audioWrap}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<audio src={playingMedia.url} controls autoPlay style={{ width: 320 }} />
</div>
)}
</div>
</div>
)}
</>
);
}
// Styles
const overlay: React.CSSProperties = {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 10002,
};
const refsGrid: React.CSSProperties = {
display: 'flex', gap: 8, flexWrap: 'wrap',
};
const refItem: React.CSSProperties = {
width: 80, textAlign: 'center',
};
const thumbWrap: React.CSSProperties = {
position: 'relative', width: 80, height: 80,
};
const refImgStyle: React.CSSProperties = {
width: 80, height: 80, objectFit: 'cover', borderRadius: 6, cursor: 'pointer',
border: '1px solid #2a2a38',
};
const placeholder: React.CSSProperties = {
width: 80, height: 80, borderRadius: 6, background: '#1a1a2e',
border: '1px solid #2a2a38', display: 'flex', alignItems: 'center',
justifyContent: 'center', fontSize: 24, color: '#888',
};
const downloadBtn: React.CSSProperties = {
position: 'absolute', bottom: 4, right: 4,
width: 22, height: 22, borderRadius: 4,
background: 'rgba(0,0,0,0.6)', border: 'none',
color: '#fff', fontSize: 12, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
};
const refLabel: React.CSSProperties = {
fontSize: 10, color: '#888', marginTop: 4, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
};
const playerWrap: React.CSSProperties = {
position: 'relative', background: '#111118', borderRadius: 12,
padding: 24, border: '1px solid #2a2a38',
};
const playerClose: React.CSSProperties = {
position: 'absolute', top: 8, right: 12,
background: 'none', border: 'none', color: '#888',
fontSize: 16, cursor: 'pointer',
};
const audioWrap: React.CSSProperties = {
display: 'flex', flexDirection: 'column', alignItems: 'center',
padding: '20px 40px', color: '#888',
};

View File

@ -39,6 +39,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
const [showMoreMenu, setShowMoreMenu] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const [refMediaPreview, setRefMediaPreview] = useState<{ url: string; type: 'video' | 'audio' } | null>(null);
const [fitSize, setFitSize] = useState<{ w: number; h: number } | null>(null);
const [intrinsicRatio, setIntrinsicRatio] = useState<number | null>(null);
const moreMenuRef = useRef<HTMLDivElement>(null);
@ -483,21 +484,29 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<div className={styles.refGrid}>
{task.references.map((ref) => (
<div key={ref.id} className={styles.refItem}>
{ref.type === 'video' ? (
<video src={ref.previewUrl} className={styles.refImg} muted />
) : ref.type === 'audio' ? (
<div className={styles.refAudioPlaceholder}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
) : ref.previewUrl ? (
<img src={tosThumb(ref.previewUrl, 300)} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} />
) : (
<div className={styles.refAudioPlaceholder} style={{ fontSize: 12, color: 'var(--color-text-disabled)' }}></div>
)}
<div style={{ position: 'relative', width: 56, height: 56 }}>
{ref.type === 'video' ? (
<video src={ref.previewUrl} className={styles.refImg} muted style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'video' })} />
) : ref.type === 'audio' ? (
<div className={styles.refAudioPlaceholder} style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'audio' })}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
) : ref.previewUrl ? (
<img src={tosThumb(ref.previewUrl, 300)} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} />
) : (
<div className={styles.refAudioPlaceholder} style={{ fontSize: 12, color: 'var(--color-text-disabled)' }}></div>
)}
{ref.previewUrl && (
<a href={ref.previewUrl} download={ref.label} target="_blank" rel="noopener noreferrer"
style={{ position: 'absolute', bottom: 2, right: 2, width: 18, height: 18, borderRadius: 3, background: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', textDecoration: 'none' }}
onClick={(e) => e.stopPropagation()}
></a>
)}
</div>
<span className={styles.refLabel}>{ref.label}</span>
</div>
))}
@ -555,6 +564,21 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
onCancel={() => setConfirmDelete(false)}
/>
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
{refMediaPreview && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10002 }} onClick={() => setRefMediaPreview(null)}>
<div style={{ position: 'relative', background: '#111118', borderRadius: 12, padding: 24, border: '1px solid #2a2a38' }} onClick={(e) => e.stopPropagation()}>
<button style={{ position: 'absolute', top: 8, right: 12, background: 'none', border: 'none', color: '#888', fontSize: 16, cursor: 'pointer' }} onClick={() => setRefMediaPreview(null)}></button>
{refMediaPreview.type === 'video' ? (
<video src={refMediaPreview.url} controls autoPlay style={{ maxWidth: '80vw', maxHeight: '70vh', borderRadius: 8 }} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '20px 40px', color: '#888' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<audio src={refMediaPreview.url} controls autoPlay style={{ width: 320 }} />
</div>
)}
</div>
</div>
)}
</div>
</div>
);

View File

@ -4,10 +4,12 @@ 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('');
@ -58,16 +60,16 @@ export function RecordsPage() {
team_id: teamFilter ? Number(teamFilter) : undefined,
});
const header = '时间,耗时,团队,用户名,消费秒数,Tokens,费用(元),成本(元),利润(元),提示词,生成模式,状态,失败原因\n';
const header = '任务ID,提交时间,完成时间,耗时,团队,用户名,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),成本(元),利润(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n';
const rows = data.results.map((r) => {
// Escape CSV fields to prevent injection
const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const esc = (s: string) => s.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧';
const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
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) + '秒' : '';
return `${r.created_at},"${elapsed}","${r.team_name || '-'}",${r.username},"${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${(r.base_cost_amount || 0).toFixed(2)}","${profit}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`;
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;' });
@ -97,7 +99,7 @@ export function RecordsPage() {
return `${min}${sec > 0 ? sec + '秒' : ''}`;
};
return (
return (<>
<div className={styles.page}>
<div className={styles.header}>
<h1 className={styles.title}></h1>
@ -156,7 +158,7 @@ export function RecordsPage() {
<tr><td colSpan={12} className={styles.empty}></td></tr>
) : (
records.map((r) => (
<tr key={r.id}>
<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>
@ -205,5 +207,9 @@ export function RecordsPage() {
</div>
)}
</div>
{detailRecord && (
<RecordDetailModal record={detailRecord} onClose={() => setDetailRecord(null)} showTeam showCost />
)}
</>
);
}

View File

@ -3,10 +3,12 @@ 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('');
@ -47,14 +49,15 @@ export function TeamRecordsPage() {
end_date: endDate || undefined,
});
const header = '时间,耗时,用户名,消费秒数,Tokens,费用(元),提示词,生成模式,状态,失败原因\n';
const header = '任务ID,提交时间,完成时间,耗时,用户名,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n';
const rows = data.results.map((r) => {
const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const esc = (s: string) => s.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧';
const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const elapsed = r.completed_at ? Math.round((new Date(r.completed_at).getTime() - new Date(r.created_at).getTime()) / 1000) + '秒' : '';
return `${r.created_at},"${elapsed}",${r.username},"${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`;
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.username}","${r.duration ?? ''}","${modeLabel}","${r.aspect_ratio || ''}","${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;' });
@ -84,7 +87,7 @@ export function TeamRecordsPage() {
return `${min}${sec > 0 ? sec + '秒' : ''}`;
};
return (
return (<>
<div className={styles.page}>
<div className={styles.header}>
<h1 className={styles.title}></h1>
@ -134,7 +137,7 @@ export function TeamRecordsPage() {
<tr><td colSpan={9} className={styles.empty}></td></tr>
) : (
records.map((r) => (
<tr key={r.id}>
<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>
@ -180,5 +183,9 @@ export function TeamRecordsPage() {
</div>
)}
</div>
{detailRecord && (
<RecordDetailModal record={detailRecord} onClose={() => setDetailRecord(null)} />
)}
</>
);
}

View File

@ -62,17 +62,26 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
const references: ReferenceSnapshot[] = allRefs
.filter((ref) => {
const url = ref.url || '';
const thumbUrl = (ref as Record<string, string>).thumb_url || '';
// 保留有真实 URL 或有缩略图的引用
if (thumbUrl) return true;
if (!url || url.trim() === '') return false;
if (url.startsWith('asset://') || url.startsWith('Asset://')) return false;
return true;
})
.map((ref, i) => ({
id: `ref_${bt.task_id}_${i}`,
type: (ref.type || 'image') as 'image' | 'video',
previewUrl: ref.url,
label: ref.label || `素材${i + 1}`,
role: ref.role,
}));
.map((ref, i) => {
const url = ref.url || '';
const thumbUrl = (ref as Record<string, string>).thumb_url || '';
// 素材库引用用 thumb_url普通上传用 url
const displayUrl = (url.startsWith('asset://') || url.startsWith('Asset://')) ? thumbUrl : url;
return {
id: `ref_${bt.task_id}_${i}`,
type: (ref.type || 'image') as 'image' | 'video',
previewUrl: displayUrl,
label: ref.label || `素材${i + 1}`,
role: ref.role,
};
});
// Asset 引用 — 仅用于 reEdit/regenerate 重建 mention span
const assetMentions = allRefs
@ -160,6 +169,29 @@ function startPolling(taskId: string, frontendId: string) {
const { data } = await videoApi.getTaskStatus(taskId);
const newStatus = mapStatus(data.status);
// Parse references from polling response (includes thumb_url for asset refs)
const pollRefs: ReferenceSnapshot[] = (data.reference_urls || [])
.filter((ref: Record<string, string>) => {
const url = ref.url || '';
const thumbUrl = ref.thumb_url || '';
if (thumbUrl) return true;
if (!url || url.trim() === '') return false;
if (url.startsWith('asset://') || url.startsWith('Asset://')) return false;
return true;
})
.map((ref: Record<string, string>, i: number) => {
const url = ref.url || '';
const thumbUrl = ref.thumb_url || '';
const displayUrl = (url.startsWith('asset://') || url.startsWith('Asset://')) ? thumbUrl : url;
return {
id: `ref_${taskId}_${i}`,
type: (ref.type || 'image') as 'image' | 'video' | 'audio',
previewUrl: displayUrl,
label: ref.label || `素材${i + 1}`,
role: ref.role,
};
});
useGenerationStore.setState((s) => ({
tasks: s.tasks.map((t) =>
t.id === frontendId
@ -172,6 +204,7 @@ function startPolling(taskId: string, frontendId: string) {
tokensConsumed: data.tokens_consumed ?? t.tokensConsumed,
costAmount: data.cost_amount ?? t.costAmount,
seed: data.seed ?? t.seed,
references: pollRefs.length > 0 ? pollRefs : t.references,
}
: t
),

View File

@ -196,6 +196,11 @@ export interface AdminRecord {
aspect_ratio?: string;
status: 'queued' | 'processing' | 'completed' | 'failed';
error_message?: string;
raw_error?: string;
reference_urls?: Array<{ type?: string; url?: string; name?: string; label?: string; thumb_url?: string; role?: string }>;
duration?: number;
seed?: number;
ark_task_id?: string;
}
export interface SystemSettings {