Feat/dialog generator cancel generation #23
@@ -102,9 +102,9 @@ function AppShell({
|
||||
"canvas",
|
||||
"scriptTokens",
|
||||
"tokenUsage",
|
||||
"community",
|
||||
"assets",
|
||||
"more",
|
||||
"assets",
|
||||
"community",
|
||||
];
|
||||
return orderedKeys
|
||||
.map((key) => navItems.find((item) => item.key === key))
|
||||
|
||||
@@ -516,6 +516,18 @@ const formatRatioDisplayValue = (value: string) => {
|
||||
const ratio = value.match(/\d+(?:\.\d+)?\s*[::]\s*\d+(?:\.\d+)?/u)?.[0]?.replace(/\s+/g, "").replace(/:/g, ":") ?? "";
|
||||
return size && ratio ? `${size}\u00a0\u00a0\u00a0${ratio}` : value.replace(/^套图[::]\s*/, "");
|
||||
};
|
||||
/** Extract CSS aspect-ratio from a ratio string like "1000x1000px 1:1" -> "1 / 1" */
|
||||
const parseRatioToAspectCss = (ratioStr: string): string => {
|
||||
const match = ratioStr.match(/(\d+)\s*[::]\s*(\d+)/u);
|
||||
if (!match) return "1 / 1";
|
||||
return `${match[1]} / ${match[2]}`;
|
||||
};
|
||||
/** Normalize ratio display string ("1000×1000px 1:1") to API format ("1:1") */
|
||||
const normalizeRatioForApi = (ratioStr: string): string => {
|
||||
const match = ratioStr.match(/(\d+)\s*[::]\s*(\d+)/u);
|
||||
if (!match) return "1:1";
|
||||
return `${match[1]}:${match[2]}`;
|
||||
};
|
||||
const greatestCommonDivisor = (left: number, right: number): number => {
|
||||
let a = Math.abs(left);
|
||||
let b = Math.abs(right);
|
||||
@@ -866,6 +878,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const [videoOutfitVideoFile, setVideoOutfitVideoFile] = useState<File | null>(null);
|
||||
const [videoOutfitRefFile, setVideoOutfitRefFile] = useState<File | null>(null);
|
||||
const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false);
|
||||
const [previewZoom, setPreviewZoom] = useState(1);
|
||||
const [requirement, setRequirement] = useState("");
|
||||
const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState<string | null>(null);
|
||||
const [cloneSettingName, setCloneSettingName] = useState("新建创作");
|
||||
@@ -1594,6 +1607,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const stamp = Date.now();
|
||||
|
||||
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
|
||||
if (imageAbortRef.current.current) break;
|
||||
const count = counts[countKey];
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (imageAbortRef.current.current) break;
|
||||
@@ -1603,7 +1617,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const { taskId } = await aiGenerationClient.createImageTask({
|
||||
model: IMAGE_MODEL,
|
||||
prompt: fullPrompt,
|
||||
ratio: pRatio,
|
||||
ratio: normalizeRatioForApi(pRatio),
|
||||
quality: pRatio.includes("720") ? "720P" : "1080P",
|
||||
gridMode: "single",
|
||||
referenceUrls,
|
||||
@@ -1640,7 +1654,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
return;
|
||||
}
|
||||
setResultFn(generatedUrls);
|
||||
setStatusFn(generatedUrls.some(Boolean) ? "done" : "idle");
|
||||
setStatusFn(generatedUrls.some(Boolean) ? "done" : "failed");
|
||||
} catch (err) {
|
||||
if (imageAbortRef.current.current) {
|
||||
setStatusFn("idle");
|
||||
@@ -1687,7 +1701,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const { taskId } = await aiGenerationClient.createImageTask({
|
||||
model: IMAGE_MODEL,
|
||||
prompt,
|
||||
ratio: pRatio,
|
||||
ratio: normalizeRatioForApi(pRatio),
|
||||
quality: pRatio.includes("720") ? "720P" : "1080P",
|
||||
gridMode: "single",
|
||||
referenceUrls,
|
||||
@@ -2010,7 +2024,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
clonePreviewCards.push({
|
||||
id: `${countKey}-${i}`,
|
||||
src: results[cloneIndex]?.src || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "",
|
||||
src: productSetResultImages[cloneIndex] || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "",
|
||||
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
|
||||
});
|
||||
cloneIndex++;
|
||||
@@ -2311,6 +2325,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
<span>
|
||||
上传商品图,AI 即刻生成 <b>符合多电商平台规范</b> 的高转化率商品素材。
|
||||
</span>
|
||||
<div className="clone-ai-preview-zoom">
|
||||
<button type="button" onClick={() => setPreviewZoom((z) => Math.max(0.25, z - 0.1))} disabled={previewZoom <= 0.25} aria-label="缩小">−</button>
|
||||
<span>{Math.round(previewZoom * 100)}%</span>
|
||||
<button type="button" onClick={() => setPreviewZoom((z) => Math.min(2, z + 0.1))} disabled={previewZoom >= 2} aria-label="放大">+</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{cloneOutput === "video" ? (
|
||||
@@ -2433,8 +2452,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
) : (
|
||||
<>
|
||||
{status === "done" ? (
|
||||
<div className="clone-ai-preview-zoom-wrap" style={{ zoom: previewZoom }}>
|
||||
<section className="clone-ai-preview-showcase" aria-label="生成结果">
|
||||
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
|
||||
<button type="button" className="clone-ai-main-result" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
|
||||
<img src={productImages[0]?.src ?? (cloneOutput === "set" ? clonePreviewCards[0].src : results[0]?.src ?? "")} alt="上传商品原图" />
|
||||
<span>原图素材</span>
|
||||
</button>
|
||||
@@ -2442,19 +2462,20 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
<div className="clone-ai-result-grid result-reveal">
|
||||
{cloneOutput === "set" ? (
|
||||
clonePreviewCards.map((card) => (
|
||||
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
|
||||
<button key={card.id} type="button" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(card)}>
|
||||
<img src={card.src} alt={card.label} />
|
||||
<span>{card.label}</span>
|
||||
</button>
|
||||
))
|
||||
) : results[0]?.src ? (
|
||||
<button type="button" onClick={() => openProductSetPreview(results[0])}>
|
||||
<button type="button" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(results[0])}>
|
||||
<img src={results[0].src} alt={selectedCloneOutput.label} />
|
||||
<span>{selectedCloneOutput.label}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<section className="clone-ai-empty-state" aria-live="polite">
|
||||
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
|
||||
@@ -2643,7 +2664,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
) : null}
|
||||
|
||||
{isSetTool ? setPreview : isDetail ? detailPreview : isTryOn ? tryOnPreview : isCloneTool ? (cloneOutput === "video" ? (
|
||||
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<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)}
|
||||
|
||||
@@ -119,6 +119,7 @@ export default function EcommerceVideoWorkspace({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
|
||||
const [flowZoom, setFlowZoom] = useState(1);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const renderAbortRef = useRef({ current: false });
|
||||
const setView = useAppStore((s) => s.setView);
|
||||
@@ -600,6 +601,12 @@ export default function EcommerceVideoWorkspace({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="ecom-video-flowbar__zoom">
|
||||
<button type="button" onClick={() => setFlowZoom((z) => Math.max(0.25, z - 0.1))} disabled={flowZoom <= 0.25} aria-label="缩小">−</button>
|
||||
<span>{Math.round(flowZoom * 100)}%</span>
|
||||
<button type="button" onClick={() => setFlowZoom((z) => Math.min(2, z + 0.1))} disabled={flowZoom >= 2} aria-label="放大">+</button>
|
||||
</div>
|
||||
|
||||
<div className="ecom-video-flowbar__actions">
|
||||
{onOpenHistory ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" onClick={onOpenHistory} title="生成记录">
|
||||
@@ -644,9 +651,10 @@ export default function EcommerceVideoWorkspace({
|
||||
|
||||
{/* ── Flow canvas ──────────────────────────────────── */}
|
||||
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
|
||||
<div style={{ zoom: flowZoom, flexShrink: 0, display: "flex", alignItems: "flex-start", justifyContent: "center", minWidth: "max-content" }}>
|
||||
{!sourceImage ? (
|
||||
<div className="ecom-video-empty">
|
||||
<span>上传商品图并点击“一键策划”开始</span>
|
||||
<span>上传商品图并点击"一键策划"开始</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ecom-video-tree">
|
||||
@@ -762,6 +770,7 @@ export default function EcommerceVideoWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Delivery dock ────────────────────────────── */}
|
||||
{primaryVideo ? (
|
||||
|
||||
@@ -231,13 +231,13 @@ export const MODE_OPTIONS: WorkbenchOption[] = (Object.keys(MODE_META) as Workbe
|
||||
}));
|
||||
|
||||
export const IMAGE_MODEL_OPTIONS: WorkbenchOption[] = [
|
||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro 4K · 0.20 积分" },
|
||||
{ value: "wan2.7-image", label: "wan 2.7 · 0.20 积分" },
|
||||
{ value: "gpt-image-2", label: "GPT-Image-2 · 0.20 积分" },
|
||||
{ value: "gpt-image-2-vip", label: "GPT-Image-2 VIP · 0.20 积分" },
|
||||
{ value: "nano-banana-pro", label: "Nano Banana Pro · 0.20 积分" },
|
||||
{ value: "nano-banana-2", label: "Nano Banana 2 · 0.20 积分" },
|
||||
{ value: "nano-banana-fast", label: "Nano Banana · 0.20 积分" },
|
||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro 4K" },
|
||||
{ value: "wan2.7-image", label: "wan 2.7" },
|
||||
{ value: "gpt-image-2", label: "GPT-Image-2" },
|
||||
{ value: "gpt-image-2-vip", label: "GPT-Image-2 VIP" },
|
||||
{ value: "nano-banana-pro", label: "Nano Banana Pro" },
|
||||
{ value: "nano-banana-2", label: "Nano Banana 2" },
|
||||
{ value: "nano-banana-fast", label: "Nano Banana" },
|
||||
];
|
||||
|
||||
export const VIDEO_MODEL_OPTIONS: WorkbenchOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({ ...option }));
|
||||
|
||||
@@ -947,4 +947,3 @@
|
||||
height: 100%;
|
||||
cursor: crosshair;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
align-content: initial;
|
||||
justify-items: initial;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
background: #0e1014;
|
||||
scrollbar-color: #353b45 #0e1014;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.product-clone-page[data-tool="clone"] .product-clone-preview--video::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ecom-video-workspace {
|
||||
@@ -20,6 +24,11 @@
|
||||
overflow: hidden;
|
||||
background: #0e1014;
|
||||
color: #e5ebf4;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.ecom-video-workspace::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ecom-video-flowbar {
|
||||
@@ -112,6 +121,42 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── Flowbar zoom controls ─────────────────────────── */
|
||||
.ecom-video-flowbar__zoom {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.ecom-video-flowbar__zoom button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid #2c3038;
|
||||
border-radius: 6px;
|
||||
background: #1a1d24;
|
||||
color: #8890a0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: border-color 150ms ease, color 150ms ease;
|
||||
}
|
||||
.ecom-video-flowbar__zoom button:hover:not(:disabled) {
|
||||
border-color: #00ff88;
|
||||
color: #00ff88;
|
||||
}
|
||||
.ecom-video-flowbar__zoom button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ecom-video-flowbar__zoom span {
|
||||
min-width: 38px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #6a7282;
|
||||
}
|
||||
|
||||
.ecom-video-flowbar__error {
|
||||
max-width: min(260px, 28vw);
|
||||
overflow: hidden;
|
||||
@@ -181,8 +226,13 @@
|
||||
background: #101318;
|
||||
padding: 32px 40px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.ecom-video-flow-canvas::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ecom-video-flow-map {
|
||||
@@ -418,22 +468,23 @@
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border: 1px solid #3d3020;
|
||||
border-radius: 8px;
|
||||
background: #15181f;
|
||||
color: #ffe1ad;
|
||||
border: 1px solid #00cc6a;
|
||||
border-radius: 9px;
|
||||
background: #00ff88;
|
||||
color: #06130d;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
border-color 150ms ease,
|
||||
background-color 150ms ease;
|
||||
filter 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.ecom-video-flow-dock button:hover {
|
||||
border-color: #4d3a1a;
|
||||
background: #241c12;
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.08);
|
||||
box-shadow: 0 2px 12px rgba(0, 255, 136, 0.25);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.ecom-video-flow-notice {
|
||||
|
||||
@@ -2792,12 +2792,18 @@
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
align-content: center;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
align-content: safe center;
|
||||
justify-items: center;
|
||||
gap: 22px;
|
||||
background: #101115;
|
||||
padding: 92px 46px 142px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-header {
|
||||
@@ -2828,6 +2834,50 @@
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
/* ── Preview zoom controls ─────────────────────────── */
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-zoom button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #2c3038;
|
||||
border-radius: 6px;
|
||||
background: #1b1d23;
|
||||
color: #a0a8b8;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: border-color 150ms ease, color 150ms ease;
|
||||
}
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-zoom button:hover:not(:disabled) {
|
||||
border-color: #00ff88;
|
||||
color: #00ff88;
|
||||
}
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-zoom button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-zoom span {
|
||||
min-width: 42px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #758096;
|
||||
}
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-zoom-wrap {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-empty-state {
|
||||
display: grid;
|
||||
width: min(100%, 600px);
|
||||
@@ -2935,7 +2985,8 @@
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-main-result {
|
||||
height: 440px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-result-grid {
|
||||
@@ -2945,12 +2996,12 @@
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button {
|
||||
height: 210px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button:first-child {
|
||||
grid-column: 1 / -1;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-main-result span,
|
||||
|
||||
@@ -8,27 +8,27 @@ export const ENTERPRISE_WANXIANG_I2V_MODEL = "wan2.7-i2v";
|
||||
export const ENTERPRISE_VIDEO_MODEL_OPTIONS = [
|
||||
{
|
||||
value: HAPPY_HORSE_UI_MODEL,
|
||||
label: "HappyHorse 1.0 · 0.72 积分/秒起",
|
||||
label: "HappyHorse 1.0",
|
||||
description: "自动匹配文生视频、首帧图生视频或参考图生视频",
|
||||
},
|
||||
{
|
||||
value: VIDU_UI_MODEL,
|
||||
label: "Vidu Q3 Turbo · 0.40 积分/秒起",
|
||||
label: "Vidu Q3 Turbo",
|
||||
description: "自动匹配文生视频或图生视频,支持16秒",
|
||||
},
|
||||
{
|
||||
value: PIXVERSE_UI_MODEL,
|
||||
label: "PixVerse V6 · 0.40 积分/秒起",
|
||||
label: "PixVerse V6",
|
||||
description: "自动匹配文生视频或图生视频,擅长动作特效",
|
||||
},
|
||||
{
|
||||
value: ENTERPRISE_WANXIANG_I2V_MODEL,
|
||||
label: "万相 图生视频 · 0.60 积分/秒起",
|
||||
label: "万相 图生视频",
|
||||
description: "图生视频模型,支持首帧图驱动",
|
||||
},
|
||||
{
|
||||
value: ENTERPRISE_KLING_MODEL,
|
||||
label: "Kling V3 Omni · 0.60 积分/秒起",
|
||||
label: "Kling V3 Omni",
|
||||
description: "支持文生视频、图生视频及多模态参考生成",
|
||||
},
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user