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>
160 lines
5.4 KiB
TypeScript
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',
|
|
};
|