完成极速成品和脚本优化

This commit is contained in:
Azmat@qq.com
2026-08-25 11:13:07 +08:00
parent 00fc454db7
commit e2ec2d14af
46 changed files with 4734 additions and 432 deletions
+602 -31
View File
@@ -1,78 +1,649 @@
import { useEffect, useState } from "react";
import { ArrowLeft, Sparkles, Trash2, Upload, WandSparkles } from "lucide-react";
import type { Page } from "./route-config";
import { useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
ArrowLeft,
Clapperboard,
Download,
ImagePlus,
RefreshCw,
ScanSearch,
ScrollText,
SlidersHorizontal,
Sparkles,
Play,
Upload,
UsersRound,
WandSparkles,
X,
Columns2,
ArrowUpRight,
} from "lucide-react";
import { api, ApiError, type QuickCreateJob } from "../api";
import type { ModelConfig } from "../types";
import {
DEFAULT_BILLING_RATES,
estimateCost,
FC_MODELS,
modelLabel,
type BillingRates,
} from "../components/free-create/constants";
import type { NavigateFn } from "./route-config";
export function QuickCreatePage({ onBack }: { onBack: () => void; navigate?: (page: Page) => void }) {
const QUICK_JOB_KEY = "airshelf:quick-create-job";
const PROGRESS_STEPS = [
{ label: "识别商品与卖点", icon: ScanSearch },
{ label: "推荐脚本方向", icon: ScrollText },
{ label: "匹配模特与场景", icon: UsersRound },
{ label: "生成故事板与视频", icon: Clapperboard },
];
const QUICK_RATIOS = [
{ value: "9:16", label: "9:16 竖屏" },
{ value: "16:9", label: "16:9 横屏" },
{ value: "1:1", label: "1:1 方形" },
{ value: "3:4", label: "3:4 竖版" },
{ value: "4:3", label: "4:3 横版" },
{ value: "21:9", label: "21:9 超宽" },
];
const QUICK_RESOLUTIONS = [
{ value: "480p", label: "480p 流畅" },
{ value: "720p", label: "720p 高清" },
{ value: "1080p", label: "1080p 超清" },
{ value: "4k", label: "4K 超清" },
];
const QUICK_DURATIONS = [15, 30, 45, 60];
function modelResolutions(config: ModelConfig | undefined) {
const capabilities = (config?.metadata?.capabilities || {}) as Record<string, unknown>;
const nested = Array.isArray(capabilities.resolutions) ? capabilities.resolutions : [];
const legacy = Array.isArray(config?.metadata?.resolutions) ? config.metadata.resolutions : [];
return (nested.length ? nested : legacy).map(String);
}
function formatClock(seconds: number) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
const rest = total % 60;
return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
}
function historyTitle(item: QuickCreateJob) {
return (item.title || item.product_name || "极速成片").replace(/ · 极速成片$/, "");
}
function savedJobId() {
try {
return localStorage.getItem(QUICK_JOB_KEY) || "";
} catch {
return "";
}
}
export function QuickCreatePage({
onBack,
backLabel = "返回视频创作",
initialProductId,
navigate,
onNotify,
onProjectCreated,
modelConfigs,
}: {
onBack: () => void;
backLabel?: string;
initialProductId?: string;
navigate: NavigateFn;
onNotify?: (type: "success" | "error" | "info", text: string) => void;
onProjectCreated?: () => void;
modelConfigs: ModelConfig[];
}) {
const [name, setName] = useState("");
const [images, setImages] = useState<File[]>([]);
const [savedImages, setSavedImages] = useState<Array<{ asset_id: string; url: string }>>([]);
const [sourceProductId, setSourceProductId] = useState("");
const [preview, setPreview] = useState("");
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const [jobId, setJobId] = useState(savedJobId);
const [job, setJob] = useState<QuickCreateJob | null>(null);
const [submitting, setSubmitting] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [serviceUnavailable, setServiceUnavailable] = useState(false);
const [unavailableMessage, setUnavailableMessage] = useState("");
const [history, setHistory] = useState<QuickCreateJob[]>([]);
const [playing, setPlaying] = useState<{ url: string; poster: string; title: string } | null>(null);
const videoConfigs = useMemo(
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
[modelConfigs],
);
const preferredModel = useMemo(
() => videoConfigs.find((config) => config.name === FC_MODELS[1].name) || videoConfigs[0],
[videoConfigs],
);
const [aspectRatio, setAspectRatio] = useState("9:16");
const [resolution, setResolution] = useState("720p");
const [totalDuration, setTotalDuration] = useState(15);
const [videoModelId, setVideoModelId] = useState("");
const [billingRates, setBillingRates] = useState<BillingRates>(DEFAULT_BILLING_RATES);
const completedNoticeRef = useRef("");
const cancelRequestedRef = useRef(false);
const notifyRef = useRef(onNotify);
const projectCreatedRef = useRef(onProjectCreated);
const imageInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!images[0]) {
setPreview("");
notifyRef.current = onNotify;
projectCreatedRef.current = onProjectCreated;
}, [onNotify, onProjectCreated]);
useEffect(() => {
if (!videoModelId && preferredModel) setVideoModelId(preferredModel.id);
}, [preferredModel, videoModelId]);
useEffect(() => {
void api.billingConfig()
.then((config) => setBillingRates({
margin: Number(config.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin,
rate: Number(config.points_per_yuan) || DEFAULT_BILLING_RATES.rate,
multiplier: Number(config.team_price_multiplier) || 1,
}))
.catch(() => undefined);
void api.quickCreateHistory()
.then((payload) => setHistory(payload.results || []))
.catch(() => undefined);
}, []);
useEffect(() => {
if (!initialProductId || jobId) return;
let cancelled = false;
void api.product(initialProductId)
.then((product) => {
if (cancelled) return;
setName((current) => current || product.title || "");
setSourceProductId((current) => current || product.id);
setSavedImages((current) => {
if (current.length) return current;
return (product.images || [])
.filter((image) => image.asset)
.slice(0, 9)
.map((image) => ({ asset_id: image.asset, url: image.preview_url || "" }));
});
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [initialProductId, jobId]);
const selectedVideoModel = useMemo(
() => videoConfigs.find((config) => config.id === videoModelId) || preferredModel,
[preferredModel, videoConfigs, videoModelId],
);
const supportedResolutions = useMemo(
() => modelResolutions(selectedVideoModel),
[selectedVideoModel],
);
useEffect(() => {
if (!supportedResolutions.length || supportedResolutions.includes(resolution)) return;
setResolution(supportedResolutions.includes("720p") ? "720p" : supportedResolutions[0]);
}, [resolution, supportedResolutions]);
useEffect(() => {
if (!images.length) {
setImagePreviews([]);
return;
}
const url = URL.createObjectURL(images[0]);
setPreview(url);
return () => URL.revokeObjectURL(url);
const urls = images.map((image) => URL.createObjectURL(image));
setImagePreviews(urls);
return () => urls.forEach((url) => URL.revokeObjectURL(url));
}, [images]);
function selectImages(files: FileList | null) {
setImages(Array.from(files || []).slice(0, 9));
useEffect(() => {
setPreview(imagePreviews[0] || savedImages.find((image) => image.url)?.url || "");
}, [imagePreviews, savedImages]);
useEffect(() => {
if (!jobId) return;
let cancelled = false;
let timer = 0;
const poll = async () => {
try {
const next = await api.quickCreateStatus(jobId);
if (cancelled) return;
setJob(next);
if (next.product_name) setName((current) => current || next.product_name);
if (next.product_images?.length) {
setSavedImages(next.product_images.filter((image) => image.asset_id));
if (next.product_id) setSourceProductId(next.product_id);
setImages([]);
}
if (next.settings) {
setAspectRatio(next.settings.aspect_ratio);
setResolution(next.settings.resolution);
setTotalDuration(next.settings.total_duration);
if (next.settings.video_model_config_id) setVideoModelId(next.settings.video_model_config_id);
}
if (next.status === "succeeded") {
if (completedNoticeRef.current !== next.id) {
completedNoticeRef.current = next.id;
notifyRef.current?.("success", "极速成片已生成");
projectCreatedRef.current?.();
void api.quickCreateHistory().then((payload) => setHistory(payload.results || [])).catch(() => undefined);
}
return;
}
if (next.status === "failed" || next.status === "cancelled") return;
timer = window.setTimeout(poll, 2500);
} catch (error) {
if (cancelled) return;
// 极速任务按团队隔离。本机切换账号后,localStorage 里可能仍保留上个团队的任务 ID;
// 404 不是生成失败,清掉这条旧记录即可,不能把“任务不存在”不断弹给新账号。
if (error instanceof ApiError && error.status === 404) {
setJob(null);
setJobId("");
try {
localStorage.removeItem(QUICK_JOB_KEY);
} catch {
/* ignore */
}
notifyRef.current?.("info", "已清除其他账号的极速成片记录");
return;
}
notifyRef.current?.("error", error instanceof Error ? error.message : "读取极速成片进度失败");
timer = window.setTimeout(poll, 8000);
}
};
void poll();
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [jobId]);
useEffect(() => {
if (!playing) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setPlaying(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [playing]);
function playClip(url?: string, poster?: string, title?: string) {
if (!url) {
onNotify?.("info", "视频还不能播放,请稍后再试");
return;
}
setPlaying({ url, poster: poster || "", title: title || "预览视频" });
}
function selectImages(files: FileList | null) {
const selected = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
setImages((current) => {
const room = Math.max(0, 9 - savedImages.length - current.length);
const accepted = selected.slice(0, room);
if (selected.length > room) onNotify?.("info", "最多上传9张图片,已保留前9张");
return [...current, ...accepted];
});
}
function removeImage(index: number) {
if (index < savedImages.length) {
setSavedImages((current) => current.filter((_, imageIndex) => imageIndex !== index));
return;
}
setImages((current) => current.filter((_, imageIndex) => imageIndex !== index - savedImages.length));
}
function clearImages() {
setSavedImages([]);
setImages([]);
}
async function startGeneration() {
if (!name.trim() || (!images.length && !savedImages.length)) {
onNotify?.("info", "请先填写商品名称并上传商品图片");
return;
}
setSubmitting(true);
setJob(null);
setServiceUnavailable(false);
setUnavailableMessage("");
cancelRequestedRef.current = false;
try {
const form = new FormData();
form.append("name", name.trim());
form.append("aspect_ratio", aspectRatio);
form.append("resolution", resolution);
form.append("total_duration", String(totalDuration));
if (videoModelId) form.append("video_model_config_id", videoModelId);
if (sourceProductId && savedImages.length) {
form.append("source_product_id", sourceProductId);
savedImages.forEach((image) => form.append("image_asset_ids", image.asset_id));
}
images.forEach((image) => form.append("images", image));
const created = await api.startQuickCreate(form);
if (cancelRequestedRef.current) {
try {
await api.cancelQuickCreate(created.id);
} catch {
/* 启动刚成功但用户已点取消时,尽量停掉后台任务 */
}
resetResult();
notifyRef.current?.("info", "已取消本次生成");
return;
}
setJob(created);
setJobId(created.id);
setImages([]);
setSavedImages(created.product_images || []);
setSourceProductId(created.product_id || "");
try {
localStorage.setItem(QUICK_JOB_KEY, created.id);
} catch {
/* 本地存储不可用时只影响刷新恢复,不影响本次生成 */
}
onNotify?.("success", "极速成片任务已启动");
onProjectCreated?.();
} catch (error) {
if (error instanceof ApiError && error.status === 503) {
setServiceUnavailable(true);
setUnavailableMessage(error.message || "极速成片服务暂不可用,请稍后再试");
onNotify?.("info", error.message || "极速成片服务暂不可用,请稍后再试");
} else {
onNotify?.("error", error instanceof Error ? error.message : "极速成片启动失败");
}
} finally {
setSubmitting(false);
}
}
function resetResult() {
setJob(null);
setJobId("");
setCancelling(false);
setServiceUnavailable(false);
setUnavailableMessage("");
completedNoticeRef.current = "";
try {
localStorage.removeItem(QUICK_JOB_KEY);
} catch {
/* ignore */
}
}
async function cancelGeneration() {
if (cancelling) return;
cancelRequestedRef.current = true;
if (!jobId) {
setSubmitting(false);
resetResult();
notifyRef.current?.("info", "已取消本次生成");
return;
}
setCancelling(true);
try {
const next = await api.cancelQuickCreate(jobId);
setJob(next);
notifyRef.current?.("info", "已取消本次生成");
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
resetResult();
notifyRef.current?.("info", "已退出本次生成");
return;
}
// 接口失败也不能把人锁在转圈里:清掉本地任务,允许重新开始。
resetResult();
notifyRef.current?.("info", error instanceof Error ? error.message : "已停止当前生成");
} finally {
setCancelling(false);
}
}
const isGenerating = submitting || job?.status === "queued" || job?.status === "running";
const isComplete = job?.status === "succeeded";
const isCancelled = job?.status === "cancelled";
const isFailed = job?.status === "failed" || isCancelled || serviceUnavailable;
const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews];
const imageCount = savedImages.length + images.length;
const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel);
const activePhase = submitting ? 0 : Math.max(0, Math.min(3, job?.phase_index ?? 0));
const result = job?.result;
const videoClips = result?.video_segments?.length
? result.video_segments
: result?.video_url
? [{ id: "final", sort_order: 0, duration_seconds: result.duration_seconds, video_url: result.video_url, poster_url: result.poster_url }]
: [];
const sceneCount = Math.max(1, Math.round(totalDuration / 15));
const videoEstimate = estimateCost(
selectedVideoModel,
{ ratio: aspectRatio, resolution, duration: totalDuration, refs: [] },
billingRates,
);
// 商品理解、基础资产和每镜故事板在脚本生成前无法精确报价;按每场约60积分给出透明预估,最终按成功任务结算。
const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 60 : sceneCount * 240;
const shellClass = [
"quick-create-shell",
isGenerating ? "is-generating" : "",
isComplete ? "is-complete" : "",
isFailed ? "is-failed" : "",
].filter(Boolean).join(" ");
return (
<div className="quick-create-page">
<header className="project-builder-header quick-create-header">
<div className="project-builder-title">
<button type="button" className="image-back-button" onClick={onBack} aria-label="返回工作台"><ArrowLeft /></button>
<div><h1>极速成片</h1><p>输入商品名称并上传图片,系统将自动完成从商品理解到视频生成的全部流程</p></div>
<button type="button" className="image-back-button" onClick={onBack} aria-label={backLabel}><ArrowLeft /></button>
<div><h1>极速成片</h1></div>
</div>
<div className="project-builder-status"><strong>AI 自动编排</strong><span>· 无需逐步配置</span></div>
</header>
<div className="quick-create-shell" id="quickCreateShell">
<div className={shellClass} id="quickCreateShell">
<section className="quick-create-panel quick-form-panel">
<div className="quick-form-copy">
<h2>告诉我们这是什么商品</h2>
<p>系统会识别商品信息,自动选择带货结构、表现形式、模特和场景,并完成15秒竖屏视频。</p>
</div>
<label className="quick-field">
<span className="quick-field-label"><span>商品名称</span><small>必填</small></span>
<input className="quick-name-input" autoComplete="off" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:净颜精华、轻醒咖啡" />
<input className="quick-name-input" autoComplete="off" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:净颜精华、轻醒咖啡" disabled={isGenerating} />
</label>
<div className="quick-field">
<span className="quick-field-label"><span>商品图片</span><small>必填 · 最多9张</small></span>
<label className={`quick-upload${preview ? " has-image" : ""}`}>
<input type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(event) => selectImages(event.target.files)} />
<span className="quick-upload-copy"><span className="quick-upload-icon"><Upload /></span><strong>上传商品图片</strong><small>建议包含清晰主图、包装和使用细节</small></span>
<span className="quick-upload-preview">
<img src={preview} alt="极速成片商品预览" />
<span className="quick-image-count">{images.length}张图片</span>
<button type="button" className="quick-image-clear" onClick={(event) => { event.preventDefault(); setImages([]); }} aria-label="删除已上传图片"><Trash2 /></button>
</span>
<div className={`quick-upload${imageCount ? " has-images" : ""}`}>
<input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(event) => { selectImages(event.target.files); event.currentTarget.value = ""; }} />
{imageCount ? (
<div className="quick-upload-filled">
<div className="quick-image-grid" aria-label={`已上传 ${imageCount} 张商品图片`}>
{Array.from({ length: 9 }, (_, index) => {
const image = displayUrls[index];
return <div key={image || savedImages[index]?.asset_id || `empty-${index}`} className={`quick-image-tile${index < imageCount ? " is-filled" : ""}`}>
{index < imageCount ? <>{image ? <img src={image} alt={`商品图片 ${index + 1}`} /> : null}<button type="button" disabled={isGenerating} onClick={() => removeImage(index)} aria-label={`删除商品图片 ${index + 1}`}><X /></button></> : null}
</div>;
})}
</div>
<div className="quick-upload-more">
<button type="button" className="quick-upload-trigger" onClick={() => imageInputRef.current?.click()} disabled={isGenerating || imageCount >= 9}>
<span className="quick-upload-more-icon"><ImagePlus /></span>
<strong>继续上传</strong>
<small>已上传 {imageCount} / 9</small>
</button>
<button type="button" className="quick-clear-all" onClick={clearImages} disabled={isGenerating}>清空全部</button>
</div>
</div>
) : <button type="button" className="quick-upload-copy" onClick={() => imageInputRef.current?.click()} disabled={isGenerating}><span className="quick-upload-icon"><Upload /></span><strong>上传商品图片</strong><small>建议包含清晰主图、包装和使用细节</small></button>}
</div>
</div>
<div className="quick-parameter-grid" aria-label="视频核心参数">
<label className="quick-parameter-field">
<span>视频比例</span>
<select value={aspectRatio} onChange={(event) => setAspectRatio(event.target.value)} disabled={isGenerating}>
{QUICK_RATIOS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="quick-parameter-field">
<span>分辨率</span>
<select value={resolution} onChange={(event) => setResolution(event.target.value)} disabled={isGenerating}>
{QUICK_RESOLUTIONS.map((option) => (
<option key={option.value} value={option.value} disabled={Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value))}>
{option.label}
</option>
))}
</select>
</label>
<label className="quick-parameter-field">
<span>视频时长</span>
<select value={totalDuration} onChange={(event) => setTotalDuration(Number(event.target.value))} disabled={isGenerating}>
{QUICK_DURATIONS.map((duration) => <option key={duration} value={duration}>{duration / 15} 场({duration}s)</option>)}
</select>
</label>
<label className="quick-parameter-field">
<span>视频模型</span>
<select value={videoModelId} onChange={(event) => setVideoModelId(event.target.value)} disabled={isGenerating || !videoConfigs.length}>
{videoConfigs.length ? videoConfigs.map((config) => (
<option key={config.id} value={config.id}>{FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name)}</option>
)) : <option value="">暂无可用视频模型</option>}
</select>
</label>
</div>
<div className="quick-auto-note" aria-label="系统自动完成内容"><span>识别商品与卖点</span><span>推荐脚本方向</span><span>匹配模特与场景</span><span>生成故事板与视频</span></div>
<div className="quick-form-footer">
<div className="quick-cost"><span>仅在视频生成成功后扣费</span><strong>预计 240 积分</strong></div>
<button type="button" className="quick-generate-button" disabled><WandSparkles /><span>立即生成视频</span></button>
{isGenerating ? (
<button type="button" className="quick-cancel-button" onClick={() => void cancelGeneration()} disabled={cancelling}>
<X /><span>{cancelling ? "正在取消…" : "取消生成"}</span>
</button>
) : (
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canRetry}>
<WandSparkles /><span>{`立即生成视频 · 消耗 ${estimatedPoints} 积分`}</span>
</button>
)}
</div>
</section>
<section className="quick-create-panel quick-status-panel" aria-live="polite">
<div className="quick-state quick-state-ready">
<div className="quick-ready-orbit"><span className="quick-ready-icon"><Sparkles /></span></div>
<h2>系统会替你完成所有选择</h2>
<p>上传商品后,AI 将根据品类、图片信息和适用场景自动编排完整视频,不需要理解复杂的制作参数。</p>
<div className="quick-ready-tags"><span>自动推荐结构</span><span>自动选择表现形式</span><span>自动匹配资产</span><span>自动质量检查</span></div>
<h2>核心参数可选,其余自动完成</h2>
</div>
<div className="quick-state quick-state-generating">
<div className="quick-generating-preview" aria-label="视频生成中"><div className="quick-preview-spinner" /></div>
<div className="quick-generating-copy"><h2>正在为商品生成视频</h2><p>{job?.message || "正在生成视频并进行质量检查…"}</p></div>
<div className="quick-progress-track">
{PROGRESS_STEPS.map((step, index) => {
const Icon = step.icon;
const done = index < activePhase;
const active = isGenerating && index === activePhase;
return <div key={step.label} className={`quick-progress-node${active ? " active" : ""}${done ? " done" : ""}`}><span className="quick-progress-dot"><Icon /></span><strong>{step.label}</strong></div>;
})}
</div>
<div className="quick-generating-actions">
<button type="button" className="secondary-action" onClick={() => void cancelGeneration()} disabled={cancelling}>
<X />{cancelling ? "正在取消…" : "取消生成"}
</button>
</div>
</div>
<div className="quick-state quick-state-complete">
<div className={`quick-video-result-grid${videoClips.length === 1 ? " is-single" : ""}`}>
{videoClips.map((clip, index) => (
<button
key={clip.id}
type="button"
className="quick-video-result-card"
onClick={() => playClip(clip.video_url || result?.video_url, clip.poster_url || result?.poster_url || preview, `第${index + 1}场视频`)}
>
<span className="quick-video-result-thumb">
{clip.poster_url || preview ? <img src={clip.poster_url || preview} alt={`第${index + 1}场视频首帧`} /> : clip.video_url ? <video src={clip.video_url} muted playsInline preload="metadata" /> : null}
<span className="quick-video-play" aria-hidden="true"><Play /></span>
</span>
<span className="quick-video-result-meta"><strong>第{index + 1}场视频</strong><small>{clip.duration_seconds || 15}秒</small></span>
</button>
))}
</div>
<div className="quick-result-head"><div><h2>{videoClips.length || 1} 个视频已生成</h2><p>{videoClips.length || 1}场 · 每场{videoClips[0]?.duration_seconds || 15}秒 · {result?.aspect_ratio || "9:16"} · {(result?.resolution || "720p").toUpperCase()} · {result?.video_model || "智能模型"}</p></div><span className="quick-result-badge">质量检查通过</span></div>
<div className={`quick-result-actions${videoClips.length > 1 ? "" : " is-single"}`}>
<button type="button" className="secondary-action" onClick={resetResult}><RefreshCw />重新生成</button>
{videoClips.length > 1 ? (
<button type="button" className="secondary-action" onClick={() => job && navigate("pipeline", { projectId: job.project_id })}><Columns2 />合并视频</button>
) : null}
{result?.video_url ? <a className="primary-action quick-download-action" href={result.video_url} target="_blank" rel="noreferrer" download><Download />下载视频</a> : <button type="button" className="primary-action" disabled><Download />下载视频</button>}
</div>
</div>
<div className="quick-state quick-state-failed">
<span className="quick-failed-icon"><AlertCircle /></span>
<h2>{serviceUnavailable ? "极速成片暂不可用" : isCancelled ? "已取消本次生成" : "本次生成未完成"}</h2>
<p>{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : job?.error_message || "生成过程中遇到问题,可以重试或重新开始。"}</p>
<div className="quick-failed-actions">
{canRetry && !serviceUnavailable ? (
<button type="button" className="primary-action" onClick={() => void startGeneration()}><RefreshCw />重试</button>
) : null}
<button type="button" className={canRetry && !serviceUnavailable ? "secondary-action" : "primary-action"} onClick={resetResult}>
<RefreshCw />重新开始
</button>
{job?.project_id ? (
<button type="button" className="secondary-action" onClick={() => navigate("pipeline", { projectId: job.project_id })}>
<SlidersHorizontal />进入专业模式
</button>
) : null}
</div>
</div>
</section>
</div>
<section className="quick-history" aria-label="过往极速成片项目">
<div className="quick-history-head">
<h2>过往极速成片项目</h2>
<span>{history.length}个项目</span>
</div>
{history.length ? (
<div className="quick-history-list">
{history.map((item) => {
const poster = item.result?.poster_url || "";
const videoUrl = item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
const duration = item.result?.duration_seconds || item.settings?.total_duration || 15;
const scenes = item.result?.video_segments?.length || Math.max(1, Math.round(duration / 15));
return (
<article key={item.id} className="quick-history-card">
<button
type="button"
className="quick-history-thumb"
onClick={() => playClip(videoUrl, poster, historyTitle(item))}
aria-label={`播放${historyTitle(item)}`}
>
{poster ? <img src={poster} alt="" /> : <span className="quick-history-thumb-empty"><Play /></span>}
<small>{formatClock(duration)}</small>
</button>
<div className="quick-history-copy">
<span className="quick-history-badge">已完成</span>
<h3>{historyTitle(item)}</h3>
<p>极速成片 · {scenes}场 · {item.result?.aspect_ratio || item.settings?.aspect_ratio || "9:16"} · {(item.result?.resolution || item.settings?.resolution || "720p").toUpperCase()} · {item.result?.video_model || item.settings?.video_model_label || "智能模型"}</p>
</div>
<button type="button" className="quick-history-open" onClick={() => navigate("pipeline", { projectId: item.project_id })}>
<ArrowUpRight />查看项目
</button>
</article>
);
})}
</div>
) : (
<p className="quick-history-empty">还没有完成的极速成片,生成成功后会出现在这里。</p>
)}
</section>
{playing ? (
<div className="quick-player-bg" onClick={() => setPlaying(null)} role="presentation">
<div className="quick-player" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={playing.title}>
<div className="quick-player-bar">
<strong>{playing.title}</strong>
<button type="button" onClick={() => setPlaying(null)} aria-label="关闭播放"><X /></button>
</div>
<video src={playing.url} poster={playing.poster || undefined} controls autoPlay playsInline controlsList="nodownload" />
</div>
</div>
) : null}
</div>
);
}