video-shuoshan/web/src/components/GenerationCard.tsx
zyc 00eb2e62d8 Revert "feat: 前端预览资源切换到 CDN 域名 airflow-play.airlabs.art"
This reverts commit bc47bd09c4562ad48398fa2146921dfbcee82ac2.
2026-04-28 16:09:54 +08:00

542 lines
21 KiB
TypeScript

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 = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const RefreshIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
);
const VideoIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
);
const DownloadIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
);
// 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<HTMLSpanElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
const isAudio = assetType === 'Audio' || assetType === 'audio';
return (
<>
<span
ref={ref}
className={styles.mentionTag}
onMouseEnter={() => {
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 ? (
<span style={{ marginRight: 3, fontSize: 13, verticalAlign: 'middle' }}></span>
) : thumbUrl ? (
<img
src={tosThumb(thumbUrl, 28)}
alt=""
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
/>
) : null}
{label}
</span>
{hover && thumbUrl && createPortal(
<div className={styles.mentionPreview} style={{ top: pos.top, left: pos.left }}>
<img src={tosThumb(thumbUrl, 200)} alt={label} className={styles.mentionPreviewImg} />
<div className={styles.mentionPreviewLabel}>{label}</div>
</div>,
document.body
)}
</>
);
}
// Render prompt text with @mentions as styled tags (thumbnail + hover preview)
export function renderPromptWithMentions(
text: string,
assetMentions: Record<string, unknown>[],
references: { label: string; previewUrl?: string }[]
) {
// Build lookup: label → { thumbUrl, assetType }
const thumbMap = new Map<string, { thumbUrl: string; assetType: string }>();
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<string, unknown>).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 <MentionTag key={i} label={label} thumbUrl={info?.thumbUrl} assetType={info?.assetType} />;
}
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<HTMLVideoElement>(null);
const moreRef = useRef<HTMLDivElement>(null);
const promptLineRef = useRef<HTMLDivElement>(null);
const promptWrapperRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<HTMLSpanElement>(null);
const refColumnRef = useRef<HTMLDivElement>(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<HTMLSpanElement>(null);
const detailLeaveTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<div className={styles.card}>
{/* Header: reference thumbnails + prompt + meta labels */}
<div className={styles.header}>
{/* Left: reference thumbnails */}
{task.references.length > 0 && (
<div ref={refColumnRef} className={styles.refColumn}>
{task.references.map((ref) => (
<div
key={ref.id}
className={styles.refThumb}
onMouseEnter={(e) => {
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' ? (
<video src={ref.previewUrl} className={styles.refMedia} muted />
) : ref.type === 'audio' ? (
<div className={styles.audioThumb}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
) : (
<img src={tosThumb(ref.previewUrl, 112)} alt={ref.label} className={styles.refMedia} />
)}
</div>
))}
</div>
)}
{/* Right: prompt + inline labels */}
<div className={styles.headerRight}>
<div
ref={promptWrapperRef}
className={styles.promptWrapper}
onMouseLeave={() => { setPromptHover(false); startDetailLeave(); }}
>
{/* 默认状态:截断提示词 + inline 标签 */}
<div ref={promptLineRef} className={styles.promptLine}>
<span onMouseEnter={() => setPromptHover(true)}>
{renderPromptWithMentions(truncatedPrompt || '(无文字描述)', task.assetMentions || [], task.references)}
</span>
<span
ref={labelsRef}
className={styles.labelsInline}
onMouseEnter={() => setPromptHover(false)}
>
<span className={styles.label}>
{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}
</span>
<span className={styles.label}>{task.duration}s</span>
<span className={styles.label}>{task.aspectRatio}</span>
<span
ref={detailLinkRef}
className={styles.detailLink}
onMouseEnter={() => {
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}
>
</span>
</span>
</div>
</div>
{/* 详细信息弹窗 — 放在 promptWrapper 外,鼠标可以移到弹窗上 */}
{detailHover && (
<div
className={styles.detailTooltip}
style={{ top: detailPos.top, right: detailPos.right }}
onMouseEnter={() => { cancelDetailLeave(); setDetailHover(true); }}
onMouseLeave={startDetailLeave}
>
<div className={styles.detailRow}>
<span></span><span>{task.aspectRatio}</span>
</div>
<div className={styles.detailRow}>
<span></span><span>{task.duration}s</span>
</div>
<div className={styles.detailRow}>
<span></span><span>720p</span>
</div>
<div className={styles.detailRow}>
<span></span>
<span>{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}</span>
</div>
<div className={styles.detailRow}>
<span></span>
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
</div>
{(task.tokensConsumed ?? 0) > 0 && (
<>
<div className={styles.detailRow}>
<span> Tokens</span>
<span>{(task.tokensConsumed ?? 0).toLocaleString()}</span>
</div>
<div className={styles.detailRow}>
<span></span>
<span>¥{(task.costAmount ?? 0).toFixed(2)}</span>
</div>
</>
)}
{(task.seed ?? -1) > 0 && (
<div className={styles.detailRow}>
<span></span>
<span>{task.seed}</span>
</div>
)}
</div>
)}
</div>
{/* hover 展开黑底:基于 header 定位,左边距图片 4px */}
{promptHover && task.prompt && (
<div
className={styles.promptExpanded}
style={{ left: refColumnRef.current ? refColumnRef.current.offsetWidth + 4 : 0 }}
onMouseEnter={() => setPromptHover(true)}
onMouseLeave={() => setPromptHover(false)}
>
{renderPromptWithMentions(task.prompt, task.assetMentions || [], task.references)}
</div>
)}
</div>
{/* Reference thumbnail hover preview */}
{refPreview && createPortal(
<div className={styles.mentionPreview} style={{ top: refPreview.top, left: refPreview.left }}>
{refPreview.type === 'video' ? (
<video src={refPreview.url} className={styles.mentionPreviewImg} autoPlay loop muted playsInline />
) : (
<img src={tosThumb(refPreview.url, 300)} alt={refPreview.label} className={styles.mentionPreviewImg} />
)}
<div className={styles.mentionPreviewLabel}>{refPreview.label}</div>
</div>,
document.body
)}
{/* Video / result area */}
<div className={styles.content}>
{isGenerating ? (
<div className={styles.resultArea}>
<div className={styles.shimmerBg} />
<div className={styles.generating}>
<div className={styles.loadingSpinner} />
<span className={styles.loadingText}></span>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${task.progress}%` }}
/>
</div>
<span className={styles.progressText}>{Math.round(task.progress)}%</span>
</div>
</div>
) : task.status === 'failed' ? (
<p className={styles.errorText}>{task.errorMessage || '生成失败,请重试'}</p>
) : task.resultUrl ? (
<div
className={styles.resultArea}
onMouseEnter={handleVideoMouseEnter}
onMouseLeave={handleVideoMouseLeave}
onClick={handleVideoClick}
style={{ cursor: 'pointer' }}
>
<video
ref={videoRef}
src={task.resultUrl}
className={styles.resultMedia}
loop
preload="metadata"
/>
{videoHover && (
<div className={styles.videoOverlay}>
<button className={styles.downloadBtn} onClick={handleDownload}>
<DownloadIcon />
</button>
<button className={styles.downloadBtn} onClick={(e) => { e.stopPropagation(); toggleFavorite(task.id); }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill={task.isFavorited ? '#faad14' : 'none'} stroke={task.isFavorited ? '#faad14' : 'currentColor'} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</button>
</div>
)}
</div>
) : (
<div className={styles.resultArea}>
<div className={styles.resultPlaceholder}>
<VideoIcon />
<span></span>
</div>
</div>
)}
</div>
{/* Bottom action buttons */}
{isGenerating && (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => reEdit(task.id)}>
<EditIcon /> <span></span>
</button>
</div>
)}
{!isGenerating && (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => reEdit(task.id)}>
<EditIcon /> <span></span>
</button>
<button className={styles.actionBtn} onClick={() => regenerate(task.id)}>
<RefreshIcon /> <span></span>
</button>
<div className={styles.moreMenu} ref={moreRef}>
<button className={styles.moreBtn} onClick={() => setShowMore(!showMore)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="19" cy="12" r="2" />
</svg>
</button>
{showMore && (
<div className={styles.moreDropdown}>
<button onClick={() => { setConfirmDelete(true); setShowMore(false); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
)}
</div>
</div>
)}
<ConfirmModal
open={confirmDelete}
title="删除视频"
message="确定要删除这条生成记录吗?此操作不可撤销。"
confirmText="删除"
danger
onConfirm={() => { removeTask(task.id); setConfirmDelete(false); }}
onCancel={() => setConfirmDelete(false)}
/>
</div>
);
}