import { useRef, useState, useEffect, useCallback } from 'react'; import type { GenerationTask } from '../types'; import { useGenerationStore } from '../store/generation'; import { showToast } from './Toast'; import { ConfirmModal } from './ConfirmModal'; import styles from './GenerationCard.module.css'; const EditIcon = () => ( ); const RefreshIcon = () => ( ); const VideoIcon = () => ( ); const DownloadIcon = () => ( ); 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 videoRef = useRef(null); const moreRef = useRef(null); const promptLineRef = useRef(null); const promptWrapperRef = useRef(null); const labelsRef = 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 [promptAbove, setPromptAbove] = useState(false); const detailLinkRef = useRef(null); // 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}`; // Measure labels width const labelsWidth = labelsEl.offsetWidth + 8; // +8 for gap // Two lines of available width, minus labels on line 2, with safety margin const totalAvailable = containerWidth * 2 - labelsWidth - 24; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d')!; ctx.font = font; const prompt = task.prompt || ''; let totalWidth = 0; let needsTruncation = false; // Check if prompt fits const fullWidth = ctx.measureText(prompt).width; if (fullWidth <= totalAvailable) { setTruncatedPrompt(prompt); return; } // Truncate character by character let truncated = ''; const ellipsisWidth = ctx.measureText('…').width; for (const char of prompt) { const charWidth = ctx.measureText(char).width; if (totalWidth + charWidth + ellipsisWidth > totalAvailable) { needsTruncation = true; break; } truncated += char; totalWidth += charWidth; } setTruncatedPrompt(needsTruncation ? truncated + '…' : prompt); }, [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) => (
{ref.type === 'video' ? (
))}
)} {/* Right: prompt + inline labels */}
setPromptHover(false)} >
{ const el = promptWrapperRef.current; if (el) { const rect = el.getBoundingClientRect(); setPromptAbove(rect.bottom + 350 > window.innerHeight); } setPromptHover(true); }} >{truncatedPrompt || '(无文字描述)'} setPromptHover(false)}> {task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'} {task.duration}s {task.aspectRatio} { const el = detailLinkRef.current; if (el) { const rect = el.getBoundingClientRect(); setDetailPos({ top: rect.bottom + 8, right: window.innerWidth - rect.right, }); } setDetailHover(true); }} onMouseLeave={() => setDetailHover(false)} > 详细信息 ⓘ {detailHover && (
视频比例{task.aspectRatio}
时长{task.duration}s
分辨率720p
模型 {task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}
生成时间 {new Date(task.createdAt).toLocaleString('zh-CN')}
)}
{promptHover && task.prompt && (

{task.prompt}

)}
{/* Video / result area */}
{isGenerating ? (
视频生成中…
{Math.round(task.progress)}%
) : task.status === 'failed' ? (

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

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