feat: 交互式对话框生成器 + 电商取消生成与上传优化
新增: - 交互式对话框生成器模块(路由、页面、样式、MorePage入口) - 电商模块取消生成功能(任务追踪/取消按钮/中止逻辑) - 视频服务图片上传支持 Blob/dataURL/远程URL 多种来源 优化: - 电商图片上传修复本地 blob 预览图缺少原始文件的问题 - 视频规划管线错误信息改进 - 生成流程中多处增加中止检查点
This commit is contained in:
@@ -59,6 +59,7 @@ interface CloneImageItem {
|
||||
id: string;
|
||||
src: string;
|
||||
name: string;
|
||||
file?: File;
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
@@ -678,6 +679,7 @@ function createObjectImageItems(files: File[], limit: number, prefix: string) {
|
||||
id: `${prefix}-${Date.now()}-${index}`,
|
||||
src: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
file,
|
||||
format: getImageFileFormat(file),
|
||||
}));
|
||||
}
|
||||
@@ -791,6 +793,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const [status, setStatus] = useState<ProductCloneStatus>("idle");
|
||||
const [results, setResults] = useState<CloneResult[]>([]);
|
||||
const imageAbortRef = useRef({ current: false });
|
||||
const activeEcommerceTaskIdsRef = useRef<Set<string>>(new Set());
|
||||
const lastFailedActionRef = useRef<(() => void) | null>(null);
|
||||
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
|
||||
const [modelSource, setModelSource] = useState<TryOnModelSource>("ai");
|
||||
@@ -845,6 +848,30 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
|
||||
} as CSSProperties;
|
||||
|
||||
const trackEcommerceTask = (taskId: string) => {
|
||||
activeEcommerceTaskIdsRef.current.add(taskId);
|
||||
};
|
||||
|
||||
const untrackEcommerceTask = (taskId: string) => {
|
||||
activeEcommerceTaskIdsRef.current.delete(taskId);
|
||||
};
|
||||
|
||||
const handleCancelGenerate = () => {
|
||||
imageAbortRef.current.current = true;
|
||||
const taskIds = Array.from(activeEcommerceTaskIdsRef.current);
|
||||
activeEcommerceTaskIdsRef.current.clear();
|
||||
taskIds.forEach((taskId) => {
|
||||
aiGenerationClient.cancelTask(taskId).catch(() => {});
|
||||
});
|
||||
lastFailedActionRef.current = null;
|
||||
if (productSetStatus === "generating") setProductSetStatus("idle");
|
||||
if (status === "generating") setStatus("idle");
|
||||
if (detailStatus === "generating") setDetailStatus("idle");
|
||||
if (tryOnStatus === "generating") setTryOnStatus("idle");
|
||||
if (tryOnStatus === "modeling") setTryOnStatus("ready");
|
||||
toast.info("\u5df2\u53d6\u6d88\u751f\u6210");
|
||||
};
|
||||
|
||||
const syncRequirementMentionQuery = (value: string, selectionStart: number | null | undefined) => {
|
||||
setRequirementImageMentionQuery(ecommerceMentionImages.length ? getImageMentionQuery(value, selectionStart) : null);
|
||||
};
|
||||
@@ -1305,11 +1332,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const urls: string[] = [];
|
||||
for (const item of images) {
|
||||
try {
|
||||
const resp = await fetch(item.src);
|
||||
const rawBlob = await resp.blob();
|
||||
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
|
||||
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
||||
const dataUrl = await blobToDataUrl(blob);
|
||||
if (!item.file && item.src.startsWith("blob:")) {
|
||||
throw new Error("本地预览图缺少原始文件,无法上传");
|
||||
}
|
||||
const rawBlob = item.file ?? (item.src.startsWith("data:") ? null : await (await fetch(item.src)).blob());
|
||||
const mimeType = normalizeEcommerceImageMime(
|
||||
rawBlob?.type || item.src.match(/^data:([^;,]+)/)?.[1] || "image/png",
|
||||
);
|
||||
const blob = rawBlob ? (rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType })) : null;
|
||||
const dataUrl = item.src.startsWith("data:") ? item.src : await blobToDataUrl(blob!);
|
||||
const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType, scope: "ecommerce-product" });
|
||||
urls.push(url);
|
||||
} catch {
|
||||
@@ -1395,6 +1426,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
setStatusFn("idle");
|
||||
return;
|
||||
}
|
||||
if (imageAbortRef.current.current) {
|
||||
setStatusFn("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const generatedUrls: string[] = [];
|
||||
const stamp = Date.now();
|
||||
@@ -1414,13 +1449,21 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
gridMode: "single",
|
||||
referenceUrls,
|
||||
});
|
||||
trackEcommerceTask(taskId);
|
||||
|
||||
const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId });
|
||||
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
abortRef: imageAbortRef.current,
|
||||
onProgress: () => {},
|
||||
});
|
||||
let resultUrl: string | null = null;
|
||||
try {
|
||||
resultUrl = await waitForTask(taskId, {
|
||||
abortRef: imageAbortRef.current,
|
||||
onProgress: () => {},
|
||||
});
|
||||
} finally {
|
||||
untrackEcommerceTask(taskId);
|
||||
}
|
||||
|
||||
if (imageAbortRef.current.current) break;
|
||||
|
||||
if (resultUrl) {
|
||||
generatedUrls.push(resultUrl);
|
||||
@@ -1432,9 +1475,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (imageAbortRef.current.current) {
|
||||
setStatusFn("idle");
|
||||
return;
|
||||
}
|
||||
setResultFn(generatedUrls);
|
||||
setStatusFn(generatedUrls.some(Boolean) ? "done" : "idle");
|
||||
} catch (err) {
|
||||
if (imageAbortRef.current.current) {
|
||||
setStatusFn("idle");
|
||||
return;
|
||||
}
|
||||
if (err instanceof ServerRequestError && err.status === 402) {
|
||||
setResultFn([]);
|
||||
toast.error("余额不足,请充值后继续");
|
||||
@@ -1465,6 +1516,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
statusFn?.("idle");
|
||||
return;
|
||||
}
|
||||
if (imageAbortRef.current.current) {
|
||||
statusFn?.("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions);
|
||||
const stamp = Date.now();
|
||||
@@ -1477,13 +1532,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
gridMode: "single",
|
||||
referenceUrls,
|
||||
});
|
||||
trackEcommerceTask(taskId);
|
||||
|
||||
const storeId = imageGen.submitTask({ title: `电商${outputKey}图`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId });
|
||||
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
abortRef: imageAbortRef.current,
|
||||
onProgress: () => {},
|
||||
});
|
||||
let resultUrl: string | null = null;
|
||||
try {
|
||||
resultUrl = await waitForTask(taskId, {
|
||||
abortRef: imageAbortRef.current,
|
||||
onProgress: () => {},
|
||||
});
|
||||
} finally {
|
||||
untrackEcommerceTask(taskId);
|
||||
}
|
||||
|
||||
if (imageAbortRef.current.current) {
|
||||
statusFn?.("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
if (resultUrl) {
|
||||
resultFn?.([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
|
||||
@@ -1494,6 +1560,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
|
||||
}
|
||||
} catch (err) {
|
||||
if (imageAbortRef.current.current) {
|
||||
statusFn?.("idle");
|
||||
return;
|
||||
}
|
||||
if (err instanceof ServerRequestError && err.status === 402) {
|
||||
resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
|
||||
toast.error("余额不足,请充值后继续");
|
||||
@@ -1527,21 +1597,38 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
dataUrl: refDataUrl, name: videoOutfitRefFile.name,
|
||||
mimeType: videoOutfitRefFile.type || "image/png", scope: "video-outfit",
|
||||
});
|
||||
if (imageAbortRef.current.current) {
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const { taskId } = await aiGenerationClient.createVideoEditTask({
|
||||
videoUrl: videoAsset.url,
|
||||
referenceUrls: [refAsset.url],
|
||||
prompt: requirement || undefined,
|
||||
});
|
||||
trackEcommerceTask(taskId);
|
||||
|
||||
const { waitForTask } = await import("../../api/taskSubscription");
|
||||
imageAbortRef.current = { current: false };
|
||||
const resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
|
||||
let resultUrl: string | null = null;
|
||||
try {
|
||||
resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
|
||||
} finally {
|
||||
untrackEcommerceTask(taskId);
|
||||
}
|
||||
if (imageAbortRef.current.current) {
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
if (resultUrl) {
|
||||
setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "换装视频" }]);
|
||||
}
|
||||
setStatus("done");
|
||||
} catch (err) {
|
||||
if (imageAbortRef.current.current) {
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
setStatus("failed");
|
||||
toast.error(err instanceof Error ? err.message : "视频换装生成失败");
|
||||
}
|
||||
@@ -1877,6 +1964,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
clampCloneVideoDuration={clampCloneVideoDuration}
|
||||
setCloneVideoSmart={setCloneVideoSmart}
|
||||
handleGenerate={handleGenerate}
|
||||
onCancelGenerate={handleCancelGenerate}
|
||||
formatRatioDisplayValue={formatRatioDisplayValue}
|
||||
setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }}
|
||||
onStartVideoPlan={() => setVideoPlanTrigger((n) => n + 1)}
|
||||
@@ -1910,6 +1998,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
handleDetailAiWrite={handleDetailAiWrite}
|
||||
toggleDetailModule={toggleDetailModule}
|
||||
handleDetailGenerate={handleDetailGenerate}
|
||||
onCancelGenerate={handleCancelGenerate}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1947,6 +2036,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
setSmartScene={setSmartScene}
|
||||
setTryOnRatio={setTryOnRatio}
|
||||
handleTryOnGenerate={handleTryOnGenerate}
|
||||
onCancelGenerate={handleCancelGenerate}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2022,6 +2112,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
{productSetStatus === "generating" ? <LoadingOutlined /> : null}
|
||||
{setPrimaryLabel}
|
||||
</button>
|
||||
{productSetStatus === "generating" ? (
|
||||
<button type="button" className="product-set-floating-submit product-set-floating-submit--cancel" onClick={handleCancelGenerate}>
|
||||
{"\u53d6\u6d88\u751f\u6210"}
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<button type="button" className="product-clone-help" aria-label="帮助">
|
||||
@@ -2373,6 +2468,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
<EcommerceVideoWorkspace
|
||||
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
|
||||
productImageDataUrls={productImages.map((img) => img.src)}
|
||||
productImageFiles={productImages.map((img) => img.file)}
|
||||
requirement={requirement}
|
||||
platform={platform}
|
||||
aspectRatio={ratio.includes("9:16") || ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") || ratio.includes("16:9") ? "16:9" : ratio.includes("3:4") || ratio.includes("3:4") ? "3:4" : "9:16"}
|
||||
|
||||
Reference in New Issue
Block a user