import { useRef, useState, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import type { GenerationTask } from '../types'; import { useGenerationStore } from '../store/generation'; import { showToast } from './Toast'; import { ConfirmModal } from './ConfirmModal'; import { tosThumb } from '../lib/api'; import styles from './GenerationCard.module.css'; const EditIcon = () => ( ); const RefreshIcon = () => ( ); const VideoIcon = () => ( ); const DownloadIcon = () => ( ); // Mention tag with thumbnail + hover preview function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?: string; assetType?: string }) { const [hover, setHover] = useState(false); const ref = useRef(null); const [pos, setPos] = useState({ top: 0, left: 0 }); const isAudio = assetType === 'Audio' || assetType === 'audio'; return ( <> { if (!isAudio && thumbUrl && ref.current) { const rect = ref.current.getBoundingClientRect(); setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 }); setHover(true); } }} onMouseLeave={() => setHover(false)} > {isAudio ? ( ) : thumbUrl ? ( ) : null} {label} {hover && thumbUrl && createPortal(
{label}
{label}
, document.body )} ); } // Render prompt text with @mentions as styled tags (thumbnail + hover preview) export function renderPromptWithMentions( text: string, assetMentions: Record[], references: { label: string; previewUrl?: string }[] ) { // Build lookup: label → { thumbUrl, assetType } const thumbMap = new Map(); for (const am of assetMentions) { if (am.label) thumbMap.set(am.label as string, { thumbUrl: (am.thumbUrl as string) || '', assetType: (am.assetType as string) || 'image', }); } for (const r of references) { if (r.label && !thumbMap.has(r.label)) thumbMap.set(r.label, { thumbUrl: r.previewUrl || '', assetType: (r as Record).type as string || 'image', }); } const labels = [...thumbMap.keys()]; if (labels.length === 0) return text; // Build regex: match @label patterns, longest first labels.sort((a, b) => b.length - a.length); const escaped = labels.map((l) => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const regex = new RegExp(`(@(?:${escaped.join('|')}))`, 'g'); const parts = text.split(regex); if (parts.length === 1) return text; return parts.map((part, i) => { if (regex.test(part)) { regex.lastIndex = 0; const label = part.slice(1); // remove @ const info = thumbMap.get(label); return ; } regex.lastIndex = 0; return part; }); } interface Props { task: GenerationTask; onOpenDetail?: (task: GenerationTask) => void; } export function GenerationCard({ task, onOpenDetail }: Props) { const removeTask = useGenerationStore((s) => s.removeTask); const reEdit = useGenerationStore((s) => s.reEdit); const regenerate = useGenerationStore((s) => s.regenerate); const toggleFavorite = useGenerationStore((s) => s.toggleFavorite); const videoRef = useRef(null); const moreRef = useRef(null); const promptLineRef = useRef(null); const promptWrapperRef = useRef(null); const labelsRef = useRef(null); const refColumnRef = useRef(null); const [videoHover, setVideoHover] = useState(false); const [promptHover, setPromptHover] = useState(false); const [showMore, setShowMore] = useState(false); const [truncatedPrompt, setTruncatedPrompt] = useState(task.prompt); const [confirmDelete, setConfirmDelete] = useState(false); const [detailHover, setDetailHover] = useState(false); const [detailPos, setDetailPos] = useState({ top: 0, right: 0 }); const detailLinkRef = useRef(null); const detailLeaveTimer = useRef | null>(null); const [refPreview, setRefPreview] = useState<{ url: string; label: string; type: string; top: number; left: number } | null>(null); const startDetailLeave = useCallback(() => { if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current); detailLeaveTimer.current = setTimeout(() => setDetailHover(false), 200); }, []); const cancelDetailLeave = useCallback(() => { if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current); }, []); // Close more menu on click outside useEffect(() => { if (!showMore) return; const handler = (e: MouseEvent) => { if (moreRef.current && !moreRef.current.contains(e.target as Node)) { setShowMore(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [showMore]); // JS-level prompt truncation: ensure labels always visible at end of line 2 const computeTruncation = useCallback(() => { const container = promptLineRef.current; const labelsEl = labelsRef.current; if (!container || !labelsEl) return; const containerWidth = container.offsetWidth; if (containerWidth === 0) return; const style = getComputedStyle(container); const font = `${style.fontSize} ${style.fontFamily}`; const labelsWidth = labelsEl.offsetWidth + 8; // Account for mention tags (thumbnails) taking extra width vs plain text const mentionCount = (task.assetMentions?.length || 0) + (task.references?.length || 0); const mentionExtraWidth = mentionCount * 24; // ~24px extra per mention (thumbnail + padding) const totalAvailable = containerWidth * 2 - labelsWidth - 24 - mentionExtraWidth; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d')!; ctx.font = font; const prompt = task.prompt || ''; const fullWidth = ctx.measureText(prompt).width; if (fullWidth <= totalAvailable) { setTruncatedPrompt(prompt); return; } let truncated = ''; let totalWidth = 0; const ellipsisWidth = ctx.measureText('…').width; for (const char of prompt) { const charWidth = ctx.measureText(char).width; if (totalWidth + charWidth + ellipsisWidth > totalAvailable) { break; } truncated += char; totalWidth += charWidth; } setTruncatedPrompt(truncated + '…'); }, [task.prompt]); useEffect(() => { computeTruncation(); const container = promptLineRef.current; if (!container) return; const ro = new ResizeObserver(() => computeTruncation()); ro.observe(container); return () => ro.disconnect(); }, [computeTruncation]); const isGenerating = task.status === 'generating'; const hasResult = task.status === 'completed' && !!task.resultUrl; const handleVideoMouseEnter = () => { setVideoHover(true); const v = videoRef.current; if (!v) return; v.muted = false; v.play().catch(() => { // Browser blocks unmuted autoplay — fallback to muted v.muted = true; v.play().catch(() => {}); }); }; const handleVideoMouseLeave = () => { setVideoHover(false); const v = videoRef.current; if (!v) return; v.pause(); v.currentTime = 0; }; const handleDownload = async (e: React.MouseEvent) => { e.stopPropagation(); if (!task.resultUrl) return; try { const res = await fetch(task.resultUrl); const blob = await res.blob(); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = `airdrama-${task.id}.mp4`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(blobUrl), 10000); } catch { const a = document.createElement('a'); a.href = task.resultUrl; a.download = `airdrama-${task.id}.mp4`; a.click(); } }; const handleCopyPrompt = (e: React.MouseEvent) => { e.stopPropagation(); navigator.clipboard.writeText(task.prompt).then(() => { showToast('已复制'); }); }; const handleVideoClick = () => { if (hasResult && onOpenDetail) { onOpenDetail(task); } }; return (
{/* Header: reference thumbnails + prompt + meta labels */}
{/* Left: reference thumbnails */} {task.references.length > 0 && (
{task.references.map((ref) => (
{ if (ref.type === 'audio') return; const rect = e.currentTarget.getBoundingClientRect(); setRefPreview({ url: ref.previewUrl, label: ref.label, type: ref.type, top: rect.top - 8, left: rect.left + rect.width / 2 }); }} onMouseLeave={() => setRefPreview(null)} > {ref.type === 'video' ? (
))}
)} {/* Right: prompt + inline labels */}
{ setPromptHover(false); startDetailLeave(); }} > {/* 默认状态:截断提示词 + inline 标签 */}
setPromptHover(true)}> {renderPromptWithMentions(truncatedPrompt || '(无文字描述)', task.assetMentions || [], task.references)} setPromptHover(false)} > {task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'} {task.duration}s {task.aspectRatio} { cancelDetailLeave(); const el = detailLinkRef.current; if (el) { const rect = el.getBoundingClientRect(); setDetailPos({ top: rect.bottom + 8, right: window.innerWidth - rect.right, }); } setDetailHover(true); }} onMouseLeave={startDetailLeave} > 详细信息 ⓘ
{/* 详细信息弹窗 — 放在 promptWrapper 外,鼠标可以移到弹窗上 */} {detailHover && (
{ cancelDetailLeave(); setDetailHover(true); }} onMouseLeave={startDetailLeave} >
视频比例{task.aspectRatio}
时长{task.duration}s
分辨率720p
模型 {task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}
生成时间 {new Date(task.createdAt).toLocaleString('zh-CN')}
{(task.tokensConsumed ?? 0) > 0 && ( <>
消耗 Tokens {(task.tokensConsumed ?? 0).toLocaleString()}
费用 ¥{(task.costAmount ?? 0).toFixed(2)}
)} {(task.seed ?? -1) > 0 && (
种子值 {task.seed}
)}
)}
{/* hover 展开黑底:基于 header 定位,左边距图片 4px */} {promptHover && task.prompt && (
setPromptHover(true)} onMouseLeave={() => setPromptHover(false)} > {renderPromptWithMentions(task.prompt, task.assetMentions || [], task.references)}
)}
{/* Reference thumbnail hover preview */} {refPreview && createPortal(
{refPreview.type === 'video' ? (
, document.body )} {/* Video / result area */}
{isGenerating ? (
视频生成中…
{Math.round(task.progress)}%
) : task.status === 'failed' ? (

{task.errorMessage || '生成失败,请重试'}

) : task.resultUrl ? (
) : (
视频已生成
)}
{/* Bottom action buttons */} {isGenerating && (
)} {!isGenerating && (
{showMore && (
)}
)} { removeTask(task.id); setConfirmDelete(false); }} onCancel={() => setConfirmDelete(false)} />
); }