From 911f3c158b46e0994abd7ff43ac78769ab6eca17 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Wed, 25 Mar 2026 13:10:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.13.3=20=E6=B6=88=E8=B4=B9=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E8=AF=A6=E6=83=85=E5=BC=B9=E7=AA=97=20+=20=E5=8F=82?= =?UTF-8?q?=E8=80=83=E7=B4=A0=E6=9D=90=E9=A2=84=E8=A7=88=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=20+=20CSV=20=E5=85=A8=E9=87=8F=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 消费记录点击行弹出任务详情弹窗(任务ID、状态、错误原因+原始错误、基本信息、完整提示词、参考素材) - ReferenceList 共用组件:图片点击大图、视频/音频点击播放、下载按钮 - VideoDetailModal 参考素材加播放和下载按钮 - 素材库引用图片修复:用 thumb_url 替代 asset:// 显示,轮询时也更新 references - raw_error 字段:存储火山原始错误信息,仅管理员弹窗可见 - CSV 导出扩充至 21 列(超管)/ 17 列(团管):新增任务ID、完成时间、视频时长、比例、种子值、原始错误、参考素材数 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migrations/0012_add_raw_error.py | 18 ++ backend/apps/generation/models.py | 1 + backend/apps/generation/views.py | 19 ++- web/src/components/RecordDetailModal.tsx | 146 ++++++++++++++++ web/src/components/ReferenceList.tsx | 159 ++++++++++++++++++ web/src/components/VideoDetailModal.tsx | 54 ++++-- web/src/pages/RecordsPage.tsx | 20 ++- web/src/pages/TeamRecordsPage.tsx | 19 ++- web/src/store/generation.ts | 47 +++++- web/src/types/index.ts | 5 + 10 files changed, 450 insertions(+), 38 deletions(-) create mode 100644 backend/apps/generation/migrations/0012_add_raw_error.py create mode 100644 web/src/components/RecordDetailModal.tsx create mode 100644 web/src/components/ReferenceList.tsx diff --git a/backend/apps/generation/migrations/0012_add_raw_error.py b/backend/apps/generation/migrations/0012_add_raw_error.py new file mode 100644 index 0000000..6ecdeaf --- /dev/null +++ b/backend/apps/generation/migrations/0012_add_raw_error.py @@ -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='原始错误信息'), + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index 4b6a974..7ddca37 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -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='种子值') diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index f536f28..ca9b259 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -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({ diff --git a/web/src/components/RecordDetailModal.tsx b/web/src/components/RecordDetailModal.tsx new file mode 100644 index 0000000..c6784d2 --- /dev/null +++ b/web/src/components/RecordDetailModal.tsx @@ -0,0 +1,146 @@ +import type { AdminRecord } from '../types'; +import { ReferenceList } from './ReferenceList'; + +const STATUS_MAP: Record = { + 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 = { 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 ( + <> +
+
e.stopPropagation()}> + {/* Header */} +
+ 任务详情 + +
+ +
+ {/* Status */} +
+ {st.label} +
+ + {/* Error */} + {r.status === 'failed' && r.error_message && ( +
+
失败原因
+
{r.error_message}
+ {r.raw_error && r.raw_error !== r.error_message && ( +
+ 原始错误:{r.raw_error} +
+ )} +
+ )} + + {/* Info Grid */} +
基本信息
+
+ {r.ark_task_id && } + {r.username && } + {showTeam && r.team_name && } + + + + + + + {showCost && } + {r.seed != null && r.seed !== -1 && } +
+ + {/* Prompt */} +
提示词
+
{r.prompt || '(无提示词)'}
+ + {/* References */} + {refs.length > 0 && ( + <> +
参考素材({refs.length})
+ + + )} +
+
+
+ + ); +} + +function InfoItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +// 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', +}; diff --git a/web/src/components/ReferenceList.tsx b/web/src/components/ReferenceList.tsx new file mode 100644 index 0000000..2f415dc --- /dev/null +++ b/web/src/components/ReferenceList.tsx @@ -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(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 ( + <> +
+ {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 ( +
+ {/* Thumbnail area */} +
+ {isAudio ? ( +
hasUrl && setPlayingMedia({ url: fullUrl, type: 'audio' })} + >♫
+ ) : isVideo ? ( +
hasUrl && setPlayingMedia({ url: fullUrl, type: 'video' })} + >▶
+ ) : thumbUrl && !thumbUrl.startsWith('asset://') ? ( + thumbUrl && !thumbUrl.startsWith('asset://') && setLightboxUrl(thumbUrl)} + /> + ) : ( +
?
+ )} + {/* Download button */} + {hasUrl && ( + + )} +
+
{label}
+
+ ); + })} +
+ + {/* Image lightbox */} + {lightboxUrl && ( +
setLightboxUrl(null)}> + +
+ )} + + {/* Video/Audio player modal */} + {playingMedia && ( +
setPlayingMedia(null)}> +
e.stopPropagation()}> + + {playingMedia.type === 'video' ? ( +
+
+ )} + + ); +} + +// 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', +}; diff --git a/web/src/components/VideoDetailModal.tsx b/web/src/components/VideoDetailModal.tsx index f3b8f0c..249a9ad 100644 --- a/web/src/components/VideoDetailModal.tsx +++ b/web/src/components/VideoDetailModal.tsx @@ -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(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(null); const moreMenuRef = useRef(null); @@ -483,21 +484,29 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
{task.references.map((ref) => (
- {ref.type === 'video' ? ( -
))} @@ -555,6 +564,21 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele onCancel={() => setConfirmDelete(false)} /> setLightboxSrc(null)} /> + {refMediaPreview && ( +
setRefMediaPreview(null)}> +
e.stopPropagation()}> + + {refMediaPreview.type === 'video' ? ( +
+
+ )}
); diff --git a/web/src/pages/RecordsPage.tsx b/web/src/pages/RecordsPage.tsx index fee1197..2d187b9 100644 --- a/web/src/pages/RecordsPage.tsx +++ b/web/src/pages/RecordsPage.tsx @@ -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([]); + const [detailRecord, setDetailRecord] = useState(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 (<>

消费记录

@@ -156,7 +158,7 @@ export function RecordsPage() { 暂无记录 ) : ( records.map((r) => ( - + setDetailRecord(r)} style={{ cursor: 'pointer' }}> {new Date(r.created_at).toLocaleString('zh-CN')} {formatElapsed(r)} {r.team_name || '-'} @@ -205,5 +207,9 @@ export function RecordsPage() {
)}
+ {detailRecord && ( + setDetailRecord(null)} showTeam showCost /> + )} + ); } diff --git a/web/src/pages/TeamRecordsPage.tsx b/web/src/pages/TeamRecordsPage.tsx index 1848f9a..9430077 100644 --- a/web/src/pages/TeamRecordsPage.tsx +++ b/web/src/pages/TeamRecordsPage.tsx @@ -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([]); + const [detailRecord, setDetailRecord] = useState(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 (<>

消费记录

@@ -134,7 +137,7 @@ export function TeamRecordsPage() { 暂无记录 ) : ( records.map((r) => ( - + setDetailRecord(r)} style={{ cursor: 'pointer' }}> {new Date(r.created_at).toLocaleString('zh-CN')} {formatElapsed(r)} {r.username} @@ -180,5 +183,9 @@ export function TeamRecordsPage() {
)}
+ {detailRecord && ( + setDetailRecord(null)} /> + )} + ); } diff --git a/web/src/store/generation.ts b/web/src/store/generation.ts index 94ae86a..d22b06e 100644 --- a/web/src/store/generation.ts +++ b/web/src/store/generation.ts @@ -62,17 +62,26 @@ function backendToFrontend(bt: BackendTask): GenerationTask { const references: ReferenceSnapshot[] = allRefs .filter((ref) => { const url = ref.url || ''; + const thumbUrl = (ref as Record).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).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) => { + 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, 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 ), diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 9243409..fbe0cff 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -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 {