Files
yingqing/core/frontend/src/components/free-create/generation-card.tsx
T
2026-08-19 17:44:56 +08:00

122 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 自由创作·任务卡:生成中(shimmer+平滑进度) / 失败(中文错误+重试) / 完成(悬停播放+操作)。
import { useRef } from "react";
import { Download, Heart, RotateCcw, Trash2 } from "lucide-react";
import type { FreeVideoTask } from "../../types";
import { MODE_LABELS, STATUS_LABELS, isInFlight, modelLabel } from "./constants";
import { isPublicGenerationError, presentGenerationError } from "../../generation-error";
function ratioStyle(ratio: string) {
const [w, h] = ratio.split(":").map(Number);
return { aspectRatio: w && h ? `${w} / ${h}` : "16 / 9" };
}
function ratioCopy(ratio: string) {
const [w, h] = ratio.split(":").map(Number);
if (!w || !h) return ratio;
if (w === h) return `方形 ${ratio}`;
return `${w > h ? "横屏" : "竖屏"} ${ratio}`;
}
function statusTag(status: string) {
if (status === "succeeded") return { cls: "green", label: "已完成" };
if (status === "failed" || status === "cancelled") return { cls: "danger", label: status === "cancelled" ? "已取消" : "失败" };
return { cls: "blue", label: STATUS_LABELS[status] || "生成中" };
}
export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavorite, onDelete, onDownload }: {
task: FreeVideoTask;
progress: number;
onOpen: () => void;
onRetry: () => void;
onToggleFavorite: () => void;
onDelete: () => void;
onDownload: () => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const inFlight = isInFlight(task.status);
const failed = task.status === "failed" || task.status === "cancelled";
const done = task.status === "succeeded" && !!task.video_url;
const tag = statusTag(task.status);
const errorText = isPublicGenerationError(task.error)
? (() => { const presentation = presentGenerationError(task.error); return `${presentation.title}:${presentation.description}`; })()
: task.error_message || "视频生成失败,请重试";
return (
<article className={`fc-card${done ? " done" : ""}`}>
<div
className="fc-card-media"
style={ratioStyle(task.aspect_ratio)}
role={done ? "button" : undefined}
tabIndex={done ? 0 : undefined}
onClick={done ? onOpen : undefined}
onKeyDown={done ? (event) => { if (event.key === "Enter") onOpen(); } : undefined}
onMouseEnter={() => { if (done) void videoRef.current?.play().catch(() => undefined); }}
onMouseLeave={() => { videoRef.current?.pause(); if (videoRef.current) videoRef.current.currentTime = 0; }}
>
{inFlight && (
<div className="fc-card-generating">
<span className="spinner" />
<div className="fc-progress"><span style={{ width: `${Math.min(progress, 95)}%` }} /></div>
<span>{STATUS_LABELS[task.status] || "生成中"} · {Math.round(Math.min(progress, 95))}%</span>
</div>
)}
{failed && (
<div className="fc-card-failed">
<span className="fc-tag danger">失败</span>
<p>{errorText}</p>
<div className="fc-failed-actions">
<button type="button" className="fc-mini" onClick={(event) => { event.stopPropagation(); onRetry(); }}>
<RotateCcw size={13} /> 重试
</button>
<button type="button" className="fc-mini" title="删除" onClick={(event) => { event.stopPropagation(); onDelete(); }}>
<Trash2 size={13} /> 删除
</button>
</div>
</div>
)}
{done && (
<>
<video
ref={videoRef}
src={task.video_url}
poster={task.thumbnail_url || undefined}
muted
loop
playsInline
preload="metadata"
/>
<span className="fc-dur">{task.duration}s</span>
<div className="fc-card-hover">
<button type="button" className="fc-hover-btn" title="下载" onClick={(event) => { event.stopPropagation(); onDownload(); }}>
<Download size={14} />
</button>
<button type="button" className={`fc-hover-btn${task.is_favorited ? " fav" : ""}`} title={task.is_favorited ? "取消收藏" : "收藏"} onClick={(event) => { event.stopPropagation(); onToggleFavorite(); }}>
<Heart size={14} fill={task.is_favorited ? "currentColor" : "none"} />
</button>
<button type="button" className="fc-hover-btn danger" title="删除" onClick={(event) => { event.stopPropagation(); onDelete(); }}>
<Trash2 size={14} />
</button>
</div>
</>
)}
{task.status === "succeeded" && !task.video_url && (
<div className="fc-card-failed">
<p>视频链接已失效{task.fallback_note ? `(${task.fallback_note})` : ""}</p>
</div>
)}
</div>
<div className="fc-card-meta">
<h3 title={task.prompt}>{task.prompt || "未命名视频"}</h3>
<p>{MODE_LABELS[task.mode] || task.mode} · {task.duration} 秒 · {ratioCopy(task.aspect_ratio)}</p>
<div className="fc-card-line">
<span className={`fc-tag ${tag.cls}`}>{tag.label}</span>
<span className="fc-tag">{modelLabel(task.model)}</span>
</div>
{inFlight && (
<div className="fc-progress-mini"><span style={{ width: `${Math.min(progress, 95)}%` }} /></div>
)}
</div>
</article>
);
}