Files
yingqing/core/frontend/src/routes/quick-create.tsx
T
2026-08-28 14:45:38 +08:00

916 lines
40 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.
import { useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
ArrowLeft,
Boxes,
Clapperboard,
ChevronRight,
ImagePlus,
RefreshCw,
ScrollText,
SlidersHorizontal,
Sparkles,
Play,
Upload,
WandSparkles,
X,
Columns2,
Download,
ArrowUpRight,
} from "lucide-react";
import { api, ApiError, type QuickCreateJob } from "../api";
import { ConfirmModal, OverlayPortal, useBodyScrollLock } from "../components/overlays";
import { useFileDrop } from "../components/use-file-drop";
import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob, stripQuickCreateSuffix } from "../quick-create-lock";
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";
import {
PC_CAT_MORE,
PC_CAT_MORE_LABEL,
PC_CAT_OPTIONS,
PC_CAT_PRIMARY,
} from "../product-business";
const PROGRESS_STEPS = [
{ label: "脚本", icon: ScrollText },
{ label: "资产", icon: Boxes },
{ 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 stripQuickCreateSuffix(item.title || item.product_name || "一键成片");
}
function jobIsComplete(item: QuickCreateJob) {
return item.status === "succeeded" || Boolean(item.result?.video_url) || Boolean(item.result?.video_segments?.some((clip) => clip.video_url));
}
function isReviewFailure(text?: string) {
return /审核|敏感内容|moderation|safety_violation|sensitivecontent/i.test(text || "");
}
function historyBadge(item: QuickCreateJob) {
if (item.status === "cancelled") return "已取消";
if (jobIsComplete(item)) return "已完成";
return "未完成";
}
function historyCover(item: QuickCreateJob) {
return item.product_images?.[0]?.url || "";
}
function historyVideoUrl(item: QuickCreateJob) {
return item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
}
function savedJobId() {
return readQuickCreateJobId();
}
export function QuickCreatePage({
onBack,
backLabel = "返回视频创作",
initialProductId,
navigate,
onNotify,
onProjectCreated,
onQuickCreateStatus,
modelConfigs,
}: {
onBack: () => void;
backLabel?: string;
initialProductId?: string;
navigate: NavigateFn;
onNotify?: (type: "success" | "error" | "info", text: string) => void;
onProjectCreated?: () => void;
onQuickCreateStatus?: (projectId: string, status: string) => void;
modelConfigs: ModelConfig[];
}) {
const [name, setName] = useState("");
const [category, setCategory] = useState("");
const [catMoreOpen, setCatMoreOpen] = useState(false);
const [images, setImages] = useState<File[]>([]);
const [savedImages, setSavedImages] = useState<Array<{ asset_id: string; url: string }>>([]);
const [sourceProductId, setSourceProductId] = 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 [confirmCancel, setConfirmCancel] = useState(false);
// 进页面先向后端确认有没有在跑的任务。localStorage 可能被清、也可能换了浏览器,
// 只认它会让刷新后看到一张空表单,用户以为没任务又提交一次 —— 那会重复建商品、重复扣费。
const [restoring, setRestoring] = useState(true);
const [serviceUnavailable, setServiceUnavailable] = useState(false);
const [unavailableMessage, setUnavailableMessage] = useState("");
const [pollEpoch, setPollEpoch] = useState(0);
const [history, setHistory] = useState<QuickCreateJob[]>([]);
const [playing, setPlaying] = useState<{ url: string; title: string } | null>(null);
useBodyScrollLock(Boolean(playing));
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 terminalNoticeRef = useRef("");
const watchedGeneratingRef = useRef(false);
const cancelRequestedRef = useRef(false);
const notifyRef = useRef(onNotify);
const projectCreatedRef = useRef(onProjectCreated);
const quickCreateStatusRef = useRef(onQuickCreateStatus);
const imageInputRef = useRef<HTMLInputElement>(null);
const productPrefillDoneRef = useRef(false);
useEffect(() => {
notifyRef.current = onNotify;
projectCreatedRef.current = onProjectCreated;
quickCreateStatusRef.current = onQuickCreateStatus;
}, [onNotify, onProjectCreated, onQuickCreateStatus]);
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 || []).filter(jobIsComplete));
const running = payload.inflight || null;
if (running) {
setJob(running);
setJobId(running.id);
rememberQuickCreateJob(running.id);
} else if (!savedJobId()) {
forgetQuickCreateJob();
}
})
.catch(() => undefined)
.finally(() => setRestoring(false));
}, []);
useEffect(() => {
if (!initialProductId || jobId || productPrefillDoneRef.current) return;
let cancelled = false;
void api.product(initialProductId)
.then((product) => {
if (cancelled || productPrefillDoneRef.current) return;
productPrefillDoneRef.current = true;
setName((current) => current || product.title || "");
setSourceProductId((current) => current || product.id);
if (product.category && PC_CAT_OPTIONS.includes(product.category)) {
setCategory((current) => current || product.category);
setCatMoreOpen(PC_CAT_MORE.includes(product.category));
}
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 urls = images.map((image) => URL.createObjectURL(image));
setImagePreviews(urls);
return () => urls.forEach((url) => URL.revokeObjectURL(url));
}, [images]);
useEffect(() => {
if (!jobId) return;
let cancelled = false;
let timer = 0;
const poll = async () => {
try {
const next = await api.quickCreateStatus(jobId);
if (cancelled) return;
if (next.status === "queued" || next.status === "running") {
watchedGeneratingRef.current = true;
}
if (next.status !== "succeeded") {
applyJobProduct(next);
}
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);
}
setJob(next);
if (next.status === "succeeded") {
if (watchedGeneratingRef.current && completedNoticeRef.current !== next.id) {
completedNoticeRef.current = next.id;
notifyRef.current?.("success", "一键成片已生成");
projectCreatedRef.current?.();
}
if (next.project_id) quickCreateStatusRef.current?.(next.project_id, next.status);
clearDraft();
void api.quickCreateHistory().then((payload) => setHistory((payload.results || []).filter(jobIsComplete))).catch(() => undefined);
watchedGeneratingRef.current = false;
return;
}
if (next.status === "failed" || next.status === "cancelled") {
if (terminalNoticeRef.current !== next.id) {
terminalNoticeRef.current = next.id;
const message = next.status === "cancelled"
? "一键成片已取消"
: next.error_message || "一键成片未完成,已完成的步骤会保留,可重试继续";
notifyRef.current?.(next.status === "cancelled" ? "info" : "error", message);
}
if (next.project_id) quickCreateStatusRef.current?.(next.project_id, next.status);
void api.quickCreateHistory().then((payload) => setHistory((payload.results || []).filter(jobIsComplete))).catch(() => undefined);
watchedGeneratingRef.current = false;
return;
}
timer = window.setTimeout(poll, 2500);
} catch (error) {
if (cancelled) return;
// 极速任务按团队隔离。本机切换账号后,localStorage 里可能仍保留上个团队的任务 ID;
// 404 不是生成失败,清掉这条旧记录即可,不能把“任务不存在”不断弹给新账号。
if (error instanceof ApiError && error.status === 404) {
setJob(null);
setJobId("");
clearDraft();
forgetQuickCreateJob();
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, pollEpoch]);
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, title?: string) {
if (!url) {
onNotify?.("info", "视频还不能播放,请稍后再试");
return;
}
setPlaying({ url, title: title || "预览视频" });
}
function selectImages(files: FileList | File[] | 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 retryGeneration() {
if (job?.id && job.status === "failed") {
setSubmitting(true);
setServiceUnavailable(false);
setUnavailableMessage("");
cancelRequestedRef.current = false;
terminalNoticeRef.current = "";
watchedGeneratingRef.current = true;
setJob((current) => (
current
? { ...current, status: "running", error_message: "", message: "正在从上次进度继续生成…" }
: current
));
try {
const next = await api.retryQuickCreate(job.id);
setJob(next);
setJobId(next.id);
setPollEpoch((value) => value + 1);
rememberQuickCreateJob(next.id);
if (next.status === "queued" || next.status === "running") {
onNotify?.("success", "已从上次进度继续生成");
} else if (next.status === "succeeded") {
onNotify?.("success", "一键成片已生成");
} else {
onNotify?.("error", next.error_message || "继续生成失败,已完成的步骤会保留");
}
onProjectCreated?.();
} catch (error) {
onNotify?.("error", error instanceof Error ? error.message : "继续生成失败");
} finally {
setSubmitting(false);
}
return;
}
await startGeneration();
}
async function startGeneration() {
if (restoring) {
onNotify?.("info", "正在读取任务状态,请稍候");
return;
}
if (!name.trim() || (!images.length && !savedImages.length)) {
onNotify?.("info", "请先填写商品名称并上传商品图片");
return;
}
if (!category) {
onNotify?.("info", "请选择商品品类,脚本会按品类来写");
return;
}
setSubmitting(true);
setJob(null);
setServiceUnavailable(false);
setUnavailableMessage("");
cancelRequestedRef.current = false;
terminalNoticeRef.current = "";
try {
const form = new FormData();
form.append("name", name.trim());
form.append("category", category);
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 || "");
rememberQuickCreateJob(created.id);
onNotify?.("success", "一键成片任务已启动");
onProjectCreated?.();
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
// 后端单飞闸:已有任务在跑。直接接上它,别让用户对着报错干瞪眼。
const running = (error.payload as { inflight?: QuickCreateJob } | undefined)?.inflight;
if (running) {
setJob(running);
setJobId(running.id);
rememberQuickCreateJob(running.id);
}
onNotify?.("info", error.message || "已有一个一键成片正在进行中");
} else 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 applyJobProduct(next: QuickCreateJob) {
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([]);
}
}
function clearDraft() {
setName("");
setCategory("");
setCatMoreOpen(false);
setImages([]);
setSavedImages([]);
setSourceProductId("");
productPrefillDoneRef.current = true;
}
function resetResult() {
setJob(null);
setJobId("");
setCancelling(false);
setConfirmCancel(false);
setServiceUnavailable(false);
setUnavailableMessage("");
completedNoticeRef.current = "";
terminalNoticeRef.current = "";
watchedGeneratingRef.current = false;
clearDraft();
forgetQuickCreateJob();
}
function openProfessional(projectId?: string, status?: string) {
if (!projectId) return;
const released = status === "queued" || status === "running" || !status ? "failed" : status;
quickCreateStatusRef.current?.(projectId, released);
navigate("pipeline", { projectId, forcePipeline: true, quickCreateStatus: released });
}
async function cancelGeneration() {
if (cancelling) return;
setConfirmCancel(false);
cancelRequestedRef.current = true;
if (!jobId) {
setSubmitting(false);
resetResult();
notifyRef.current?.("info", "已取消本次生成");
return;
}
setCancelling(true);
try {
const next = await api.cancelQuickCreate(jobId);
setJob(next);
if (next.project_id) quickCreateStatusRef.current?.(next.project_id, next.status);
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);
}
}
function requestCancelGeneration() {
if (!cancelling) setConfirmCancel(true);
}
const isGenerating = submitting || job?.status === "queued" || job?.status === "running";
const imageDrop = useFileDrop((files) => selectImages(files), { disabled: isGenerating });
const isComplete = job?.status === "succeeded";
const isCancelled = job?.status === "cancelled";
const isFailed = !isGenerating && !isComplete && (job?.status === "failed" || isCancelled || serviceUnavailable);
const reviewBlocked = isReviewFailure(job?.error_message);
const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews];
const imageCount = savedImages.length + images.length;
const canStart = Boolean(name.trim() && category && imageCount && selectedVideoModel) && !reviewBlocked;
const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel) && !reviewBlocked;
const activePhase = submitting ? 0 : Math.max(0, Math.min(PROGRESS_STEPS.length, 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 clipUrls = new Set(videoClips.map((clip) => clip.video_url).filter(Boolean));
const mergedUrl = result?.final_video_url || (result?.video_url && !clipUrls.has(result.video_url) ? result.video_url : "");
const sceneCount = Math.max(1, Math.round(totalDuration / 15));
const videoEstimate = estimateCost(
selectedVideoModel,
{ ratio: aspectRatio, resolution, duration: totalDuration, refs: [] },
billingRates,
);
// 商品理解和基础资产在脚本生成前无法精确报价;按每场约40积分给出透明预估,最终按成功任务结算。
// 故事板下线后每场少一次 image-2 出图,预估相应下调。
const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 40 : sceneCount * 200;
const shellClass = [
"quick-create-shell",
restoring ? "is-restoring" : "",
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={backLabel}><ArrowLeft /></button>
<div><h1>一键成片</h1></div>
</div>
</header>
<div className={shellClass} id="quickCreateShell">
<section
className={`quick-create-panel quick-form-panel${restoring ? " is-locked" : ""}`}
aria-busy={restoring}
inert={restoring}
>
<div className="quick-form-copy">
<h2>告诉我们这是什么商品</h2>
</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="例如:净颜精华、轻醒咖啡" disabled={isGenerating} />
</label>
<div className="quick-field">
<span className="quick-field-label"><span>商品品类</span><small>必选 · 写脚本时会用到</small></span>
<div className={`quick-cat-grid${catMoreOpen ? " is-more-open" : ""}`} role="listbox" aria-label="商品品类">
{PC_CAT_PRIMARY.map((option) => (
<button
type="button"
className={`chip quick-cat-chip${category === option ? " active" : ""}`}
key={option}
disabled={isGenerating}
aria-selected={category === option}
onClick={() => { setCategory(option); setCatMoreOpen(false); }}
>
{option}
</button>
))}
<button
type="button"
className={`chip quick-cat-chip quick-cat-more${catMoreOpen ? " is-open" : ""}`}
disabled={isGenerating}
aria-expanded={catMoreOpen}
onClick={() => setCatMoreOpen((open) => !open)}
>
<span>{PC_CAT_MORE_LABEL}</span>
<ChevronRight />
</button>
{catMoreOpen ? PC_CAT_MORE.map((option) => (
<button
type="button"
className={`chip quick-cat-chip${category === option ? " active" : ""}`}
key={option}
disabled={isGenerating}
aria-selected={category === option}
onClick={() => setCategory(option)}
>
{option}
</button>
)) : null}
</div>
</div>
<div className="quick-field">
<span className="quick-field-label"><span>商品图片</span><small>必填 · 最多9</small></span>
<div
className={`quick-upload${imageCount ? " has-images" : ""}${imageDrop.dragging ? " is-dragover" : ""}`}
{...imageDrop.dropProps}
>
<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-form-footer">
{isGenerating ? (
<button type="button" className="quick-cancel-button" onClick={requestCancelGeneration} disabled={cancelling}>
<X /><span>{cancelling ? "正在取消…" : "取消生成"}</span>
</button>
) : (
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canStart}>
<WandSparkles /><span>{`立即生成视频 · 消耗 ${estimatedPoints} 积分`}</span>
</button>
)}
</div>
</section>
<section className="quick-create-panel quick-status-panel" aria-live="polite">
<div className="quick-state quick-state-restoring" role="status">
<div className="quick-restoring-spinner" aria-hidden="true" />
<h2>正在读取任务状态</h2>
<p>确认有没有正在进行的一键成片,稍候</p>
</div>
<div className="quick-state quick-state-ready">
<div className="quick-ready-orbit"><span className="quick-ready-icon"><Sparkles /></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 && activePhase < PROGRESS_STEPS.length;
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={requestCancelGeneration} 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) => {
const clipUrl = clip.video_url || result?.video_url || "";
return (
<button
key={clip.id}
type="button"
className="quick-video-result-card"
onClick={() => playClip(clipUrl, `第${index + 1}场视频`)}
>
<span className="quick-video-result-thumb">
{clipUrl ? <video src={clipUrl} 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 ? (
mergedUrl ? (
<button type="button" className="secondary-action" onClick={() => playClip(mergedUrl, "完整视频")}>
<Play />播放
</button>
) : (
<button type="button" className="secondary-action" onClick={() => job && openProfessional(job.project_id, job.status)}>
<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
? "已取消本次生成"
: reviewBlocked
? "图片未通过审核"
: job?.phase === "script"
? "本次生成未完成"
: "成片尚未完成"}
</h2>
<p>
{serviceUnavailable
? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。")
: reviewBlocked
? (job?.error_message || "商品图或生成画面未通过内容审核。请更换商品图,或进入专业模式调整后再生成。")
: (job?.error_message || "生成还没走完。点重试会从上次进度继续,已生成的脚本和素材会保留。")}
</p>
<div className="quick-failed-actions">
{reviewBlocked && job?.project_id ? (
<button type="button" className="primary-action" onClick={() => openProfessional(job.project_id, job.status)}>
<SlidersHorizontal />进入专业模式
</button>
) : null}
{canRetry && !serviceUnavailable && !reviewBlocked ? (
<button type="button" className="primary-action" onClick={() => void retryGeneration()}><RefreshCw />重试</button>
) : null}
<button type="button" className={(reviewBlocked && job?.project_id) || (canRetry && !serviceUnavailable) ? "secondary-action" : "primary-action"} onClick={resetResult}>
<RefreshCw />重新开始
</button>
{!reviewBlocked && job?.project_id ? (
<button type="button" className="secondary-action" onClick={() => openProfessional(job.project_id, job.status)}>
<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 cover = historyCover(item);
const videoUrl = historyVideoUrl(item);
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));
const badge = historyBadge(item);
const canPlay = Boolean(videoUrl);
return (
<article key={item.id} className="quick-history-card">
<button
type="button"
className="quick-history-thumb"
onClick={() => playClip(videoUrl, historyTitle(item))}
aria-label={canPlay ? `播放${historyTitle(item)}` : historyTitle(item)}
disabled={!canPlay}
>
{cover ? <img src={cover} alt="" /> : <span className="quick-history-thumb-empty">{canPlay ? null : <Play />}</span>}
{canPlay ? <span className="quick-history-play" aria-hidden="true"><Play /></span> : null}
<small>{formatClock(duration)}</small>
</button>
<div className="quick-history-copy">
<span className={`quick-history-badge${badge === "已完成" ? "" : " is-wait"}`}>{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={() => openProfessional(item.project_id, item.status)}>
<ArrowUpRight />查看项目
</button>
</article>
);
})}
</div>
) : (
<p className="quick-history-empty">还没有完成的一键成片,生成成功后会出现在这里。</p>
)}
</section>
<ConfirmModal
open={confirmCancel}
title="确认取消生成?"
subtitle="当前任务将停止"
detail="取消后,本次一键成片不会继续生成;已完成的商品和素材会保留在专业创作项目中。"
confirmText="确认取消"
icon={<AlertCircle size={16} />}
dismissable={!cancelling}
onCancel={() => { if (!cancelling) setConfirmCancel(false); }}
onConfirm={() => void cancelGeneration()}
/>
{playing ? (
<OverlayPortal>
<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} controls autoPlay playsInline controlsList="nodownload" />
</div>
</div>
</OverlayPortal>
) : null}
</div>
);
}