122 lines
5.4 KiB
TypeScript
122 lines
5.4 KiB
TypeScript
// 自由创作·任务卡:生成中(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>
|
||
);
|
||
}
|