video-shuoshan/web/src/components/ReferenceList.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

160 lines
5.4 KiB
TypeScript

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',
};