Merge origin/master into feat/dialog-generator-cancel-generation

This commit is contained in:
OmniAI Developer
2026-06-08 14:46:34 +08:00
76 changed files with 2510 additions and 928 deletions
+104 -23
View File
@@ -12,7 +12,9 @@ import {
SettingOutlined,
SkinOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import "../../styles/pages/ecommerce.css";
import "../../styles/pages/local-theme-parity.css";
import { ossAssets } from "../../data/ossAssets";
import { EcommerceProgressBar } from "./EcommerceProgressBar";
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
@@ -587,6 +589,7 @@ const cloneSetCountOptions: Array<{
{ key: "white", title: "白底图", desc: "白底主图,多角度呈现商品细节" },
{ key: "scene", title: "场景图", desc: "展示商品的生活使用场景和人物搭配" },
];
const cloneSetCountKeys = cloneSetCountOptions.map((option) => option.key);
const defaultCloneSetCounts: Record<CloneSetCountKey, number> = {
selling: 3,
white: 1,
@@ -938,25 +941,58 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [selectedDetailModules, setSelectedDetailModules] = useState<string[]>(defaultDetailModuleIds);
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null);
const productSetRatioOptions = getPlatformRatioOptions(productSetPlatform, productSetOutput);
const hotUploadedRatioOption = cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null;
const baseCloneRatioOptions = getPlatformRatioOptions(platform, cloneOutput);
const cloneRatioOptions = hotUploadedRatioOption
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
: baseCloneRatioOptions;
const productSetLanguageOptions = getPlatformLanguageOptions(productSetPlatform, productSetMarket);
const cloneLanguageOptions = getPlatformLanguageOptions(platform, market);
const detailLanguageOptions = getPlatformLanguageOptions(detailPlatform, detailMarket);
const productSetRatioOptions = useMemo(
() => getPlatformRatioOptions(productSetPlatform, productSetOutput),
[productSetOutput, productSetPlatform],
);
const hotUploadedRatioOption = useMemo(
() => cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null,
[cloneOutput, cloneReferenceImages],
);
const baseCloneRatioOptions = useMemo(
() => getPlatformRatioOptions(platform, cloneOutput),
[cloneOutput, platform],
);
const cloneRatioOptions = useMemo(
() => hotUploadedRatioOption
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
: baseCloneRatioOptions,
[baseCloneRatioOptions, hotUploadedRatioOption],
);
const productSetLanguageOptions = useMemo(
() => getPlatformLanguageOptions(productSetPlatform, productSetMarket),
[productSetMarket, productSetPlatform],
);
const cloneLanguageOptions = useMemo(
() => getPlatformLanguageOptions(platform, market),
[market, platform],
);
const detailLanguageOptions = useMemo(
() => getPlatformLanguageOptions(detailPlatform, detailMarket),
[detailMarket, detailPlatform],
);
const ecommerceMentionImages: MentionImageOption[] = [
...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })),
...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })),
];
const ecommerceVideoImageDataUrls = useMemo(
() => productImages.map((img) => img.src),
[productImages],
);
const ecommerceVideoImageFiles = useMemo(
() => productImages.map((img) => img.file),
[productImages],
);
const selectedProductSetOutput =
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
const productSetPreviewReady = productSetStatus === "done";
const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0);
const cloneSetTotal = useMemo(
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
[cloneSetCounts],
);
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
const canGenerate = (cloneOutput === "video-outfit"
? Boolean(videoOutfitVideoFile && videoOutfitRefFile)
@@ -965,9 +1001,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
const cloneVideoDurationProgress =
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
const cloneVideoDurationStyle: CSSProperties = {
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
} as CSSProperties;
const cloneVideoDurationStyle: CSSProperties = useMemo(
() => ({
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
}) as CSSProperties,
[cloneVideoDurationProgress],
);
const trackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.add(taskId);
@@ -1162,6 +1201,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
};
const clearCloneSetCountHold = () => {
window.removeEventListener("pointerup", clearCloneSetCountHold);
window.removeEventListener("pointercancel", clearCloneSetCountHold);
if (countHoldTimeoutRef.current !== null) {
window.clearTimeout(countHoldTimeoutRef.current);
countHoldTimeoutRef.current = null;
@@ -1276,6 +1317,34 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
requirement,
});
const latestCloneSettingSnapshot = useMemo(
() => createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"),
[
cloneOutput,
platform,
market,
language,
ratio,
cloneSetCounts,
selectedCloneDetailModules,
cloneModelPanelTab,
selectedCloneModelScenes,
cloneModelCustomScene,
cloneModelGender,
cloneModelAge,
cloneModelEthnicity,
cloneModelBody,
cloneModelAppearance,
cloneVideoQuality,
cloneVideoDuration,
cloneVideoSmart,
cloneReferenceMode,
cloneReplicateLevel,
requirement,
cloneSettingName,
],
);
const persistLatestCloneSetting = () => {
const snapshot = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
latestCloneSettingRef.current = snapshot;
@@ -1323,8 +1392,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
};
useEffect(() => {
latestCloneSettingRef.current = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
});
latestCloneSettingRef.current = latestCloneSettingSnapshot;
}, [latestCloneSettingSnapshot]);
useEffect(() => {
const latestSetting = readCloneLatestSetting();
@@ -1631,7 +1700,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const generatedUrls: string[] = [];
const stamp = Date.now();
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
for (const countKey of cloneSetCountKeys) {
if (imageAbortRef.current.current) break;
const count = counts[countKey];
for (let i = 0; i < count; i++) {
@@ -1908,7 +1977,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
platform, ratio, language, market,
{ gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene },
(s) => setTryOnStatus(s as TryOnStatus),
(res) => setTryOnResultImages(res.map((r) => r.src).filter(Boolean)),
(res) => {
const urls: string[] = [];
for (const item of res) {
if (item.src) urls.push(item.src);
}
setTryOnResultImages(urls);
},
);
lastFailedActionRef.current = () => handleTryOnGenerate();
};
@@ -2028,7 +2103,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
productImages.length === 0 ? "请先上传商品原图" : status === "generating" ? "生成中..." : `生成${selectedCloneOutput.label}`;
const setPreviewCards: CloneResult[] = [];
let setIndex = 0;
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
for (const countKey of cloneSetCountKeys) {
const count = cloneSetCounts[countKey];
const info = setCountLabels[countKey];
for (let i = 0; i < count; i++) {
@@ -2043,7 +2118,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const clonePreviewCards: CloneResult[] = [];
let cloneIndex = 0;
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
for (const countKey of cloneSetCountKeys) {
const count = cloneSetCounts[countKey];
const info = setCountLabels[countKey];
for (let i = 0; i < count; i++) {
@@ -2055,6 +2130,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
cloneIndex++;
}
}
const detailSourcePreviewImages = detailProductImages.length
? detailProductImages.reduce<string[]>((urls, item) => {
urls.push(item.src);
return urls;
}, [])
: detailProductSamples;
const cloneBasicSelects: Array<{
key: CloneBasicSelectKey;
label: string;
@@ -2564,7 +2645,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<section className="product-detail-demo-board">
<div className="product-detail-source-stack">
{(detailProductImages.length ? detailProductImages.map((item) => item.src) : detailProductSamples).map((src, index) => (
{detailSourcePreviewImages.map((src, index) => (
<figure key={`${src}-${index}`}>
<img src={src} alt={`商品原图 ${index + 1}`} />
</figure>
@@ -2692,8 +2773,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0 }}>
<EcommerceVideoWorkspace
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
productImageDataUrls={productImages.map((img) => img.src)}
productImageFiles={productImages.map((img) => img.file)}
productImageDataUrls={ecommerceVideoImageDataUrls}
productImageFiles={ecommerceVideoImageFiles}
requirement={requirement}
platform={platform}
aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"}
@@ -8,6 +8,10 @@ import {
TagsOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import "../../styles/pages/ecommerce.css";
import "../../styles/pages/local-theme-parity.css";
import type { WebProjectSummary } from "../../types";
import { useDebounce } from "../../hooks/useDebounce";
import { templateCarouselCases, templateCases, templateCategories, type TemplateCase } from "./ecommerceTemplates";
@@ -1,4 +1,5 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import "../../styles/pages/ecommerce-video.css";
import {
CloseOutlined,
CopyOutlined,
@@ -122,6 +123,7 @@ export default function EcommerceVideoWorkspace({
const [flowZoom, setFlowZoom] = useState(1);
const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false });
const actionNoticeTimerRef = useRef<number | null>(null);
const setView = useAppStore((s) => s.setView);
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
const keepalivePollingStartedRef = useRef(false);
@@ -277,9 +279,23 @@ export default function EcommerceVideoWorkspace({
// Note: keep-alive is NOT cleared on completion — results persist across page switches.
// Only cleared when user explicitly starts a new plan via handlePlan.
useEffect(() => {
return () => {
if (actionNoticeTimerRef.current !== null) {
window.clearTimeout(actionNoticeTimerRef.current);
}
};
}, []);
const showNotice = (msg: string) => {
setActionNotice(msg);
setTimeout(() => setActionNotice(null), 3000);
if (actionNoticeTimerRef.current !== null) {
window.clearTimeout(actionNoticeTimerRef.current);
}
actionNoticeTimerRef.current = window.setTimeout(() => {
actionNoticeTimerRef.current = null;
setActionNotice(null);
}, 3000);
};
const handleDownload = async (url: string) => {
+12 -23
View File
@@ -9,6 +9,7 @@ import {
type AdVideoUserConfig,
} from "../../api/adVideoPlanClient";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { serverRequest } from "../../api/serverConnection";
import { waitForTask } from "../../api/taskSubscription";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
@@ -430,15 +431,6 @@ export interface VideoHistoryListResponse {
offset: number;
}
import { getStoredToken } from "../../api/serverConnection";
const API_BASE = "/api/ai/ecommerce/video-history";
function getAuthHeaders(): Record<string, string> {
const token = getStoredToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
const uploadAssetByUrl = payload.uploadAssetByUrl;
const scenes = await Promise.all(
@@ -486,13 +478,12 @@ export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryP
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
const res = await fetch(API_BASE, {
return serverRequest<{ id: number; createdAt: string }>("ai/ecommerce/video-history", {
method: "POST",
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
body: JSON.stringify(historyPayload),
body: historyPayload,
maxRetries: 0,
fallbackMessage: "Failed to save video history",
});
if (!res.ok) throw new Error("Failed to save video history");
return res.json();
}
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
@@ -511,12 +502,10 @@ export async function fetchVideoHistory(
limit = 20,
offset = 0,
): Promise<VideoHistoryListResponse> {
const res = await fetch(
`${API_BASE}?limit=${limit}&offset=${offset}`,
{ headers: getAuthHeaders() },
);
if (!res.ok) throw new Error("Failed to fetch video history");
const history = (await res.json()) as VideoHistoryListResponse;
const search = new URLSearchParams({ limit: String(limit), offset: String(offset) });
const history = await serverRequest<VideoHistoryListResponse>(`ai/ecommerce/video-history?${search}`, {
fallbackMessage: "Failed to fetch video history",
});
return {
...history,
items: history.items.map(removeTemporaryHistoryUrls),
@@ -524,9 +513,9 @@ export async function fetchVideoHistory(
}
export async function deleteVideoHistory(id: number): Promise<void> {
const res = await fetch(`${API_BASE}/${id}`, {
await serverRequest<void>(`ai/ecommerce/video-history/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
maxRetries: 0,
fallbackMessage: "Failed to delete video history",
});
if (!res.ok) throw new Error("Failed to delete video history");
}