Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1a3a84d06 | |||
| f1be7d8d66 | |||
| c6583d1881 | |||
| 047c66ed88 | |||
| d82a49d96c | |||
| 91f2f9dfe8 | |||
| 1eca1d702b | |||
| ff4d40bcf6 | |||
| c8e0839fc8 | |||
| 20c3772cbb | |||
| 0543766bd6 | |||
| 8269e32779 | |||
| 94711dc4cf | |||
| fdc48d2e65 | |||
| 39a3edde1c | |||
| c748d1e3ba | |||
| 2e87adc957 | |||
| 0958a9870e | |||
| bdedad0b90 | |||
| a9f707525d | |||
| d8cbf0d182 | |||
| 3a36174041 | |||
| 2b69a82aea | |||
| e460901ad7 | |||
| b416e96e99 | |||
| 3b72455062 | |||
| 526ad490f7 | |||
| 4993f6eeec | |||
| 3321b96e29 | |||
| 120fc2e70c | |||
| 79f220dbbf | |||
| c1c7cb3cc7 | |||
| b67f2e7601 | |||
| 003c41ddcc | |||
| f056547160 | |||
| 643595bede | |||
| de3eb1d06a | |||
| f929be30ed | |||
| 66b761314b | |||
| 62fcf461b6 | |||
| 9a9c7eb86d | |||
| 6dd292207f | |||
| 8985deea0a | |||
| f30e585cfa | |||
| 45e6534ee1 | |||
| 48262d6233 | |||
| 062c8b3445 |
@@ -0,0 +1,43 @@
|
||||
# 自动检测文本文件并统一换行符
|
||||
* text=auto eol=lf
|
||||
|
||||
# 源码强制使用 LF(跨平台一致)
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.cjs text eol=lf
|
||||
*.json text eol=lf
|
||||
*.css text eol=lf
|
||||
*.html text eol=lf
|
||||
*.md text eol=lf
|
||||
*.svg text eol=lf
|
||||
|
||||
# 配置类(统一 LF)
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.conf text eol=lf
|
||||
|
||||
# Windows 专用脚本保持 CRLF
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# 二进制文件,不做换行符转换
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.otf binary
|
||||
*.mp4 binary
|
||||
*.mp3 binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.gz binary
|
||||
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -0,0 +1 @@
|
||||
npm run css:audit
|
||||
@@ -0,0 +1,10 @@
|
||||
// lint-staged 配置 —— 配合 husky pre-commit 使用
|
||||
//
|
||||
// 当前只运行 tsc 全量类型检查(tsc 不接受单文件增量检查),
|
||||
// 未来可扩展 ESLint / Prettier / stylelint 等按文件的检查。
|
||||
//
|
||||
// 函数语法返回原始命令字符串,lint-staged 不会追加文件名。
|
||||
|
||||
export default {
|
||||
"*.{ts,tsx}": () => "tsc --noEmit",
|
||||
};
|
||||
Generated
+3459
File diff suppressed because it is too large
Load Diff
+14
-4
@@ -7,20 +7,30 @@
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||
"type-check": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"css:audit": "node scripts/css-audit.mjs",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "5.3.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"scheduler": "0.23.0",
|
||||
"zustand": "5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0",
|
||||
"@types/react": "18.2.55",
|
||||
"@types/react-dom": "18.2.18",
|
||||
"@vitejs/plugin-react": "4.2.1",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.7",
|
||||
"typescript": "5.3.3",
|
||||
"vite": "5.1.0",
|
||||
"vite-plugin-compression2": "2.5.3"
|
||||
"vite-plugin-compression2": "2.5.3",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// CSS 健康度审计脚本。
|
||||
// 用法: npm run css:audit
|
||||
// 输出每个 CSS 文件的行数、选择器数、!important 数、@media 数,
|
||||
// 以及 !important 密度(每 100 行的 !important 数)。
|
||||
// 用于建立基线、跟踪 CSS 瘦身进度、防止 !important 回潮。
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "styles");
|
||||
const REPORT = [];
|
||||
|
||||
function scanCssFile(filePath) {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const lines = content.split(/\r?\n/).length;
|
||||
const selectors = (content.match(/\{/g) || []).length;
|
||||
const important = (content.match(/!important/g) || []).length;
|
||||
const media = (content.match(/@media/g) || []).length;
|
||||
const density = lines > 0 ? ((important / lines) * 100).toFixed(1) : "0";
|
||||
return { lines, selectors, important, media, density };
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
walk(full);
|
||||
} else if (entry.endsWith(".css")) {
|
||||
const rel = relative(ROOT, full).replace(/\\/g, "/");
|
||||
REPORT.push({ file: rel, ...scanCssFile(full) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(ROOT);
|
||||
|
||||
// Sort by !important count descending to surface the worst offenders.
|
||||
REPORT.sort((a, b) => b.important - a.important);
|
||||
|
||||
const totals = REPORT.reduce(
|
||||
(acc, r) => {
|
||||
acc.lines += r.lines;
|
||||
acc.selectors += r.selectors;
|
||||
acc.important += r.important;
|
||||
acc.media += r.media;
|
||||
return acc;
|
||||
},
|
||||
{ lines: 0, selectors: 0, important: 0, media: 0 },
|
||||
);
|
||||
|
||||
const pad = (s, n) => String(s).padEnd(n);
|
||||
const num = (s, n) => String(s).padStart(n);
|
||||
|
||||
console.log("\nCSS Audit Report — src/styles/\n");
|
||||
console.log(
|
||||
`${pad("File", 52)} ${num("Lines", 7)} ${num("Sel", 6)} ${num("!imp", 7)} ${num("@media", 7)} imp/100ln`,
|
||||
);
|
||||
console.log("-".repeat(92));
|
||||
for (const r of REPORT) {
|
||||
console.log(
|
||||
`${pad(r.file, 52)} ${num(r.lines, 7)} ${num(r.selectors, 6)} ${num(r.important, 7)} ${num(r.media, 7)} ${r.density}`,
|
||||
);
|
||||
}
|
||||
console.log("-".repeat(92));
|
||||
console.log(
|
||||
`${pad("TOTAL", 52)} ${num(totals.lines, 7)} ${num(totals.selectors, 6)} ${num(totals.important, 7)} ${num(totals.media, 7)} ${((totals.important / totals.lines) * 100).toFixed(1)}`,
|
||||
);
|
||||
console.log("");
|
||||
|
||||
// Exit non-zero if total !important exceeds a budget threshold.
|
||||
// Current baseline: ~7795. Set budget slightly above to allow incremental work
|
||||
// while preventing uncontrolled growth.
|
||||
const IMPORTANT_BUDGET = 7820;
|
||||
if (totals.important > IMPORTANT_BUDGET) {
|
||||
console.error(
|
||||
`FAIL: !important count ${totals.important} exceeds budget ${IMPORTANT_BUDGET}. ` +
|
||||
`Run with --no-important-check to bypass (not recommended).`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(
|
||||
`OK: !important count ${totals.important} within budget ${IMPORTANT_BUDGET} ` +
|
||||
`(headroom ${IMPORTANT_BUDGET - totals.important}).`,
|
||||
);
|
||||
}
|
||||
+5
-1
@@ -350,7 +350,10 @@ function App() {
|
||||
|
||||
return (
|
||||
<div className="ecommerce-standalone web-shell" data-theme="dark" data-ui-theme="dark-green" data-view="ecommerce">
|
||||
<header className="ecommerce-standalone__topbar">
|
||||
<header
|
||||
className="ecommerce-standalone__topbar"
|
||||
style={{ background: "transparent !important", border: "none !important", boxShadow: "none !important", backdropFilter: "none !important" }}
|
||||
>
|
||||
<button type="button" className="ecommerce-standalone__brand" onClick={handleOpenWorkspace}>
|
||||
<span className="ecommerce-standalone__logo" aria-hidden="true">
|
||||
<img src="https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png" alt="" />
|
||||
@@ -366,6 +369,7 @@ function App() {
|
||||
<button
|
||||
type="button"
|
||||
className="ecommerce-profile-menu__trigger"
|
||||
style={{ background: "transparent !important", border: "none !important", boxShadow: "none !important" }}
|
||||
onClick={() => setProfileMenuOpen((open) => !open)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={profileMenuOpen}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./aiGenerationClient.ts";
|
||||
@@ -1,12 +1,18 @@
|
||||
import {
|
||||
buildApiUrl,
|
||||
buildAuthHeaders,
|
||||
isRecord,
|
||||
readJsonResponse,
|
||||
serverRequest,
|
||||
throwResponseError,
|
||||
} from "./serverConnection";
|
||||
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
||||
import {
|
||||
parseAiTaskStatus,
|
||||
parseAiTaskStatusList,
|
||||
parseImageTaskCreateResponse,
|
||||
parseSseTaskFrame,
|
||||
parseTaskCreateResponse,
|
||||
} from "./dtoParsers";
|
||||
import type { WebGenerationPreviewTask } from "../types";
|
||||
|
||||
export interface ImageGenInput {
|
||||
@@ -190,13 +196,6 @@ function parseContentDispositionFilename(value: string | null): string | undefin
|
||||
return plainMatch?.[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
function extractTaskList(payload: unknown): AiTaskStatus[] {
|
||||
if (Array.isArray(payload)) return payload as AiTaskStatus[];
|
||||
if (!isRecord(payload)) return [];
|
||||
const rows = payload.tasks ?? payload.items;
|
||||
return Array.isArray(rows) ? (rows as AiTaskStatus[]) : [];
|
||||
}
|
||||
|
||||
function getStoredSessionRole(): string {
|
||||
try {
|
||||
if (typeof window === "undefined") return "";
|
||||
@@ -251,67 +250,73 @@ export const aiGenerationClient = {
|
||||
projectId: input.projectId,
|
||||
conversationId: input.conversationId,
|
||||
});
|
||||
const payload = await serverRequest<ImageTaskCreateResponse>("ai/image", {
|
||||
const payload = await serverRequest<unknown>("ai/image", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Image generation request failed",
|
||||
});
|
||||
if (payload.providerDebug) {
|
||||
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
|
||||
const parsed = parseImageTaskCreateResponse(payload);
|
||||
if (parsed.providerDebug) {
|
||||
emitImageRouteDebug("[ai/image-provider-debug]", parsed.providerDebug as Record<string, unknown>);
|
||||
}
|
||||
return payload;
|
||||
return parsed;
|
||||
},
|
||||
|
||||
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
|
||||
return serverRequest<{ taskId: string }>("ai/video", {
|
||||
const payload = await serverRequest<unknown>("ai/video", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Video generation request failed",
|
||||
});
|
||||
return parseTaskCreateResponse(payload);
|
||||
},
|
||||
|
||||
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
|
||||
return serverRequest<{ taskId: string }>("ai/video/super-resolve", {
|
||||
const payload = await serverRequest<unknown>("ai/video/super-resolve", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Video super-resolution request failed",
|
||||
});
|
||||
return parseTaskCreateResponse(payload);
|
||||
},
|
||||
|
||||
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
|
||||
return serverRequest<{ taskId: string }>("ai/video/erase-subtitles", {
|
||||
const payload = await serverRequest<unknown>("ai/video/erase-subtitles", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Subtitle removal request failed",
|
||||
});
|
||||
return parseTaskCreateResponse(payload);
|
||||
},
|
||||
|
||||
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
|
||||
return serverRequest<{ taskId: string }>("ai/image/super-resolve", {
|
||||
const payload = await serverRequest<unknown>("ai/image/super-resolve", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Image super-resolution request failed",
|
||||
});
|
||||
return parseTaskCreateResponse(payload);
|
||||
},
|
||||
|
||||
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
|
||||
return serverRequest<{ taskId: string }>("ai/image/edit", {
|
||||
const payload = await serverRequest<unknown>("ai/image/edit", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Image edit request failed",
|
||||
});
|
||||
return parseTaskCreateResponse(payload);
|
||||
},
|
||||
|
||||
async cancelTask(taskId: string): Promise<void> {
|
||||
@@ -328,10 +333,11 @@ export const aiGenerationClient = {
|
||||
},
|
||||
|
||||
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
|
||||
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
|
||||
const payload = await serverRequest<unknown>(`ai/tasks/${taskId}`, {
|
||||
timeoutMs: TASK_STATUS_TIMEOUT_MS,
|
||||
fallbackMessage: "Task status request failed",
|
||||
});
|
||||
return parseAiTaskStatus(payload);
|
||||
},
|
||||
|
||||
async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> {
|
||||
@@ -361,7 +367,7 @@ export const aiGenerationClient = {
|
||||
const payload = await serverRequest<unknown>(`ai/tasks${search.toString() ? `?${search}` : ""}`, {
|
||||
fallbackMessage: "Task history request failed",
|
||||
});
|
||||
return extractTaskList(payload).map(toPreviewTask);
|
||||
return parseAiTaskStatusList(payload).map(toPreviewTask);
|
||||
} catch (error) {
|
||||
if (isOptionalApiRouteMissing(error)) {
|
||||
taskHistoryRouteMissing = true;
|
||||
@@ -451,7 +457,7 @@ export const aiGenerationClient = {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
onUpdate(data);
|
||||
onUpdate(parseSseTaskFrame(data));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./apiErrorUtils.ts";
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseAiTaskStatus,
|
||||
parseTaskCreateResponse,
|
||||
parseImageTaskCreateResponse,
|
||||
parseAiTaskStatusList,
|
||||
parseSseTaskFrame,
|
||||
} from "./dtoParsers";
|
||||
|
||||
describe("parseAiTaskStatus", () => {
|
||||
it("parses a well-formed camelCase DTO", () => {
|
||||
const result = parseAiTaskStatus({
|
||||
taskId: "task-1",
|
||||
type: "video",
|
||||
status: "running",
|
||||
progress: 42,
|
||||
resultUrl: "https://example.com/r.mp4",
|
||||
error: null,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:01:00Z",
|
||||
});
|
||||
expect(result.taskId).toBe("task-1");
|
||||
expect(result.type).toBe("video");
|
||||
expect(result.status).toBe("running");
|
||||
expect(result.progress).toBe(42);
|
||||
expect(result.resultUrl).toBe("https://example.com/r.mp4");
|
||||
});
|
||||
|
||||
it("tolerates snake_case field names", () => {
|
||||
const result = parseAiTaskStatus({
|
||||
task_id: "task-2",
|
||||
result_url: "https://example.com/x.png",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
expect(result.taskId).toBe("task-2");
|
||||
expect(result.resultUrl).toBe("https://example.com/x.png");
|
||||
});
|
||||
|
||||
it("falls back to safe defaults for missing fields", () => {
|
||||
const result = parseAiTaskStatus({});
|
||||
expect(result.taskId).toBe("");
|
||||
expect(result.type).toBe("image");
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.progress).toBe(0);
|
||||
expect(result.resultUrl).toBeNull();
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unknown status/type values", () => {
|
||||
const result = parseAiTaskStatus({ status: "weird", type: "audio" });
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.type).toBe("image");
|
||||
});
|
||||
|
||||
it("clamps progress to [0, 100]", () => {
|
||||
expect(parseAiTaskStatus({ progress: 150 }).progress).toBe(100);
|
||||
expect(parseAiTaskStatus({ progress: -10 }).progress).toBe(0);
|
||||
expect(parseAiTaskStatus({ progress: "not-a-number" }).progress).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves numeric conversationId and nulls others", () => {
|
||||
expect(parseAiTaskStatus({ conversationId: 7 }).conversationId).toBe(7);
|
||||
expect(parseAiTaskStatus({ conversation_id: 9 }).conversationId).toBe(9);
|
||||
expect(parseAiTaskStatus({ conversationId: "nope" }).conversationId).toBeNull();
|
||||
expect(parseAiTaskStatus({}).conversationId).toBeNull();
|
||||
});
|
||||
|
||||
it("returns empty string for a non-record payload", () => {
|
||||
const result = parseAiTaskStatus("garbage");
|
||||
expect(result.taskId).toBe("");
|
||||
expect(result.status).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTaskCreateResponse", () => {
|
||||
it("extracts taskId from a create response", () => {
|
||||
expect(parseTaskCreateResponse({ taskId: "abc" }).taskId).toBe("abc");
|
||||
expect(parseTaskCreateResponse({ task_id: "def" }).taskId).toBe("def");
|
||||
expect(parseTaskCreateResponse({ id: "ghi" }).taskId).toBe("ghi");
|
||||
});
|
||||
|
||||
it("throws when taskId is missing", () => {
|
||||
expect(() => parseTaskCreateResponse({})).toThrow();
|
||||
expect(() => parseTaskCreateResponse({ taskId: "" })).toThrow();
|
||||
expect(() => parseTaskCreateResponse({ taskId: " " })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseImageTaskCreateResponse", () => {
|
||||
it("includes providerDebug when present", () => {
|
||||
const result = parseImageTaskCreateResponse({
|
||||
taskId: "img-1",
|
||||
providerDebug: {
|
||||
requestedModel: "gpt-image",
|
||||
effectiveModel: "dall-e-3",
|
||||
route: ["primary", "fallback"],
|
||||
candidates: [{ provider: "openai", model: "dall-e-3" }],
|
||||
},
|
||||
});
|
||||
expect(result.taskId).toBe("img-1");
|
||||
expect(result.providerDebug?.effectiveModel).toBe("dall-e-3");
|
||||
expect(result.providerDebug?.route).toEqual(["primary", "fallback"]);
|
||||
expect(result.providerDebug?.candidates?.[0]?.model).toBe("dall-e-3");
|
||||
});
|
||||
|
||||
it("omits providerDebug when absent", () => {
|
||||
const result = parseImageTaskCreateResponse({ taskId: "img-2" });
|
||||
expect(result.taskId).toBe("img-2");
|
||||
expect(result.providerDebug).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tolerates snake_case providerDebug fields", () => {
|
||||
const result = parseImageTaskCreateResponse({
|
||||
taskId: "img-3",
|
||||
provider_debug: { requested_model: "x", primary_provider: "openai" },
|
||||
});
|
||||
expect(result.providerDebug?.requestedModel).toBe("x");
|
||||
expect(result.providerDebug?.primaryProvider).toBe("openai");
|
||||
});
|
||||
|
||||
it("throws when taskId missing even if providerDebug present", () => {
|
||||
expect(() => parseImageTaskCreateResponse({ providerDebug: {} })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAiTaskStatusList", () => {
|
||||
it("parses a bare array", () => {
|
||||
const result = parseAiTaskStatusList([{ taskId: "a" }, { taskId: "b" }]);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].taskId).toBe("a");
|
||||
});
|
||||
|
||||
it("parses an envelope { tasks: [...] }", () => {
|
||||
const result = parseAiTaskStatusList({ tasks: [{ taskId: "a" }, { task_id: "b" }] });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].taskId).toBe("b");
|
||||
});
|
||||
|
||||
it("parses an envelope { items: [...] }", () => {
|
||||
const result = parseAiTaskStatusList({ items: [{ taskId: "a" }] });
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops rows with no taskId rather than crashing", () => {
|
||||
const result = parseAiTaskStatusList([{ taskId: "keep" }, { status: "running" }, {}]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].taskId).toBe("keep");
|
||||
});
|
||||
|
||||
it("returns empty array for non-array non-record payload", () => {
|
||||
expect(parseAiTaskStatusList(null)).toEqual([]);
|
||||
expect(parseAiTaskStatusList("nope")).toEqual([]);
|
||||
expect(parseAiTaskStatusList({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSseTaskFrame", () => {
|
||||
it("parses a well-formed SSE frame", () => {
|
||||
const frame = parseSseTaskFrame({
|
||||
taskId: "sse-1",
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
resultUrl: "https://example.com/done.png",
|
||||
});
|
||||
expect(frame.taskId).toBe("sse-1");
|
||||
expect(frame.status).toBe("completed");
|
||||
expect(frame.progress).toBe(100);
|
||||
expect(frame.resultUrl).toBe("https://example.com/done.png");
|
||||
});
|
||||
|
||||
it("clamps progress and rejects unknown status", () => {
|
||||
const frame = parseSseTaskFrame({ taskId: "sse-2", status: "oops", progress: 999 });
|
||||
expect(frame.status).toBe("failed");
|
||||
expect(frame.progress).toBe(100);
|
||||
});
|
||||
|
||||
it("handles a non-object payload", () => {
|
||||
const frame = parseSseTaskFrame("garbage");
|
||||
expect(frame.taskId).toBe("");
|
||||
expect(frame.status).toBe("failed");
|
||||
expect(frame.progress).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
// DTO 解析层:把后端返回的 unknown 安全地解析成强类型 view model。
|
||||
// 所有从 serverRequest / SSE / localStorage 进入前端状态的 DTO 都应经过这里的 parser,
|
||||
// 避免 as unknown as / as T 这类静默断言在后端变形时把 undefined/错误类型传到 UI。
|
||||
//
|
||||
// helper 与 keyServerClient 里的 toNumber/toStringValue 同构,为避免改动 keyServerClient
|
||||
// 暂在此自带一份;后续可统一到共享 dtoHelpers 模块。
|
||||
|
||||
import { isRecord } from "./serverConnection";
|
||||
import type { AiTaskStatus, ImageTaskCreateResponse, ImageProviderDebug } from "./aiGenerationClient";
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
const numberValue = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : fallback;
|
||||
}
|
||||
|
||||
function toNullableString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
const TASK_STATUS_VALUES: ReadonlySet<string> = new Set(["pending", "running", "completed", "failed", "cancelled"]);
|
||||
const TASK_TYPE_VALUES: ReadonlySet<string> = new Set(["image", "video"]);
|
||||
|
||||
function normalizeTaskStatusValue(value: unknown): AiTaskStatus["status"] {
|
||||
return typeof value === "string" && TASK_STATUS_VALUES.has(value)
|
||||
? (value as AiTaskStatus["status"])
|
||||
: "failed";
|
||||
}
|
||||
|
||||
function normalizeTaskTypeValue(value: unknown): AiTaskStatus["type"] {
|
||||
return typeof value === "string" && TASK_TYPE_VALUES.has(value) ? (value as AiTaskStatus["type"]) : "image";
|
||||
}
|
||||
|
||||
interface ProviderDebugCandidate {
|
||||
provider?: string;
|
||||
transport?: string;
|
||||
model?: string;
|
||||
requestedModel?: string;
|
||||
billingProvider?: string;
|
||||
fallbackOf?: string;
|
||||
}
|
||||
|
||||
function normalizeProviderDebugCandidate(raw: unknown): ProviderDebugCandidate {
|
||||
if (!isRecord(raw)) return {};
|
||||
return {
|
||||
provider: toNullableString(raw.provider) ?? undefined,
|
||||
transport: toNullableString(raw.transport) ?? undefined,
|
||||
model: toNullableString(raw.model) ?? undefined,
|
||||
requestedModel: toNullableString(raw.requestedModel ?? raw.requested_model) ?? undefined,
|
||||
billingProvider: toNullableString(raw.billingProvider ?? raw.billing_provider) ?? undefined,
|
||||
fallbackOf: toNullableString(raw.fallbackOf ?? raw.fallback_of) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toStringArray(raw: unknown): string[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
return (raw as unknown[])
|
||||
.map((item) => toNullableString(item))
|
||||
.filter((item): item is string => item !== null);
|
||||
}
|
||||
|
||||
function normalizeProviderDebug(raw: unknown): ImageProviderDebug | undefined {
|
||||
if (!isRecord(raw)) return undefined;
|
||||
const hasAny =
|
||||
(raw.requestedModel ?? raw.requested_model) !== undefined ||
|
||||
(raw.effectiveModel ?? raw.effective_model) !== undefined ||
|
||||
(raw.primaryProvider ?? raw.primary_provider) !== undefined ||
|
||||
(raw.fallbackProviders ?? raw.fallback_providers) !== undefined ||
|
||||
raw.route !== undefined ||
|
||||
raw.candidates !== undefined;
|
||||
if (!hasAny) return undefined;
|
||||
const fallbackProviders = toStringArray(raw.fallbackProviders ?? raw.fallback_providers);
|
||||
const route = toStringArray(raw.route);
|
||||
const candidates = Array.isArray(raw.candidates)
|
||||
? (raw.candidates as unknown[]).map(normalizeProviderDebugCandidate)
|
||||
: undefined;
|
||||
return {
|
||||
requestedModel: toNullableString(raw.requestedModel ?? raw.requested_model) ?? undefined,
|
||||
effectiveModel: toNullableString(raw.effectiveModel ?? raw.effective_model) ?? undefined,
|
||||
primaryProvider: toNullableString(raw.primaryProvider ?? raw.primary_provider) ?? undefined,
|
||||
fallbackProviders,
|
||||
route,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single task status DTO. Returns a well-formed AiTaskStatus with safe
|
||||
* defaults for any missing/malformed field, so downstream code never sees
|
||||
* undefined where it expects a value.
|
||||
*/
|
||||
export function parseAiTaskStatus(payload: unknown): AiTaskStatus {
|
||||
const task = isRecord(payload) ? payload : {};
|
||||
return {
|
||||
taskId: toNullableString(task.taskId ?? task.task_id ?? task.id) ?? "",
|
||||
projectId: toNullableString(task.projectId ?? task.project_id) ?? undefined,
|
||||
conversationId: typeof task.conversationId === "number" || typeof task.conversation_id === "number"
|
||||
? ((task.conversationId ?? task.conversation_id) as number)
|
||||
: null,
|
||||
clientQueueId: toNullableString(task.clientQueueId ?? task.client_queue_id),
|
||||
type: normalizeTaskTypeValue(task.type),
|
||||
status: normalizeTaskStatusValue(task.status),
|
||||
progress: Math.max(0, Math.min(100, toNumber(task.progress))),
|
||||
resultUrl: toNullableString(task.resultUrl ?? task.result_url),
|
||||
error: toNullableString(task.error),
|
||||
params: isRecord(task.params) ? task.params : undefined,
|
||||
createdAt: toNullableString(task.createdAt ?? task.created_at) ?? "",
|
||||
updatedAt: toNullableString(task.updatedAt ?? task.updated_at) ?? "",
|
||||
completedAt: toNullableString(task.completedAt ?? task.completed_at),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a task-create response ({ taskId }). Throws if taskId is missing,
|
||||
* rather than silently returning { taskId: undefined }.
|
||||
*/
|
||||
export function parseTaskCreateResponse(payload: unknown): { taskId: string } {
|
||||
const body = isRecord(payload) ? payload : {};
|
||||
const taskId = toNullableString(body.taskId ?? body.task_id ?? body.id);
|
||||
if (!taskId) {
|
||||
throw new Error("任务创建失败:服务端未返回任务 ID");
|
||||
}
|
||||
return { taskId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an image task-create response, including optional provider debug info.
|
||||
*/
|
||||
export function parseImageTaskCreateResponse(payload: unknown): ImageTaskCreateResponse {
|
||||
const base = parseTaskCreateResponse(payload);
|
||||
const body = isRecord(payload) ? payload : {};
|
||||
const providerDebug = normalizeProviderDebug(body.providerDebug ?? body.provider_debug);
|
||||
return providerDebug ? { ...base, providerDebug } : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a task list payload that may be a bare array or an envelope
|
||||
* ({ tasks | items: [...] }). Malformed elements are dropped, not coerced,
|
||||
* because a single bad row should not corrupt the whole history list.
|
||||
*/
|
||||
export function parseAiTaskStatusList(payload: unknown): AiTaskStatus[] {
|
||||
let rows: unknown[];
|
||||
if (Array.isArray(payload)) {
|
||||
rows = payload;
|
||||
} else if (isRecord(payload)) {
|
||||
const nested = payload.tasks ?? payload.items;
|
||||
rows = Array.isArray(nested) ? nested : [];
|
||||
} else {
|
||||
rows = [];
|
||||
}
|
||||
// Keep only rows that have a non-empty taskId — empty-id rows are useless
|
||||
// to the UI and indicate a malformed DTO.
|
||||
return rows.map(parseAiTaskStatus).filter((task) => task.taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SSE task frame. SSE data is untyped JSON from the server stream;
|
||||
* this validates the subset of fields that subscribeTaskStatus forwards.
|
||||
*/
|
||||
export function parseSseTaskFrame(payload: unknown): Pick<
|
||||
AiTaskStatus,
|
||||
"taskId" | "status" | "progress" | "resultUrl" | "error"
|
||||
> {
|
||||
const frame = isRecord(payload) ? payload : {};
|
||||
return {
|
||||
taskId: toNullableString(frame.taskId ?? frame.task_id) ?? "",
|
||||
status: normalizeTaskStatusValue(frame.status),
|
||||
progress: Math.max(0, Math.min(100, toNumber(frame.progress))),
|
||||
resultUrl: toNullableString(frame.resultUrl ?? frame.result_url),
|
||||
error: toNullableString(frame.error),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./generationRecordClient.ts";
|
||||
@@ -43,15 +43,40 @@ export interface SaveGenerationRecordResult {
|
||||
// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截,
|
||||
// 避免后端在缺少去重时插入重复记录。
|
||||
const inFlightSaves = new Map<string, Promise<SaveGenerationRecordResult>>();
|
||||
const recentlySavedAt = new Map<string, number>();
|
||||
const recentlySavedRecords = new Map<string, { savedAt: number; signature: string }>();
|
||||
const SAVE_DEDUPE_WINDOW_MS = 60_000;
|
||||
|
||||
function pruneRecentlySaved(now: number): void {
|
||||
for (const [id, savedAt] of recentlySavedAt) {
|
||||
if (now - savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedAt.delete(id);
|
||||
for (const [id, record] of recentlySavedRecords) {
|
||||
if (now - record.savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedRecords.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function stableJsonStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(stableJsonStringify).join(",")}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, entryValue]) => entryValue !== undefined)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJsonStringify(entryValue)}`).join(",")}}`;
|
||||
}
|
||||
|
||||
function buildSaveSignature(input: SaveGenerationRecordInput): string {
|
||||
return stableJsonStringify({
|
||||
tool: input.tool,
|
||||
mode: input.mode,
|
||||
title: input.title,
|
||||
status: input.status,
|
||||
prompt: input.prompt,
|
||||
taskIds: input.taskIds,
|
||||
assets: input.assets,
|
||||
config: input.config,
|
||||
result: input.result,
|
||||
metadata: input.metadata,
|
||||
createdAt: input.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
function readPendingRecords(): SaveGenerationRecordInput[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PENDING_RECORDS_KEY);
|
||||
@@ -78,26 +103,29 @@ export async function saveGenerationRecord(input: SaveGenerationRecordInput): Pr
|
||||
pruneRecentlySaved(now);
|
||||
|
||||
const recordId = input.clientRecordId;
|
||||
const signature = buildSaveSignature(input);
|
||||
if (recordId) {
|
||||
const inFlight = inFlightSaves.get(recordId);
|
||||
const saveKey = `${recordId}:${signature}`;
|
||||
const inFlight = inFlightSaves.get(saveKey);
|
||||
if (inFlight) return inFlight;
|
||||
const savedAt = recentlySavedAt.get(recordId);
|
||||
if (savedAt !== undefined && now - savedAt <= SAVE_DEDUPE_WINDOW_MS) {
|
||||
// 终态记录只需落库一次;窗口内的重复调用直接视为已保存。
|
||||
const savedRecord = recentlySavedRecords.get(recordId);
|
||||
if (savedRecord && savedRecord.signature === signature && now - savedRecord.savedAt <= SAVE_DEDUPE_WINDOW_MS) {
|
||||
// 相同 clientRecordId 且 payload 完全一致时才拦截;同一记录的多轮更新需要继续保存。
|
||||
return { source: "server", id: recordId };
|
||||
}
|
||||
}
|
||||
|
||||
const promise = saveGenerationRecordInternal(input);
|
||||
if (recordId) {
|
||||
inFlightSaves.set(recordId, promise);
|
||||
const saveKey = `${recordId}:${signature}`;
|
||||
inFlightSaves.set(saveKey, promise);
|
||||
void promise
|
||||
.then((result) => {
|
||||
if (result.source === "server") recentlySavedAt.set(recordId, Date.now());
|
||||
if (result.source === "server") recentlySavedRecords.set(recordId, { savedAt: Date.now(), signature });
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
inFlightSaves.delete(recordId);
|
||||
inFlightSaves.delete(saveKey);
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./serverConnection.ts";
|
||||
@@ -82,9 +82,19 @@ function parseStoredSession(raw: string | null): WebUserSession | null {
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return isRecord(parsed) && typeof parsed.token === "string" && isRecord(parsed.user)
|
||||
? (parsed as unknown as WebUserSession)
|
||||
: null;
|
||||
// Require token + a user object with at least an id, so a malformed/partial
|
||||
// cached session does not get cast wholesale into WebUserSession and then
|
||||
// crash UI code that reads user.id / user.username.
|
||||
if (!isRecord(parsed) || typeof parsed.token !== "string" || !isRecord(parsed.user)) {
|
||||
return null;
|
||||
}
|
||||
const user = parsed.user;
|
||||
const userId = user.id ?? user.userId ?? user.user_id;
|
||||
const username = user.username ?? user.name;
|
||||
if (userId === undefined || typeof username !== "string" || !username.trim()) {
|
||||
return null;
|
||||
}
|
||||
return parsed as unknown as WebUserSession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./taskSubscription.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./webGenerationGateway.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./toastStore.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ossAssets.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./workflows.ts";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./EcommercePage.tsx";
|
||||
export * from "./EcommercePage.tsx";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import "../../styles/pages/ecommerce-video.css";
|
||||
import {
|
||||
CloseOutlined,
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
runVideoPlan,
|
||||
renderSceneImage,
|
||||
renderScene,
|
||||
buildSceneTasks,
|
||||
saveVideoHistory,
|
||||
buildComplianceFailureMessage,
|
||||
@@ -29,7 +27,6 @@ import {
|
||||
type PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
|
||||
import { ServerRequestError } from "../../api/serverConnection";
|
||||
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
|
||||
import { useAppStore } from "../../stores";
|
||||
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
||||
@@ -39,6 +36,7 @@ import {
|
||||
clearEcommerceVideoState,
|
||||
} from "./ecommerceVideoKeepalive";
|
||||
import { saveUnifiedEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
|
||||
import { useVideoSceneRunner } from "./useVideoSceneRunner";
|
||||
|
||||
interface EcommerceVideoWorkspaceProps {
|
||||
isAuthenticated: boolean;
|
||||
@@ -137,8 +135,6 @@ export default function EcommerceVideoWorkspace({
|
||||
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 actionNoticeTimerRef = useRef<number | null>(null);
|
||||
const setView = useAppStore((s) => s.setView);
|
||||
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
|
||||
@@ -150,6 +146,28 @@ export default function EcommerceVideoWorkspace({
|
||||
[productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution],
|
||||
);
|
||||
|
||||
const {
|
||||
abortControllerRef,
|
||||
renderAbortRef,
|
||||
runImagePhase,
|
||||
runVideoPhase,
|
||||
resumePolling,
|
||||
cancel,
|
||||
retryScene,
|
||||
} = useVideoSceneRunner({
|
||||
inputFingerprint,
|
||||
planResult,
|
||||
completedSteps,
|
||||
sourceImageUrls,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
generation: generation as unknown as Parameters<typeof useVideoSceneRunner>[0]["generation"],
|
||||
sceneStoreIdMap,
|
||||
onScenesChange: setScenes,
|
||||
onStageChange: setStage,
|
||||
onError: setError,
|
||||
});
|
||||
|
||||
// ── Keep-alive: restore saved state on mount ─────────────
|
||||
useEffect(() => {
|
||||
if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return;
|
||||
@@ -180,11 +198,11 @@ export default function EcommerceVideoWorkspace({
|
||||
setError(buildComplianceFailureMessage(planResult.compliance));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
||||
const timer = setTimeout(() => { void runImagePhase(scenes); }, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
|
||||
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
|
||||
const timer = setTimeout(() => { void runVideoPhase(scenes); }, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [stage, scenes, planResult]);
|
||||
@@ -301,82 +319,11 @@ export default function EcommerceVideoWorkspace({
|
||||
useEffect(() => {
|
||||
if (keepalivePollingStartedRef.current) return;
|
||||
if (!scenes.length || stage === "idle" || stage === "cancelled" || stage === "completed") return;
|
||||
|
||||
const hasRunningScenes = scenes.some((s) => s.status === "running" || s.status === "pending");
|
||||
if (!hasRunningScenes) return;
|
||||
keepalivePollingStartedRef.current = true;
|
||||
|
||||
// Resume polling for image generation tasks
|
||||
if (stage === "imaging") {
|
||||
renderAbortRef.current = { current: false };
|
||||
void (async () => {
|
||||
for (const scene of scenes) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
if (scene.status !== "running" && scene.status !== "pending") continue;
|
||||
if (!scene.imageTaskId) continue;
|
||||
try {
|
||||
const { waitForTask } = await import("../../api/taskSubscription");
|
||||
const resultUrl = await waitForTask(scene.imageTaskId, {
|
||||
abortRef: renderAbortRef.current,
|
||||
onProgress: (e) =>
|
||||
setScenes((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s))),
|
||||
});
|
||||
if (resultUrl) {
|
||||
setScenes((prev) =>
|
||||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", progress: 100, imageUrl: resultUrl } : s)),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setScenes((prev) =>
|
||||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", error: "恢复任务失败" } : s)),
|
||||
);
|
||||
}
|
||||
}
|
||||
setScenes((current) => {
|
||||
const allImaged = current.every((s) => s.imageUrl);
|
||||
if (allImaged) setStage("imaged");
|
||||
return current;
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
// Resume polling for video rendering tasks
|
||||
if (stage === "rendering") {
|
||||
renderAbortRef.current = { current: false };
|
||||
void (async () => {
|
||||
for (const scene of scenes) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
if (scene.status !== "running" && scene.status !== "pending") continue;
|
||||
if (!scene.taskId) continue;
|
||||
try {
|
||||
const { waitForTask } = await import("../../api/taskSubscription");
|
||||
const resultUrl = await waitForTask(scene.taskId, {
|
||||
abortRef: renderAbortRef.current,
|
||||
onProgress: (e) =>
|
||||
setScenes((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s))),
|
||||
});
|
||||
if (resultUrl) {
|
||||
setScenes((prev) =>
|
||||
prev.map((s) =>
|
||||
s.sceneId === scene.sceneId ? { ...s, status: "completed", progress: 100, resultUrl: resultUrl } : s,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setScenes((prev) =>
|
||||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "failed", error: "恢复任务失败" } : s)),
|
||||
);
|
||||
}
|
||||
}
|
||||
setScenes((current) => {
|
||||
const hasFailed = current.some((s) => s.status === "failed");
|
||||
const allDone = current.every((s) => s.status === "completed" || s.status === "failed");
|
||||
if (allDone) setStage(hasFailed ? "partial_failed" : "completed");
|
||||
return current;
|
||||
});
|
||||
})();
|
||||
}
|
||||
}, [scenes, stage]);
|
||||
void resumePolling(stage, scenes);
|
||||
}, [scenes, stage, resumePolling]);
|
||||
|
||||
// Note: keep-alive is NOT cleared on completion — results persist across page switches.
|
||||
// Only cleared when user explicitly starts a new plan via handlePlan.
|
||||
@@ -559,157 +506,9 @@ export default function EcommerceVideoWorkspace({
|
||||
await runPlanFlow(planProgress);
|
||||
};
|
||||
|
||||
// ── Phase 2: Image generation per scene ──────────────────────
|
||||
|
||||
const handleGenerateImages = async () => {
|
||||
if (!planResult || !scenes.length) return;
|
||||
if (!planAllowsVideoGeneration(planResult)) {
|
||||
setError(buildComplianceFailureMessage(planResult.compliance));
|
||||
return;
|
||||
}
|
||||
setStage("imaging"); setError(null);
|
||||
renderAbortRef.current = { current: false };
|
||||
const ratio = aspectRatio.includes("9:16") || aspectRatio.includes("9:16") ? "9:16"
|
||||
: aspectRatio.includes("16:9") || aspectRatio.includes("16:9") ? "16:9"
|
||||
: "1:1";
|
||||
let currentScenes = [...scenes];
|
||||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||||
currentScenes = next;
|
||||
setScenes(next);
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
};
|
||||
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
|
||||
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
|
||||
if (!scenesToProcess.length) {
|
||||
setStage("imaged");
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: "imaged", completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
return;
|
||||
}
|
||||
for (const scene of scenesToProcess) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderSceneImage(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio, productImageUrls: sourceImageUrls },
|
||||
{
|
||||
onSceneImageSubmitted: (id, taskId) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s));
|
||||
const storeId = generation.submitTask({ title: `分镜${id}图片`, type: "image", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "imaging" } });
|
||||
sceneStoreIdMap.current.set(id, storeId);
|
||||
},
|
||||
onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||
onSceneImageCompleted: (id, url) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markCompleted(sid, url);
|
||||
},
|
||||
onSceneImageFailed: (id, err2) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markFailed(sid, err2);
|
||||
},
|
||||
},
|
||||
renderAbortRef.current,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "图片生成失败";
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "idle", error: message } : s));
|
||||
}
|
||||
}
|
||||
const allHaveImages = currentScenes.every((s) => s.imageUrl);
|
||||
const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const;
|
||||
setStage(finalStage);
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
};
|
||||
|
||||
// ── Phase 3: Video rendering from generated images ──────────
|
||||
|
||||
const handleRenderVideos = async () => {
|
||||
if (!scenes.length) return;
|
||||
if (!planAllowsVideoGeneration(planResult)) {
|
||||
setError(planResult ? buildComplianceFailureMessage(planResult.compliance) : "合规检查未通过,已停止生成。");
|
||||
return;
|
||||
}
|
||||
if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; }
|
||||
setStage("rendering"); setError(null);
|
||||
renderAbortRef.current = { current: false };
|
||||
const quality = mapResolutionToQuality(resolution);
|
||||
let currentScenes = [...scenes];
|
||||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||||
currentScenes = next;
|
||||
setScenes(next);
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
};
|
||||
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
|
||||
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
|
||||
if (!scenesToProcess.length) {
|
||||
const finalStage = currentScenes.every((s) => s.status === "completed") ? "completed" as const : "partial_failed" as const;
|
||||
setStage(finalStage);
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
return;
|
||||
}
|
||||
for (const scene of scenesToProcess) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
if (!scene.imageUrl) continue;
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderScene(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, productImageUrls: sourceImageUrls, aspectRatio, resolution: quality },
|
||||
{
|
||||
onSceneSubmitted: (id, taskId) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s));
|
||||
const storeId = generation.submitTask({ title: `分镜${id}视频`, type: "video", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "rendering" } });
|
||||
sceneStoreIdMap.current.set(id, storeId);
|
||||
},
|
||||
onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||
onSceneCompleted: (id, url) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markCompleted(sid, url);
|
||||
},
|
||||
onSceneFailed: (id, err2) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markFailed(sid, err2);
|
||||
},
|
||||
},
|
||||
renderAbortRef.current,
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "生成失败";
|
||||
const isPayment = err instanceof ServerRequestError && err.status === 402;
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: isPayment ? "余额不足,请充值后继续" : msg } : s));
|
||||
if (isPayment) { setError("余额不足,请充值后再生成视频"); renderAbortRef.current.current = true; break; }
|
||||
}
|
||||
}
|
||||
const hasFailed = currentScenes.some((s) => s.status === "failed");
|
||||
const allDone = currentScenes.every((s) => s.status === "completed" || s.status === "failed");
|
||||
const finalStage = allDone ? (hasFailed ? "partial_failed" as const : "completed" as const) : "rendering" as const;
|
||||
setScenes(currentScenes);
|
||||
setStage(finalStage);
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
};
|
||||
|
||||
const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); };
|
||||
|
||||
const handleRetryScene = async (scene: EcommerceVideoSceneTask) => {
|
||||
if (!scene.imageUrl) return;
|
||||
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderScene(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, productImageUrls: sourceImageUrls, aspectRatio, resolution: mapResolutionToQuality(resolution) },
|
||||
{
|
||||
onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
|
||||
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||
onSceneCompleted: (id, url) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
|
||||
onSceneFailed: (id, err2) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
|
||||
},
|
||||
renderAbortRef.current,
|
||||
);
|
||||
} catch (err) {
|
||||
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: (err as Error).message } : s));
|
||||
}
|
||||
};
|
||||
|
||||
// ── Derived state ───────────────────────────────────────────
|
||||
|
||||
@@ -759,13 +558,13 @@ export default function EcommerceVideoWorkspace({
|
||||
) : null}
|
||||
{stage === "planned" || stage === "imaged" ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
|
||||
onClick={() => void runImagePhase(scenes)} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
|
||||
{stage === "imaged" ? <ReloadOutlined /> : <SendOutlined />}
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
onClick={() => void handleRenderVideos()} title={stage === "partial_failed" ? "重新生成失败的视频" : "生成视频"}>
|
||||
onClick={() => void runVideoPhase(scenes)} title={stage === "partial_failed" ? "重新生成失败的视频" : "生成视频"}>
|
||||
<SendOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
@@ -779,7 +578,7 @@ export default function EcommerceVideoWorkspace({
|
||||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 生成视频中</span>
|
||||
) : null}
|
||||
{stage === "planning" || stage === "imaging" || stage === "rendering" ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--danger" onClick={handleCancel} title="终止">
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--danger" onClick={cancel} title="终止">
|
||||
<StopOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
@@ -868,7 +667,7 @@ export default function EcommerceVideoWorkspace({
|
||||
<span className="ecom-video-tree-node__tag">分镜视频{scene.sceneId}</span>
|
||||
{vidFailed ? (
|
||||
<button type="button" className="ecom-video-tree-node__retry"
|
||||
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
|
||||
onClick={(e) => { e.stopPropagation(); void retryScene(scene); }}
|
||||
title="重试此镜头">
|
||||
<ReloadOutlined />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ecommerceGenerationPersistence.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ecommerceImageValidation.ts";
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
validateEcommerceImageFiles,
|
||||
summarizeRejectedImages,
|
||||
normalizeEcommerceImageMime,
|
||||
ECOMMERCE_MAX_IMAGE_BYTES,
|
||||
} from "./ecommerceImageValidation";
|
||||
|
||||
function makeFile(name: string, type: string, size: number): File {
|
||||
return new File([new Uint8Array(size)], name, { type });
|
||||
}
|
||||
|
||||
describe("validateEcommerceImageFiles", () => {
|
||||
it("accepts supported types under the size limit", () => {
|
||||
const result = validateEcommerceImageFiles([
|
||||
makeFile("a.png", "image/png", 1024),
|
||||
makeFile("b.jpg", "image/jpeg", 1024),
|
||||
makeFile("c.webp", "image/webp", 1024),
|
||||
makeFile("d.gif", "image/gif", 1024),
|
||||
]);
|
||||
expect(result.accepted).toHaveLength(4);
|
||||
expect(result.rejected).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects unsupported mime types", () => {
|
||||
const result = validateEcommerceImageFiles([makeFile("x.bmp", "image/bmp", 1024)]);
|
||||
expect(result.accepted).toHaveLength(0);
|
||||
expect(result.rejected[0]).toMatchObject({ name: "x.bmp", reason: "不支持的图片格式" });
|
||||
});
|
||||
|
||||
it("rejects files over 10MB", () => {
|
||||
const result = validateEcommerceImageFiles([
|
||||
makeFile("big.png", "image/png", ECOMMERCE_MAX_IMAGE_BYTES + 1),
|
||||
]);
|
||||
expect(result.accepted).toHaveLength(0);
|
||||
expect(result.rejected[0]).toMatchObject({ name: "big.png", reason: "图片超过 10MB" });
|
||||
});
|
||||
|
||||
it("accepts exactly 10MB (boundary)", () => {
|
||||
const result = validateEcommerceImageFiles([
|
||||
makeFile("edge.png", "image/png", ECOMMERCE_MAX_IMAGE_BYTES),
|
||||
]);
|
||||
expect(result.accepted).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("partitions a mixed batch", () => {
|
||||
const result = validateEcommerceImageFiles([
|
||||
makeFile("ok.png", "image/png", 100),
|
||||
makeFile("bad.bmp", "image/bmp", 100),
|
||||
makeFile("huge.jpg", "image/jpeg", ECOMMERCE_MAX_IMAGE_BYTES + 1),
|
||||
]);
|
||||
expect(result.accepted).toHaveLength(1);
|
||||
expect(result.rejected).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeRejectedImages", () => {
|
||||
it("returns empty string for no rejections", () => {
|
||||
expect(summarizeRejectedImages([])).toBe("");
|
||||
});
|
||||
|
||||
it("summarizes a single rejection", () => {
|
||||
expect(summarizeRejectedImages([{ name: "a.bmp", reason: "不支持的图片格式" }])).toBe(
|
||||
"a.bmp 已跳过:不支持的图片格式",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends count suffix for multiple rejections", () => {
|
||||
const summary = summarizeRejectedImages([
|
||||
{ name: "a.bmp", reason: "不支持的图片格式" },
|
||||
{ name: "b.bmp", reason: "不支持的图片格式" },
|
||||
]);
|
||||
expect(summary).toBe("a.bmp 等 2 个文件 已跳过:不支持的图片格式");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeEcommerceImageMime", () => {
|
||||
it("passes through supported types", () => {
|
||||
expect(normalizeEcommerceImageMime("image/png")).toBe("image/png");
|
||||
expect(normalizeEcommerceImageMime("image/jpeg")).toBe("image/jpeg");
|
||||
expect(normalizeEcommerceImageMime("image/webp")).toBe("image/webp");
|
||||
expect(normalizeEcommerceImageMime("image/gif")).toBe("image/gif");
|
||||
});
|
||||
|
||||
it("falls back to image/png for unsupported or empty types", () => {
|
||||
expect(normalizeEcommerceImageMime("image/bmp")).toBe("image/png");
|
||||
expect(normalizeEcommerceImageMime("")).toBe("image/png");
|
||||
expect(normalizeEcommerceImageMime("application/octet-stream")).toBe("image/png");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ecommerceTemplates.ts";
|
||||
@@ -381,108 +381,6 @@ export default function EcommerceClonePanel({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{cloneOutput === "hot" ? (
|
||||
<section className="clone-ai-replicate-panel" aria-label="爆款图复刻设置">
|
||||
<div className="clone-ai-dynamic-head">
|
||||
<strong>爆款图参考设置</strong>
|
||||
<span>随生成模式切换</span>
|
||||
</div>
|
||||
<div className="clone-ai-replicate-section">
|
||||
<span className="clone-ai-replicate-title">参考内容</span>
|
||||
<div className="clone-ai-replicate-tabs" role="tablist" aria-label="参考内容来源">
|
||||
<button
|
||||
type="button"
|
||||
className={cloneReferenceMode === "upload" ? "is-active" : ""}
|
||||
aria-selected={cloneReferenceMode === "upload"}
|
||||
onClick={() => setCloneReferenceMode("upload")}
|
||||
>
|
||||
上传参考图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cloneReferenceMode === "link" ? "is-active" : ""}
|
||||
aria-selected={cloneReferenceMode === "link"}
|
||||
onClick={() => setCloneReferenceMode("link")}
|
||||
>
|
||||
导入链接
|
||||
</button>
|
||||
</div>
|
||||
{cloneReferenceMode === "upload" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`clone-ai-replicate-upload${isCloneReferenceDragging ? " is-dragging" : ""}${cloneReferenceImages.length ? " has-files" : ""}`}
|
||||
onClick={() => cloneReferenceInputRef.current?.click()}
|
||||
onDragOver={handleCloneReferenceDragOver}
|
||||
onDragLeave={handleCloneReferenceDragLeave}
|
||||
onDrop={handleCloneReferenceDrop}
|
||||
>
|
||||
{cloneReferenceImages.length ? (
|
||||
<>
|
||||
<div className="clone-ai-replicate-files">
|
||||
{cloneReferenceImages.map((item) => (
|
||||
<figure
|
||||
key={item.id}
|
||||
className="clone-ai-replicate-file"
|
||||
onMouseEnter={(e) => handleFileMouseEnter(item.src, e)}
|
||||
onMouseLeave={handleFileMouseLeave}
|
||||
>
|
||||
<img src={item.src} alt="" />
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
<span className="clone-ai-replicate-add-more">
|
||||
<CloudUploadOutlined />
|
||||
点击继续上传文件
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
<CloudUploadOutlined />
|
||||
<span className="clone-ai-replicate-upload-text">拖拽或点击上传参考图</span>
|
||||
</span>
|
||||
)}
|
||||
<em>{cloneReferenceImages.length ? `已选 ${cloneReferenceImages.length}/${maxCloneReferenceImages}` : `最多 ${maxCloneReferenceImages} 张`}</em>
|
||||
{isCloneReferenceDragging ? (
|
||||
<div className="clone-ai-replicate-upload-overlay">
|
||||
<CloudUploadOutlined />
|
||||
<span>释放文件以上传</span>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
) : (
|
||||
<label className="clone-ai-replicate-link">
|
||||
<input placeholder="粘贴商品图或详情页链接" />
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={cloneReferenceInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
onChange={handleCloneReferenceUpload}
|
||||
aria-label="上传参考图片"
|
||||
/>
|
||||
</div>
|
||||
<div className="clone-ai-replicate-section">
|
||||
<span className="clone-ai-replicate-title">复刻程度</span>
|
||||
<div className="clone-ai-replicate-levels" role="toolbar" aria-label="复刻程度">
|
||||
{cloneReplicateLevelOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={cloneReplicateLevel === option.key ? "is-active" : ""}
|
||||
aria-pressed={cloneReplicateLevel === option.key}
|
||||
onClick={() => setCloneReplicateLevel(option.key)}
|
||||
>
|
||||
<strong>{option.title}</strong>
|
||||
<span>{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{cloneOutput === "set" ? (
|
||||
<section className="clone-ai-count-panel" aria-label="套图图片数量">
|
||||
<div className="clone-ai-dynamic-head">
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
// 视频场景任务编排 hook。
|
||||
// 从 EcommerceVideoWorkspace.tsx 抽出,封装"分镜图片生成 / 视频渲染 / 恢复轮询 / 取消"
|
||||
// 四类场景任务的执行逻辑,消除组件内 persistScenes 闭包的重复。
|
||||
//
|
||||
// 运行时行为与原组件逻辑等价(setScenes/setStage/saveEcommerceVideoState 的调用顺序和参数不变);
|
||||
// 抽离目的是建立逻辑边界,让 resume 与正常执行共享同一套遍历。
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import {
|
||||
renderSceneImage,
|
||||
renderScene,
|
||||
} from "./ecommerceVideoService";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { ServerRequestError } from "../../api/serverConnection";
|
||||
import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import {
|
||||
saveEcommerceVideoState,
|
||||
} from "./ecommerceVideoKeepalive";
|
||||
import type {
|
||||
EcommerceVideoSceneTask,
|
||||
EcommerceVideoStage,
|
||||
EcommerceVideoPlanResult,
|
||||
PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
|
||||
type SetStateAction<T> = T | ((prev: T) => T);
|
||||
|
||||
export interface VideoSceneRunnerContext {
|
||||
inputFingerprint: string;
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
completedSteps: PlanStep[];
|
||||
sourceImageUrls: string[];
|
||||
aspectRatio: string;
|
||||
resolution: string;
|
||||
/** useGenerationTasks 实例,用于 submitTask/markCompleted/markFailed */
|
||||
generation: {
|
||||
submitTask: (task: Record<string, unknown> & { taskId: string }) => string;
|
||||
markCompleted: (id: string, resultUrl?: string) => void;
|
||||
markFailed: (id: string, error?: string) => void;
|
||||
};
|
||||
sceneStoreIdMap: MutableRefObject<Map<number, string>>;
|
||||
onScenesChange: (updater: SetStateAction<EcommerceVideoSceneTask[]>) => void;
|
||||
onStageChange: (stage: EcommerceVideoStage) => void;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
function mapResolutionToQuality(res: string): "720P" | "1080P" {
|
||||
return res.includes("720") ? "720P" : "1080P";
|
||||
}
|
||||
|
||||
function deriveAspectRatioToken(aspectRatio: string): string {
|
||||
if (aspectRatio.includes("9:16") || aspectRatio.includes("9:16")) return "9:16";
|
||||
if (aspectRatio.includes("16:9") || aspectRatio.includes("16:9")) return "16:9";
|
||||
return "1:1";
|
||||
}
|
||||
|
||||
export function useVideoSceneRunner(context: VideoSceneRunnerContext) {
|
||||
const {
|
||||
inputFingerprint,
|
||||
planResult,
|
||||
completedSteps,
|
||||
sourceImageUrls,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
generation,
|
||||
sceneStoreIdMap,
|
||||
onScenesChange,
|
||||
onStageChange,
|
||||
onError,
|
||||
} = context;
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const renderAbortRef = useRef({ current: false });
|
||||
|
||||
// ── Image phase: generate per-scene images ──────────────────
|
||||
const runImagePhase = useCallback(
|
||||
async (scenes: EcommerceVideoSceneTask[]): Promise<void> => {
|
||||
if (!planResult || !scenes.length) return;
|
||||
const ratio = deriveAspectRatioToken(aspectRatio);
|
||||
let currentScenes = [...scenes];
|
||||
|
||||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||||
currentScenes = next;
|
||||
onScenesChange(next);
|
||||
saveEcommerceVideoState({
|
||||
inputFingerprint,
|
||||
stage: "imaging",
|
||||
completedSteps,
|
||||
planResult,
|
||||
scenes: next,
|
||||
sourceImageUrls,
|
||||
});
|
||||
};
|
||||
|
||||
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
|
||||
if (!scenesToProcess.length) {
|
||||
onStageChange("imaged");
|
||||
saveEcommerceVideoState({
|
||||
inputFingerprint,
|
||||
stage: "imaged",
|
||||
completedSteps,
|
||||
planResult,
|
||||
scenes: currentScenes,
|
||||
sourceImageUrls,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const scene of scenesToProcess) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
persistScenes(
|
||||
currentScenes.map((s) =>
|
||||
s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await renderSceneImage(
|
||||
{
|
||||
sceneId: scene.sceneId,
|
||||
prompt: scene.prompt,
|
||||
aspectRatio: ratio,
|
||||
productImageUrls: sourceImageUrls,
|
||||
},
|
||||
{
|
||||
onSceneImageSubmitted: (id, taskId) => {
|
||||
persistScenes(
|
||||
currentScenes.map((s) => (s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)),
|
||||
);
|
||||
const storeId = generation.submitTask({
|
||||
title: `分镜${id}图片`,
|
||||
type: "image",
|
||||
status: "running",
|
||||
progress: 0,
|
||||
prompt: scene.prompt,
|
||||
sourceView: "ecommerce",
|
||||
taskId,
|
||||
params: { sceneId: id, phase: "imaging" },
|
||||
});
|
||||
sceneStoreIdMap.current.set(id, storeId);
|
||||
},
|
||||
onSceneImageProgress: (id, progress) =>
|
||||
persistScenes(currentScenes.map((s) => (s.sceneId === id ? { ...s, progress } : s))),
|
||||
onSceneImageCompleted: (id, url) => {
|
||||
persistScenes(
|
||||
currentScenes.map((s) => (s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)),
|
||||
);
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markCompleted(sid, url);
|
||||
},
|
||||
onSceneImageFailed: (id, err2) => {
|
||||
persistScenes(
|
||||
currentScenes.map((s) => (s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)),
|
||||
);
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markFailed(sid, err2);
|
||||
},
|
||||
},
|
||||
renderAbortRef.current,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "图片生成失败";
|
||||
persistScenes(
|
||||
currentScenes.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", error: message } : s)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allHaveImages = currentScenes.every((s) => s.imageUrl);
|
||||
const finalStage: EcommerceVideoStage = allHaveImages ? "imaged" : "partial_failed";
|
||||
onStageChange(finalStage);
|
||||
saveEcommerceVideoState({
|
||||
inputFingerprint,
|
||||
stage: finalStage,
|
||||
completedSteps,
|
||||
planResult,
|
||||
scenes: currentScenes,
|
||||
sourceImageUrls,
|
||||
});
|
||||
},
|
||||
[
|
||||
planResult,
|
||||
aspectRatio,
|
||||
inputFingerprint,
|
||||
completedSteps,
|
||||
sourceImageUrls,
|
||||
generation,
|
||||
sceneStoreIdMap,
|
||||
onScenesChange,
|
||||
onStageChange,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Video phase: render per-scene videos ────────────────────
|
||||
const runVideoPhase = useCallback(
|
||||
async (scenes: EcommerceVideoSceneTask[]): Promise<void> => {
|
||||
if (!scenes.length) return;
|
||||
const quality = mapResolutionToQuality(resolution);
|
||||
let currentScenes = [...scenes];
|
||||
|
||||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||||
currentScenes = next;
|
||||
onScenesChange(next);
|
||||
saveEcommerceVideoState({
|
||||
inputFingerprint,
|
||||
stage: "rendering",
|
||||
completedSteps,
|
||||
planResult,
|
||||
scenes: next,
|
||||
sourceImageUrls,
|
||||
});
|
||||
};
|
||||
|
||||
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
|
||||
if (!scenesToProcess.length) {
|
||||
const finalStage: EcommerceVideoStage = currentScenes.every((s) => s.status === "completed")
|
||||
? "completed"
|
||||
: "partial_failed";
|
||||
onStageChange(finalStage);
|
||||
saveEcommerceVideoState({
|
||||
inputFingerprint,
|
||||
stage: finalStage,
|
||||
completedSteps,
|
||||
planResult,
|
||||
scenes: currentScenes,
|
||||
sourceImageUrls,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const scene of scenesToProcess) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
if (!scene.imageUrl) continue;
|
||||
persistScenes(
|
||||
currentScenes.map((s) =>
|
||||
s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await renderScene(
|
||||
{
|
||||
sceneId: scene.sceneId,
|
||||
prompt: scene.prompt,
|
||||
durationSeconds: scene.durationSeconds,
|
||||
imageUrl: scene.imageUrl,
|
||||
productImageUrls: sourceImageUrls,
|
||||
aspectRatio,
|
||||
resolution: quality,
|
||||
},
|
||||
{
|
||||
onSceneSubmitted: (id, taskId) => {
|
||||
persistScenes(
|
||||
currentScenes.map((s) => (s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
|
||||
);
|
||||
const storeId = generation.submitTask({
|
||||
title: `分镜${id}视频`,
|
||||
type: "video",
|
||||
status: "running",
|
||||
progress: 0,
|
||||
prompt: scene.prompt,
|
||||
sourceView: "ecommerce",
|
||||
taskId,
|
||||
params: { sceneId: id, phase: "rendering" },
|
||||
});
|
||||
sceneStoreIdMap.current.set(id, storeId);
|
||||
},
|
||||
onSceneProgress: (id, progress) =>
|
||||
persistScenes(currentScenes.map((s) => (s.sceneId === id ? { ...s, progress } : s))),
|
||||
onSceneCompleted: (id, url) => {
|
||||
persistScenes(
|
||||
currentScenes.map((s) =>
|
||||
s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s,
|
||||
),
|
||||
);
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markCompleted(sid, url);
|
||||
},
|
||||
onSceneFailed: (id, err2) => {
|
||||
persistScenes(
|
||||
currentScenes.map((s) => (s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
|
||||
);
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markFailed(sid, err2);
|
||||
},
|
||||
},
|
||||
renderAbortRef.current,
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "生成失败";
|
||||
const isPayment = err instanceof ServerRequestError && err.status === 402;
|
||||
persistScenes(
|
||||
currentScenes.map((s) =>
|
||||
s.sceneId === scene.sceneId
|
||||
? { ...s, status: "failed", error: isPayment ? "余额不足,请充值后继续" : msg }
|
||||
: s,
|
||||
),
|
||||
);
|
||||
if (isPayment) {
|
||||
onError?.("余额不足,请充值后再生成视频");
|
||||
renderAbortRef.current.current = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasFailed = currentScenes.some((s) => s.status === "failed");
|
||||
const allDone = currentScenes.every((s) => s.status === "completed" || s.status === "failed");
|
||||
const finalStage: EcommerceVideoStage = allDone
|
||||
? hasFailed
|
||||
? "partial_failed"
|
||||
: "completed"
|
||||
: "rendering";
|
||||
onScenesChange(currentScenes);
|
||||
onStageChange(finalStage);
|
||||
saveEcommerceVideoState({
|
||||
inputFingerprint,
|
||||
stage: finalStage,
|
||||
completedSteps,
|
||||
planResult,
|
||||
scenes: currentScenes,
|
||||
sourceImageUrls,
|
||||
});
|
||||
},
|
||||
[
|
||||
resolution,
|
||||
inputFingerprint,
|
||||
completedSteps,
|
||||
planResult,
|
||||
sourceImageUrls,
|
||||
aspectRatio,
|
||||
generation,
|
||||
sceneStoreIdMap,
|
||||
onScenesChange,
|
||||
onStageChange,
|
||||
onError,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Resume polling: re-attach waitForTask to running scenes ─
|
||||
// Used when the page is restored from keep-alive. Differs from runImagePhase/runVideoPhase
|
||||
// in that it does NOT create new tasks — it only polls existing imageTaskId/taskId.
|
||||
const resumePolling = useCallback(
|
||||
async (stage: EcommerceVideoStage, scenes: EcommerceVideoSceneTask[]): Promise<void> => {
|
||||
renderAbortRef.current = { current: false };
|
||||
|
||||
if (stage === "imaging") {
|
||||
for (const scene of scenes) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
if (scene.status !== "running" && scene.status !== "pending") continue;
|
||||
if (!scene.imageTaskId) continue;
|
||||
try {
|
||||
const resultUrl = await waitForTask(scene.imageTaskId, {
|
||||
abortRef: renderAbortRef.current,
|
||||
onProgress: (e) =>
|
||||
onScenesChange((prev) =>
|
||||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s)),
|
||||
),
|
||||
});
|
||||
if (resultUrl) {
|
||||
onScenesChange((prev) =>
|
||||
prev.map((s) =>
|
||||
s.sceneId === scene.sceneId ? { ...s, status: "idle", progress: 100, imageUrl: resultUrl } : s,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
onScenesChange((prev) =>
|
||||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", error: "恢复任务失败" } : s)),
|
||||
);
|
||||
}
|
||||
}
|
||||
onScenesChange((current) => {
|
||||
const allImaged = current.every((s) => s.imageUrl);
|
||||
if (allImaged) onStageChange("imaged");
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
if (stage === "rendering") {
|
||||
for (const scene of scenes) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
if (scene.status !== "running" && scene.status !== "pending") continue;
|
||||
if (!scene.taskId) continue;
|
||||
try {
|
||||
const resultUrl = await waitForTask(scene.taskId, {
|
||||
abortRef: renderAbortRef.current,
|
||||
onProgress: (e) =>
|
||||
onScenesChange((prev) =>
|
||||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s)),
|
||||
),
|
||||
});
|
||||
if (resultUrl) {
|
||||
onScenesChange((prev) =>
|
||||
prev.map((s) =>
|
||||
s.sceneId === scene.sceneId
|
||||
? { ...s, status: "completed", progress: 100, resultUrl: resultUrl }
|
||||
: s,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
onScenesChange((prev) =>
|
||||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "failed", error: "恢复任务失败" } : s)),
|
||||
);
|
||||
}
|
||||
}
|
||||
onScenesChange((current) => {
|
||||
const hasFailed = current.some((s) => s.status === "failed");
|
||||
const allDone = current.every((s) => s.status === "completed" || s.status === "failed");
|
||||
if (allDone) onStageChange(hasFailed ? "partial_failed" : "completed");
|
||||
return current;
|
||||
});
|
||||
}
|
||||
},
|
||||
[onScenesChange, onStageChange],
|
||||
);
|
||||
|
||||
// ── Cancel: abort planning + scene rendering ────────────────
|
||||
const cancel = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
renderAbortRef.current.current = true;
|
||||
onStageChange("cancelled");
|
||||
}, [onStageChange]);
|
||||
|
||||
// ── Retry a single scene's video ────────────────────────────
|
||||
const retryScene = useCallback(
|
||||
async (scene: EcommerceVideoSceneTask): Promise<void> => {
|
||||
if (!scene.imageUrl) return;
|
||||
onScenesChange((prev) =>
|
||||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s)),
|
||||
);
|
||||
try {
|
||||
await renderScene(
|
||||
{
|
||||
sceneId: scene.sceneId,
|
||||
prompt: scene.prompt,
|
||||
durationSeconds: scene.durationSeconds,
|
||||
imageUrl: scene.imageUrl,
|
||||
productImageUrls: sourceImageUrls,
|
||||
aspectRatio,
|
||||
resolution: mapResolutionToQuality(resolution),
|
||||
},
|
||||
{
|
||||
onSceneSubmitted: (id, taskId) =>
|
||||
onScenesChange((prev) => prev.map((s) => (s.sceneId === id ? { ...s, taskId, status: "running" } : s))),
|
||||
onSceneProgress: (id, progress) =>
|
||||
onScenesChange((prev) => prev.map((s) => (s.sceneId === id ? { ...s, progress } : s))),
|
||||
onSceneCompleted: (id, url) =>
|
||||
onScenesChange((prev) =>
|
||||
prev.map((s) => (s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
|
||||
),
|
||||
onSceneFailed: (id, err2) =>
|
||||
onScenesChange((prev) =>
|
||||
prev.map((s) => (s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
|
||||
),
|
||||
},
|
||||
renderAbortRef.current,
|
||||
);
|
||||
} catch (err) {
|
||||
onScenesChange((prev) =>
|
||||
prev.map((s) =>
|
||||
s.sceneId === scene.sceneId ? { ...s, status: "failed", error: (err as Error).message } : s,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[sourceImageUrls, aspectRatio, resolution, onScenesChange],
|
||||
);
|
||||
|
||||
return {
|
||||
abortControllerRef,
|
||||
renderAbortRef,
|
||||
runImagePhase,
|
||||
runVideoPhase,
|
||||
resumePolling,
|
||||
cancel,
|
||||
retryScene,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// 克隆 / 电商历史的本地持久化模块。
|
||||
// 从 EcommercePage.tsx 抽出,逻辑零改动。
|
||||
// 把 localStorage 读写 + 字段校验 + 默认值收口在此,页面只调用 read/write。
|
||||
//
|
||||
// 领域类型(CloneImageItem / CloneResult / CloneSavedSetting / EcommerceHistoryRecord
|
||||
// 及其依赖的 type alias)也定义在此并 export,因为它们本质上是"持久化数据契约";
|
||||
// EcommercePage 从这里 re-import,避免循环依赖(类型 import 编译期擦除)。
|
||||
|
||||
import type { CloneOutputKey } from "./platformRules";
|
||||
|
||||
export type CloneSetCountKey = "selling" | "white" | "scene";
|
||||
export type CloneModelPanelTab = "scene" | "model";
|
||||
export type CloneVideoQualityKey = "standard" | "high" | "ultra";
|
||||
export type CloneReplicateLevelKey = "style" | "high";
|
||||
export type CloneReferenceMode = "upload" | "link";
|
||||
|
||||
export interface CloneImageItem {
|
||||
id: string;
|
||||
src: string;
|
||||
name: string;
|
||||
file?: File;
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
mimeType?: string;
|
||||
ossKey?: string;
|
||||
}
|
||||
|
||||
export interface CloneResult {
|
||||
id: string;
|
||||
src: string;
|
||||
label: string;
|
||||
type?: "image" | "video";
|
||||
}
|
||||
|
||||
export interface CloneSavedSetting {
|
||||
id: string;
|
||||
name: string;
|
||||
savedAt: string;
|
||||
output: CloneOutputKey;
|
||||
platform: string;
|
||||
market: string;
|
||||
language: string;
|
||||
ratio: string;
|
||||
setCounts: Record<CloneSetCountKey, number>;
|
||||
detailModules: string[];
|
||||
modelPanelTab: CloneModelPanelTab;
|
||||
modelScenes: string[];
|
||||
modelCustomScene: string;
|
||||
modelGender: string;
|
||||
modelAge: string;
|
||||
modelEthnicity: string;
|
||||
modelBody: string;
|
||||
modelAppearance: string;
|
||||
videoQuality: CloneVideoQualityKey;
|
||||
videoDurationSeconds: number;
|
||||
videoSmart: boolean;
|
||||
referenceMode?: CloneReferenceMode;
|
||||
replicateLevel?: CloneReplicateLevelKey;
|
||||
requirement: string;
|
||||
}
|
||||
|
||||
export interface EcommerceHistoryRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
output: CloneOutputKey;
|
||||
platform: string;
|
||||
market: string;
|
||||
language: string;
|
||||
ratio: string;
|
||||
requirement: string;
|
||||
productImages: CloneImageItem[];
|
||||
results: CloneResult[];
|
||||
setResultImages: string[];
|
||||
setCounts: Record<CloneSetCountKey, number>;
|
||||
detailModules: string[];
|
||||
modelScenes: string[];
|
||||
referenceImages: CloneImageItem[];
|
||||
replicateLevel: CloneReplicateLevelKey;
|
||||
}
|
||||
|
||||
export const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting";
|
||||
export const ecommerceHistoryStorageKey = "omniai.ecommerce.history.records";
|
||||
|
||||
export const defaultCloneSetCounts: Record<CloneSetCountKey, number> = {
|
||||
selling: 3,
|
||||
white: 1,
|
||||
scene: 3,
|
||||
};
|
||||
|
||||
export const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"];
|
||||
|
||||
export function isCloneImageItem(item: unknown): item is CloneImageItem {
|
||||
const candidate = item as Partial<CloneImageItem>;
|
||||
return typeof candidate.id === "string" && typeof candidate.src === "string" && typeof candidate.name === "string";
|
||||
}
|
||||
|
||||
export function isCloneResult(item: unknown): item is CloneResult {
|
||||
const candidate = item as Partial<CloneResult>;
|
||||
return typeof candidate.id === "string" && typeof candidate.src === "string" && typeof candidate.label === "string";
|
||||
}
|
||||
|
||||
export function isEcommerceHistoryRecord(item: unknown): item is EcommerceHistoryRecord {
|
||||
const candidate = item as Partial<EcommerceHistoryRecord>;
|
||||
return (
|
||||
typeof candidate.id === "string" &&
|
||||
typeof candidate.title === "string" &&
|
||||
typeof candidate.createdAt === "number" &&
|
||||
typeof candidate.output === "string" &&
|
||||
typeof candidate.platform === "string" &&
|
||||
typeof candidate.market === "string" &&
|
||||
typeof candidate.language === "string" &&
|
||||
typeof candidate.ratio === "string" &&
|
||||
typeof candidate.requirement === "string" &&
|
||||
Array.isArray(candidate.productImages) &&
|
||||
candidate.productImages.every(isCloneImageItem) &&
|
||||
Array.isArray(candidate.results) &&
|
||||
candidate.results.every(isCloneResult)
|
||||
);
|
||||
}
|
||||
|
||||
export function isCloneSavedSetting(item: unknown): item is CloneSavedSetting {
|
||||
const candidate = item as Partial<CloneSavedSetting>;
|
||||
return (
|
||||
typeof candidate.id === "string" &&
|
||||
typeof candidate.name === "string" &&
|
||||
typeof candidate.savedAt === "string" &&
|
||||
typeof candidate.output === "string" &&
|
||||
typeof candidate.platform === "string" &&
|
||||
typeof candidate.market === "string" &&
|
||||
typeof candidate.language === "string" &&
|
||||
typeof candidate.ratio === "string" &&
|
||||
typeof candidate.videoDurationSeconds === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function removeFilePayloadFromImages(images: CloneImageItem[]): CloneImageItem[] {
|
||||
return images.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({
|
||||
id,
|
||||
src,
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
mimeType,
|
||||
ossKey,
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord {
|
||||
return {
|
||||
...record,
|
||||
productImages: removeFilePayloadFromImages(record.productImages),
|
||||
referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []),
|
||||
results: record.results ?? [],
|
||||
setResultImages: record.setResultImages ?? [],
|
||||
setCounts: record.setCounts ?? defaultCloneSetCounts,
|
||||
detailModules: record.detailModules ?? defaultCloneDetailModuleIds,
|
||||
modelScenes: record.modelScenes ?? [],
|
||||
replicateLevel: record.replicateLevel ?? "high",
|
||||
};
|
||||
}
|
||||
|
||||
export function readCloneLatestSetting(): CloneSavedSetting | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(cloneLatestSettingStorageKey);
|
||||
if (rawValue) {
|
||||
const parsedValue: unknown = JSON.parse(rawValue);
|
||||
if (isCloneSavedSetting(parsedValue)) return parsedValue;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeCloneLatestSetting(setting: CloneSavedSetting): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(cloneLatestSettingStorageKey, JSON.stringify(setting));
|
||||
}
|
||||
|
||||
export function clearCloneLatestSetting(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.removeItem(cloneLatestSettingStorageKey);
|
||||
}
|
||||
|
||||
export function readEcommerceHistoryRecords(): EcommerceHistoryRecord[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(ecommerceHistoryStorageKey);
|
||||
if (!rawValue) return [];
|
||||
const parsedValue: unknown = JSON.parse(rawValue);
|
||||
if (!Array.isArray(parsedValue)) return [];
|
||||
return parsedValue
|
||||
.filter(isEcommerceHistoryRecord)
|
||||
.map(normalizeEcommerceHistoryRecord)
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
.slice(0, 30);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function writeEcommerceHistoryRecords(records: EcommerceHistoryRecord[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(
|
||||
ecommerceHistoryStorageKey,
|
||||
JSON.stringify(records.map(normalizeEcommerceHistoryRecord).slice(0, 30)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
clampNumber,
|
||||
normalizeHexColor,
|
||||
hexToRgb,
|
||||
rgbToHex,
|
||||
parseSmartCutoutAspect,
|
||||
parseSmartCutoutPercent,
|
||||
hsvToRgb,
|
||||
hexToHsv,
|
||||
} from "./colorUtils";
|
||||
|
||||
describe("clampNumber", () => {
|
||||
it("clamps below min", () => {
|
||||
expect(clampNumber(-5, 0, 100)).toBe(0);
|
||||
});
|
||||
it("clamps above max", () => {
|
||||
expect(clampNumber(200, 0, 100)).toBe(100);
|
||||
});
|
||||
it("passes through values in range", () => {
|
||||
expect(clampNumber(50, 0, 100)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeHexColor", () => {
|
||||
it("normalizes a valid hex", () => {
|
||||
expect(normalizeHexColor("#FF8800")).toBe("#ff8800");
|
||||
});
|
||||
it("accepts hex without leading #", () => {
|
||||
expect(normalizeHexColor("ff8800")).toBe("#ff8800");
|
||||
});
|
||||
it("returns null for invalid hex", () => {
|
||||
expect(normalizeHexColor("#fff")).toBeNull();
|
||||
expect(normalizeHexColor("ggghhh")).toBeNull();
|
||||
expect(normalizeHexColor("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hex <-> rgb round-trip", () => {
|
||||
const cases: Array<[string, { r: number; g: number; b: number }]> = [
|
||||
["#000000", { r: 0, g: 0, b: 0 }],
|
||||
["#ffffff", { r: 255, g: 255, b: 255 }],
|
||||
["#ff8800", { r: 255, g: 136, b: 0 }],
|
||||
["#2dd4bf", { r: 45, g: 212, b: 191 }],
|
||||
];
|
||||
for (const [hex, rgb] of cases) {
|
||||
it(`hexToRgb(${hex}) -> rgb`, () => {
|
||||
expect(hexToRgb(hex)).toEqual(rgb);
|
||||
});
|
||||
it(`rgbToHex(${rgb.r},${rgb.g},${rgb.b}) -> ${hex}`, () => {
|
||||
expect(rgbToHex(rgb.r, rgb.g, rgb.b)).toBe(hex);
|
||||
});
|
||||
}
|
||||
|
||||
it("hexToRgb returns null for invalid", () => {
|
||||
expect(hexToRgb("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("rgbToHex clamps out-of-range channels", () => {
|
||||
expect(rgbToHex(300, -5, 128)).toBe(rgbToHex(255, 0, 128));
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSmartCutoutAspect", () => {
|
||||
it("parses a W / H aspect string", () => {
|
||||
expect(parseSmartCutoutAspect("295 / 413")).toBeCloseTo(295 / 413, 5);
|
||||
});
|
||||
it("handles decimals", () => {
|
||||
expect(parseSmartCutoutAspect("1.5 / 2")).toBeCloseTo(0.75, 5);
|
||||
});
|
||||
it("returns null when no ratio pattern is present", () => {
|
||||
expect(parseSmartCutoutAspect("not-a-ratio")).toBeNull();
|
||||
expect(parseSmartCutoutAspect("")).toBeNull();
|
||||
});
|
||||
it("returns null for zero dimensions", () => {
|
||||
expect(parseSmartCutoutAspect("0 / 100")).toBeNull();
|
||||
expect(parseSmartCutoutAspect("100 / 0")).toBeNull();
|
||||
});
|
||||
it("ignores leading sign (regex only matches digits)", () => {
|
||||
// The regex \d+ does not match '-', so "-1 / 2" parses as 1/2.
|
||||
expect(parseSmartCutoutAspect("-1 / 2")).toBeCloseTo(0.5, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSmartCutoutPercent", () => {
|
||||
it("parses a percentage", () => {
|
||||
expect(parseSmartCutoutPercent("82%", 0.5)).toBeCloseTo(0.82, 5);
|
||||
});
|
||||
it("clamps to [0.05, 1]", () => {
|
||||
expect(parseSmartCutoutPercent("150%", 0.5)).toBe(1);
|
||||
expect(parseSmartCutoutPercent("1%", 0.5)).toBe(0.05);
|
||||
});
|
||||
it("returns fallback for non-numeric", () => {
|
||||
expect(parseSmartCutoutPercent("abc", 0.5)).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hsv <-> rgb", () => {
|
||||
it("hsvToRgb of pure red", () => {
|
||||
expect(hsvToRgb(0, 100, 100)).toEqual({ r: 255, g: 0, b: 0 });
|
||||
});
|
||||
it("hsvToRgb of pure green", () => {
|
||||
expect(hsvToRgb(120, 100, 100)).toEqual({ r: 0, g: 255, b: 0 });
|
||||
});
|
||||
it("hsvToRgb of white (saturation 0)", () => {
|
||||
expect(hsvToRgb(0, 0, 100)).toEqual({ r: 255, g: 255, b: 255 });
|
||||
});
|
||||
|
||||
it("hexToHsv then hsvToRgb round-trips within ±2 (rounding)", () => {
|
||||
const hex = "#2dd4bf";
|
||||
const hsv = hexToHsv(hex);
|
||||
const rgb = hsvToRgb(hsv.h, hsv.s, hsv.v);
|
||||
const original = hexToRgb(hex)!;
|
||||
expect(Math.abs(rgb.r - original.r)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(rgb.g - original.g)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(rgb.b - original.b)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("hexToHsv of white", () => {
|
||||
expect(hexToHsv("#ffffff")).toEqual({ h: 0, s: 0, v: 100 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// 智能抠图 / 调色板用到的纯数值与颜色转换工具。
|
||||
// 从 EcommercePage.tsx 抽出,逻辑零改动,仅加 export 以便单测。
|
||||
|
||||
export const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
export const normalizeHexColor = (value: string) => {
|
||||
const clean = value.trim().replace(/^#/, "");
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(clean)) return null;
|
||||
return `#${clean.toLowerCase()}`;
|
||||
};
|
||||
|
||||
export const hexToRgb = (value: string) => {
|
||||
const normalized = normalizeHexColor(value);
|
||||
if (!normalized) return null;
|
||||
const numeric = Number.parseInt(normalized.slice(1), 16);
|
||||
return {
|
||||
r: (numeric >> 16) & 255,
|
||||
g: (numeric >> 8) & 255,
|
||||
b: numeric & 255,
|
||||
};
|
||||
};
|
||||
|
||||
export const rgbToHex = (r: number, g: number, b: number) =>
|
||||
`#${[r, g, b].map((item) => clampNumber(Math.round(item), 0, 255).toString(16).padStart(2, "0")).join("")}`;
|
||||
|
||||
export const parseSmartCutoutAspect = (aspect: string) => {
|
||||
const match = aspect.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
|
||||
if (!match) return null;
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
|
||||
return width / height;
|
||||
};
|
||||
|
||||
export const parseSmartCutoutPercent = (value: string, fallback: number) => {
|
||||
const numeric = Number(value.replace("%", ""));
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return clampNumber(numeric / 100, 0.05, 1);
|
||||
};
|
||||
|
||||
export const hsvToRgb = (h: number, s: number, v: number) => {
|
||||
const hue = ((h % 360) + 360) % 360;
|
||||
const saturation = clampNumber(s, 0, 100) / 100;
|
||||
const value = clampNumber(v, 0, 100) / 100;
|
||||
const chroma = value * saturation;
|
||||
const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||
const match = value - chroma;
|
||||
const [red, green, blue] =
|
||||
hue < 60
|
||||
? [chroma, x, 0]
|
||||
: hue < 120
|
||||
? [x, chroma, 0]
|
||||
: hue < 180
|
||||
? [0, chroma, x]
|
||||
: hue < 240
|
||||
? [0, x, chroma]
|
||||
: hue < 300
|
||||
? [x, 0, chroma]
|
||||
: [chroma, 0, x];
|
||||
return {
|
||||
r: (red + match) * 255,
|
||||
g: (green + match) * 255,
|
||||
b: (blue + match) * 255,
|
||||
};
|
||||
};
|
||||
|
||||
export const hexToHsv = (value: string) => {
|
||||
const rgb = hexToRgb(value) ?? { r: 255, g: 255, b: 255 };
|
||||
const red = rgb.r / 255;
|
||||
const green = rgb.g / 255;
|
||||
const blue = rgb.b / 255;
|
||||
const max = Math.max(red, green, blue);
|
||||
const min = Math.min(red, green, blue);
|
||||
const delta = max - min;
|
||||
const hue =
|
||||
delta === 0
|
||||
? 0
|
||||
: max === red
|
||||
? 60 * (((green - blue) / delta) % 6)
|
||||
: max === green
|
||||
? 60 * ((blue - red) / delta + 2)
|
||||
: 60 * ((red - green) / delta + 4);
|
||||
return {
|
||||
h: Math.round((hue + 360) % 360),
|
||||
s: max === 0 ? 0 : Math.round((delta / max) * 100),
|
||||
v: Math.round(max * 100),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultCloneOutput,
|
||||
defaultEcommercePlatform,
|
||||
defaultProductSetOutput,
|
||||
formatUploadedImageRatio,
|
||||
getPlatformDefaultLanguage,
|
||||
getPlatformDefaultRatio,
|
||||
getPlatformLanguageOptions,
|
||||
getPlatformRatioOptions,
|
||||
getUniqueRatioOptions,
|
||||
normalizeLanguageForPlatform,
|
||||
normalizeMarket,
|
||||
normalizePlatform,
|
||||
normalizeRatioForPlatform,
|
||||
platformOptions,
|
||||
} from "./platformRules";
|
||||
|
||||
describe("platform defaults", () => {
|
||||
it("exposes the default ecommerce platform and outputs", () => {
|
||||
expect(defaultEcommercePlatform).toBe("淘宝/天猫");
|
||||
expect(defaultProductSetOutput).toBe("set");
|
||||
expect(defaultCloneOutput).toBe("set");
|
||||
});
|
||||
|
||||
it("lists platform labels for UI selectors", () => {
|
||||
expect(platformOptions).toContain("淘宝/天猫");
|
||||
expect(platformOptions).toContain("亚马逊 Amazon");
|
||||
expect(platformOptions).toContain("TikTok Shop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePlatform", () => {
|
||||
it("normalizes legacy labels", () => {
|
||||
expect(normalizePlatform("亚马逊Amazon")).toBe("亚马逊 Amazon");
|
||||
expect(normalizePlatform("亚马逊")).toBe("亚马逊 Amazon");
|
||||
});
|
||||
|
||||
it("falls back to the default platform for unknown labels", () => {
|
||||
expect(normalizePlatform("unknown")).toBe("淘宝/天猫");
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform ratios", () => {
|
||||
it("returns mode-specific ratios", () => {
|
||||
expect(getPlatformRatioOptions("淘宝/天猫", "set")).toContain("1000×1000px\u00a0\u00a0\u00a01:1");
|
||||
expect(getPlatformDefaultRatio("淘宝/天猫", "video")).toBe("1080×1920px\u00a0\u00a0\u00a09:16");
|
||||
});
|
||||
|
||||
it("normalizes an existing or partially matching ratio for a platform", () => {
|
||||
expect(normalizeRatioForPlatform("淘宝/天猫", "1000×1000px\u00a0\u00a0\u00a01:1", "set")).toBe("1000×1000px\u00a0\u00a0\u00a01:1");
|
||||
expect(normalizeRatioForPlatform("淘宝/天猫", "1000×1000px", "set")).toBe("1000×1000px\u00a0\u00a0\u00a01:1");
|
||||
});
|
||||
|
||||
it("falls back to the mode default when no ratio matches", () => {
|
||||
expect(normalizeRatioForPlatform("淘宝/天猫", "nope", "set")).toBe("1000×1000px\u00a0\u00a0\u00a01:1");
|
||||
});
|
||||
|
||||
it("deduplicates ratio lists without changing order", () => {
|
||||
expect(getUniqueRatioOptions(["1:1", "3:4", "1:1"])).toEqual(["1:1", "3:4"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("market and language rules", () => {
|
||||
it("normalizes unknown markets to the default country", () => {
|
||||
expect(normalizeMarket("火星")).toBe("中国");
|
||||
});
|
||||
|
||||
it("uses Chinese by default for domestic platforms", () => {
|
||||
expect(getPlatformDefaultLanguage("淘宝/天猫", "美国")).toBe("中文");
|
||||
});
|
||||
|
||||
it("includes English for domestic platforms while preserving local languages", () => {
|
||||
expect(getPlatformLanguageOptions("淘宝/天猫", "美国")).toEqual(["中文", "英文"]);
|
||||
});
|
||||
|
||||
it("uses market languages for cross-border platforms", () => {
|
||||
expect(getPlatformDefaultLanguage("亚马逊 Amazon", "日本")).toBe("日文");
|
||||
});
|
||||
|
||||
it("normalizes language aliases and falls back when not available", () => {
|
||||
expect(normalizeLanguageForPlatform("亚马逊 Amazon", "日本", "日语")).toBe("日文");
|
||||
expect(normalizeLanguageForPlatform("亚马逊 Amazon", "日本", "德语")).toBe("日文");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUploadedImageRatio", () => {
|
||||
it("formats dimensions and aspect ratio", () => {
|
||||
expect(formatUploadedImageRatio({ width: 750, height: 1000, format: "PNG" })).toBe("上传图片 750×1000px\u00a0\u00a0\u00a03:4\u00a0\u00a0\u00a0PNG");
|
||||
});
|
||||
|
||||
it("falls back to original ratio when dimensions are missing", () => {
|
||||
expect(formatUploadedImageRatio({ format: "JPG" })).toBe("上传图片\u00a0\u00a0\u00a0原图比例\u00a0\u00a0\u00a0JPG");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,479 @@
|
||||
import { formatAspectRatio, normalizeRatioToken } from "./ratioUtils";
|
||||
|
||||
export type ProductSetOutputKey = "set" | "detail" | "model" | "video";
|
||||
export type CloneOutputKey = ProductSetOutputKey | "hot";
|
||||
export type PlatformRatioModeKey = ProductSetOutputKey | "hot";
|
||||
|
||||
export interface PlatformRatioGroup {
|
||||
ratios: string[];
|
||||
defaultRatio: string;
|
||||
}
|
||||
|
||||
export interface EcommercePlatformSpec {
|
||||
label: string;
|
||||
ratios: string[];
|
||||
defaultRatio: string;
|
||||
ratioGroups?: Partial<Record<PlatformRatioModeKey, PlatformRatioGroup>>;
|
||||
specs: string[];
|
||||
tip?: string;
|
||||
aliases?: string[];
|
||||
}
|
||||
export const platformSpecOptions: EcommercePlatformSpec[] = [
|
||||
{
|
||||
label: "淘宝/天猫",
|
||||
ratios: ["淘宝主图 / SKU 图 800×800px", "详情页宽 750px", "详情页宽 790px"],
|
||||
defaultRatio: "淘宝主图 / SKU 图 800×800px",
|
||||
ratioGroups: {
|
||||
set: {
|
||||
ratios: ["1000×1000px\u00a0\u00a0\u00a01:1", "800×800px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
detail: {
|
||||
ratios: [
|
||||
"750×1000px\u00a0\u00a0\u00a03:4",
|
||||
"790×1053px\u00a0\u00a0\u00a03:4",
|
||||
"750×1125px\u00a0\u00a0\u00a02:3",
|
||||
"790×1185px\u00a0\u00a0\u00a02:3",
|
||||
],
|
||||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||||
},
|
||||
model: {
|
||||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4"],
|
||||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||||
},
|
||||
video: {
|
||||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1440px\u00a0\u00a0\u00a03:4", "1080×1080px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||||
},
|
||||
hot: {
|
||||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
},
|
||||
specs: ["主图 / SKU 图 800×800px,≤3MB", "详情页宽 750px 或 790px,单张高度≤1546px"],
|
||||
tip: "建议主图 200-400KB JPG,超过 500KB 会影响加载速度。",
|
||||
},
|
||||
{
|
||||
label: "京东",
|
||||
ratios: ["京东主图 / SKU 图 800×800px", "详情页宽 750px", "首图主体占比 ≥80%"],
|
||||
defaultRatio: "京东主图 / SKU 图 800×800px",
|
||||
ratioGroups: {
|
||||
set: {
|
||||
ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
detail: {
|
||||
ratios: [
|
||||
"750×1000px\u00a0\u00a0\u00a03:4",
|
||||
"990×1320px\u00a0\u00a0\u00a03:4",
|
||||
"750×1125px\u00a0\u00a0\u00a02:3",
|
||||
"990×1485px\u00a0\u00a0\u00a02:3",
|
||||
],
|
||||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||||
},
|
||||
model: {
|
||||
ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "990×1485px\u00a0\u00a0\u00a02:3"],
|
||||
defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3",
|
||||
},
|
||||
video: {
|
||||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"],
|
||||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||||
},
|
||||
hot: {
|
||||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
},
|
||||
specs: ["主图 / SKU 图 800×800px,白底,≤3MB", "详情页宽 750px,首图主体占比 ≥80%"],
|
||||
},
|
||||
{
|
||||
label: "拼多多",
|
||||
ratios: ["主图 750×352px", "主图 800×800px", "详情页宽 750px"],
|
||||
defaultRatio: "主图 750×352px",
|
||||
ratioGroups: {
|
||||
set: {
|
||||
ratios: ["800×800px\u00a0\u00a0\u00a01:1", "750×1000px\u00a0\u00a0\u00a03:4"],
|
||||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
detail: {
|
||||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"],
|
||||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||||
},
|
||||
model: {
|
||||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4"],
|
||||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||||
},
|
||||
video: {
|
||||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
|
||||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||||
},
|
||||
hot: {
|
||||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
},
|
||||
specs: ["主图 750×352px 或 800×800px,≤1MB", "详情页宽 750px,要求纯白底、无水印、无拼接"],
|
||||
},
|
||||
{
|
||||
label: "抖音电商",
|
||||
ratios: ["短视频1080×1920px"],
|
||||
defaultRatio: "短视频1080×1920px",
|
||||
ratioGroups: {
|
||||
video: {
|
||||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
|
||||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||||
},
|
||||
hot: {
|
||||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
},
|
||||
specs: ["短视频 1080×1920px,9:16", "30s 内最佳"],
|
||||
},
|
||||
{
|
||||
label: "亚马逊 Amazon",
|
||||
ratios: ["主图 ≥1600×1600px", "建议 2000×2000px+", "最小 500×500px"],
|
||||
defaultRatio: "主图 ≥1600×1600px",
|
||||
ratioGroups: {
|
||||
set: {
|
||||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
detail: {
|
||||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1", "1200×1800px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"],
|
||||
defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3",
|
||||
},
|
||||
model: {
|
||||
ratios: ["1200×1800px\u00a0\u00a0\u00a02:3"],
|
||||
defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3",
|
||||
},
|
||||
video: {
|
||||
ratios: ["1920×1080px\u00a0\u00a0\u00a016:9"],
|
||||
defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9",
|
||||
},
|
||||
hot: {
|
||||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
},
|
||||
specs: ["主图 1600×1600px+,纯白底,≤10MB", "最小 500×500px,建议 2000px+ 以支持缩放"],
|
||||
aliases: ["亚马逊"],
|
||||
},
|
||||
{
|
||||
label: "Shopee",
|
||||
ratios: ["商品主图 1024×1024px", "基础主图 800×800px"],
|
||||
defaultRatio: "商品主图 1024×1024px",
|
||||
ratioGroups: {
|
||||
set: {
|
||||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
detail: {
|
||||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"],
|
||||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||||
},
|
||||
model: {
|
||||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4"],
|
||||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||||
},
|
||||
video: {
|
||||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
|
||||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||||
},
|
||||
hot: {
|
||||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
},
|
||||
specs: ["商品主图推荐 1024×1024px,基础 800×800px", "≤2MB,白底或浅色底"],
|
||||
aliases: ["虾皮 Shopee/Lazada", "虾皮"],
|
||||
},
|
||||
{
|
||||
label: "Lazada",
|
||||
ratios: ["商品主图 800×800px"],
|
||||
defaultRatio: "商品主图 800×800px",
|
||||
ratioGroups: {
|
||||
set: {
|
||||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
detail: {
|
||||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"],
|
||||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||||
},
|
||||
model: {
|
||||
ratios: ["750×1000px\u00a0\u00a0\u00a03:4"],
|
||||
defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4",
|
||||
},
|
||||
video: {
|
||||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
|
||||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||||
},
|
||||
hot: {
|
||||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
},
|
||||
specs: ["商品主图 800×800px,1:1"],
|
||||
},
|
||||
{
|
||||
label: "Instagram",
|
||||
ratios: ["帖子 1080×1350px", "帖子 1080×1080px", "Stories / Reels 1080×1920px", "头像 320×320px"],
|
||||
defaultRatio: "帖子 1080×1350px",
|
||||
ratioGroups: {
|
||||
set: {
|
||||
ratios: ["1080×1080px\u00a0\u00a0\u00a01:1", "1080×1350px\u00a0\u00a0\u00a04:5"],
|
||||
defaultRatio: "1080×1080px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
detail: {
|
||||
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
|
||||
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
|
||||
},
|
||||
model: {
|
||||
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
|
||||
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
|
||||
},
|
||||
video: {
|
||||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1350px\u00a0\u00a0\u00a04:5"],
|
||||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||||
},
|
||||
},
|
||||
specs: ["帖子 1080×1350px 或 1080×1080px", "Stories / Reels 封面 1080×1920px,头像 320×320px"],
|
||||
tip: "建议 ≤8MB JPG。",
|
||||
aliases: ["Instagram Reels"],
|
||||
},
|
||||
{
|
||||
label: "速卖通",
|
||||
ratios: ["主图 800×800px", "主图 1000×1000px+"],
|
||||
defaultRatio: "主图 800×800px",
|
||||
ratioGroups: {
|
||||
set: {
|
||||
ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
detail: {
|
||||
ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "750×1000px\u00a0\u00a0\u00a03:4"],
|
||||
defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3",
|
||||
},
|
||||
model: {
|
||||
ratios: ["750×1125px\u00a0\u00a0\u00a02:3"],
|
||||
defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3",
|
||||
},
|
||||
video: {
|
||||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"],
|
||||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||||
},
|
||||
hot: {
|
||||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
},
|
||||
specs: ["主图建议 800×800px 或更高,1:1", "适合跨境电商主图、SKU 图和场景图"],
|
||||
},
|
||||
{
|
||||
label: "eBay",
|
||||
ratios: ["商品图1:1", "白底多角度展示图 1:1"],
|
||||
defaultRatio: "商品图1:1",
|
||||
ratioGroups: {
|
||||
set: {
|
||||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
detail: {
|
||||
ratios: ["1000×1500px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"],
|
||||
defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3",
|
||||
},
|
||||
model: {
|
||||
ratios: ["1000×1500px\u00a0\u00a0\u00a02:3"],
|
||||
defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3",
|
||||
},
|
||||
video: {
|
||||
ratios: ["1920×1080px\u00a0\u00a0\u00a016:9", "1080×1920px\u00a0\u00a0\u00a09:16"],
|
||||
defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9",
|
||||
},
|
||||
hot: {
|
||||
ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
},
|
||||
specs: ["商品图建议 1:1,主体清晰居中", "适合白底主图和多角度展示图"],
|
||||
},
|
||||
{
|
||||
label: "TikTok Shop",
|
||||
ratios: ["商品主图 1:1", "短视频/ 竖版封面 9:16"],
|
||||
defaultRatio: "商品主图 1:1",
|
||||
ratioGroups: {
|
||||
set: {
|
||||
ratios: ["1280×1280px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "1280×1280px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
detail: {
|
||||
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
|
||||
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
|
||||
},
|
||||
model: {
|
||||
ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"],
|
||||
defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5",
|
||||
},
|
||||
video: {
|
||||
ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"],
|
||||
defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16",
|
||||
},
|
||||
hot: {
|
||||
ratios: ["800×800px\u00a0\u00a0\u00a01:1"],
|
||||
defaultRatio: "800×800px\u00a0\u00a0\u00a01:1",
|
||||
},
|
||||
},
|
||||
specs: ["商品主图建议 1:1", "短视频竖版封面建议 9:16"],
|
||||
},
|
||||
];
|
||||
export const platformOptions = platformSpecOptions.map((option) => option.label);
|
||||
const getPlatformLogoText = (value: string) => {
|
||||
const normalized = value.toLowerCase();
|
||||
if (value.includes("淘宝") || value.includes("天猫")) return "淘";
|
||||
if (value.includes("京东")) return "京";
|
||||
if (value.includes("拼多多") || value.includes("鎷煎澶")) return "拼";
|
||||
if (value.includes("抖音")) return "抖";
|
||||
if (normalized.includes("amazon")) return "a";
|
||||
if (normalized.includes("shopee")) return "S";
|
||||
if (normalized.includes("lazada")) return "L";
|
||||
if (normalized.includes("instagram")) return "IG";
|
||||
if (value.includes("速卖通") || value.includes("閫熷崠閫")) return "AE";
|
||||
if (normalized.includes("ebay")) return "eB";
|
||||
if (normalized.includes("tiktok")) return "♪";
|
||||
return value.trim().slice(0, 1).toUpperCase() || "商";
|
||||
};
|
||||
const getPlatformLogoVariant = (value: string) => {
|
||||
const normalized = value.toLowerCase();
|
||||
if (value.includes("淘宝") || value.includes("天猫")) return "taobao";
|
||||
if (value.includes("京东")) return "jd";
|
||||
if (value.includes("拼多多") || value.includes("鎷煎澶")) return "pdd";
|
||||
if (value.includes("抖音")) return "douyin";
|
||||
if (normalized.includes("amazon")) return "amazon";
|
||||
if (normalized.includes("shopee")) return "shopee";
|
||||
if (normalized.includes("lazada")) return "lazada";
|
||||
if (normalized.includes("instagram")) return "instagram";
|
||||
if (value.includes("速卖通") || value.includes("閫熷崠閫")) return "aliexpress";
|
||||
if (normalized.includes("ebay")) return "ebay";
|
||||
if (normalized.includes("tiktok")) return "tiktok";
|
||||
return "default";
|
||||
};
|
||||
const getPlatformLogoMarks = (value: string) => {
|
||||
if (value.includes("淘宝") || value.includes("天猫")) return ["淘", "猫"];
|
||||
return [getPlatformLogoText(value)];
|
||||
};
|
||||
export const marketLanguageOptions: Array<{ country: string; languages: string[] }> = [
|
||||
{ country: "中国", languages: ["中文"] },
|
||||
{ country: "美国", languages: ["英文"] },
|
||||
{ country: "加拿大", languages: ["英文", "法文"] },
|
||||
{ country: "英国", languages: ["英文"] },
|
||||
{ country: "德国", languages: ["德文"] },
|
||||
{ country: "法国", languages: ["法文"] },
|
||||
{ country: "意大利", languages: ["意大利语"] },
|
||||
{ country: "西班牙", languages: ["西班牙语"] },
|
||||
{ country: "日本", languages: ["日文"] },
|
||||
{ country: "韩国", languages: ["韩文"] },
|
||||
{ country: "澳大利亚", languages: ["英文"] },
|
||||
{ country: "新加坡", languages: ["英文", "中文"] },
|
||||
{ country: "马来西亚", languages: ["马来语", "英文", "中文"] },
|
||||
{ country: "印尼", languages: ["印度尼西亚语", "英文"] },
|
||||
{ country: "越南", languages: ["越南语", "英文"] },
|
||||
{ country: "泰国", languages: ["泰语", "英文"] },
|
||||
{ country: "菲律宾", languages: ["菲律宾语(他加禄语)", "英文"] },
|
||||
{ country: "巴西", languages: ["葡萄牙语"] },
|
||||
{ country: "墨西哥", languages: ["西班牙语"] },
|
||||
{ country: "智利", languages: ["西班牙语"] },
|
||||
{ country: "哥伦比亚", languages: ["西班牙语"] },
|
||||
{ country: "阿联酋", languages: ["阿拉伯语", "英文"] },
|
||||
{ country: "沙特阿拉伯", languages: ["阿拉伯语", "英文"] },
|
||||
{ country: "俄罗斯", languages: ["俄语"] },
|
||||
{ country: "波兰", languages: ["波兰语"] },
|
||||
];
|
||||
export const marketOptions = marketLanguageOptions.map((option) => option.country);
|
||||
export const languageOptions = Array.from(new Set(marketLanguageOptions.flatMap((option) => option.languages)));
|
||||
export const languageAliases: Record<string, string> = {
|
||||
"英文": "英文",
|
||||
"中文": "中文",
|
||||
"英语": "英文",
|
||||
"日语": "日文",
|
||||
"日文": "日文",
|
||||
"德语": "德文",
|
||||
"德文": "德文",
|
||||
"法语": "法文",
|
||||
"法文": "法文",
|
||||
"韩语": "韩文",
|
||||
"韩文": "韩文",
|
||||
"西文": "西班牙语",
|
||||
"西班牙语": "西班牙语",
|
||||
"葡文": "葡萄牙语",
|
||||
"葡萄牙语": "葡萄牙语",
|
||||
"印尼语": "印度尼西亚语",
|
||||
"印度尼西亚语": "印度尼西亚语",
|
||||
"菲律宾语": "菲律宾语(他加禄语)",
|
||||
"菲律宾语(他加禄语)": "菲律宾语(他加禄语)",
|
||||
};
|
||||
export const defaultPlatformSpec = platformSpecOptions[0]!;
|
||||
export const getPlatformSpec = (value: string) =>
|
||||
platformSpecOptions.find((option) => option.label === value || option.aliases?.includes(value)) ?? defaultPlatformSpec;
|
||||
export const legacyPlatformAliases: Record<string, string> = {
|
||||
"淘宝/天猫": "淘宝/天猫",
|
||||
"京东": "京东",
|
||||
"拼多多": "拼多多",
|
||||
"抖音电商": "抖音电商",
|
||||
"亚马逊Amazon": "亚马逊 Amazon",
|
||||
"速卖通": "速卖通",
|
||||
};
|
||||
export const normalizePlatform = (value: string) => getPlatformSpec(legacyPlatformAliases[value] ?? value).label;
|
||||
export const domesticPlatformLabels = new Set(["淘宝/天猫", "京东", "拼多多", "抖音电商"]);
|
||||
export const domesticPlatformLanguages = ["中文"];
|
||||
export const isDomesticPlatform = (platformValue: string) => domesticPlatformLabels.has(normalizePlatform(platformValue));
|
||||
export const getPlatformRatioGroup = (value: string, mode?: PlatformRatioModeKey): PlatformRatioGroup => {
|
||||
const platformSpec = getPlatformSpec(value);
|
||||
return (mode ? platformSpec.ratioGroups?.[mode] : null) ?? {
|
||||
ratios: platformSpec.ratios,
|
||||
defaultRatio: platformSpec.defaultRatio,
|
||||
};
|
||||
};
|
||||
export const getPlatformRatioOptions = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).ratios;
|
||||
export const getPlatformDefaultRatio = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).defaultRatio;
|
||||
export const getUniqueRatioOptions = (ratios: string[]) => Array.from(new Set(ratios));
|
||||
export const normalizeRatioForPlatform = (platformValue: string, ratioValue: string, mode?: PlatformRatioModeKey) => {
|
||||
const platformRatios = getPlatformRatioOptions(platformValue, mode);
|
||||
if (platformRatios.includes(ratioValue)) return ratioValue;
|
||||
const normalizedRatio = normalizeRatioToken(ratioValue);
|
||||
const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio));
|
||||
return matchedRatio ?? getPlatformDefaultRatio(platformValue, mode);
|
||||
};
|
||||
|
||||
export const defaultMarketLanguageOption = marketLanguageOptions[0]!;
|
||||
export const normalizeMarket = (value: string) =>
|
||||
marketLanguageOptions.some((option) => option.country === value) ? value : defaultMarketLanguageOption.country;
|
||||
export const normalizeLanguage = (value: string) => languageAliases[value] ?? value;
|
||||
export const uniqueLanguages = (languages: string[]) => Array.from(new Set(languages));
|
||||
export const appendEnglish = (languages: string[]) => Array.from(new Set([...languages, "英文"]));
|
||||
export const getMarketLanguageOptions = (marketValue: string) =>
|
||||
appendEnglish((marketLanguageOptions.find((option) => option.country === marketValue) ?? defaultMarketLanguageOption).languages);
|
||||
export const getPlatformLanguageOptions = (platformValue: string, marketValue: string) => {
|
||||
const marketLanguages = getMarketLanguageOptions(marketValue);
|
||||
if (!isDomesticPlatform(platformValue)) return marketLanguages;
|
||||
const localLanguages = marketLanguages.filter((item) => item !== "英文");
|
||||
return uniqueLanguages([...localLanguages, ...domesticPlatformLanguages, "英文"]);
|
||||
};
|
||||
export const getPlatformDefaultLanguage = (platformValue: string, marketValue: string) =>
|
||||
isDomesticPlatform(platformValue) ? "中文" : (getPlatformLanguageOptions(platformValue, marketValue)[0] ?? languageOptions[0] ?? "英文");
|
||||
export const normalizeLanguageForPlatform = (platformValue: string, marketValue: string, languageValue: string) => {
|
||||
const normalizedLanguage = normalizeLanguage(languageValue);
|
||||
const platformLanguages = getPlatformLanguageOptions(platformValue, marketValue);
|
||||
return platformLanguages.includes(normalizedLanguage) ? normalizedLanguage : getPlatformDefaultLanguage(platformValue, marketValue);
|
||||
};
|
||||
|
||||
export const defaultEcommercePlatform = "淘宝/天猫";
|
||||
export const defaultProductSetOutput: ProductSetOutputKey = "set";
|
||||
export const defaultCloneOutput: CloneOutputKey = "set";
|
||||
|
||||
export const formatUploadedImageRatio = (image?: { width?: number; height?: number; format?: string }) => {
|
||||
if (!image) return null;
|
||||
const format = image.format ? `\u00a0\u00a0\u00a0${image.format}` : "";
|
||||
if (!image.width || !image.height) return `上传图片\u00a0\u00a0\u00a0原图比例${format}`;
|
||||
return `上传图片 ${image.width}×${image.height}px\u00a0\u00a0\u00a0${formatAspectRatio(image.width, image.height)}${format}`;
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDetailModulePrompt,
|
||||
buildEcommerceImagePrompt,
|
||||
buildSetSubPrompt,
|
||||
setCountLabels,
|
||||
type EcommercePromptDetailModule,
|
||||
} from "./promptBuilder";
|
||||
|
||||
const detailModules: EcommercePromptDetailModule[] = [
|
||||
{ id: "hero", title: "首页焦点图", desc: "集中呈现核心利益点" },
|
||||
{ id: "usage", title: "使用情境图", desc: "还原实际使用画面" },
|
||||
{ id: "spec", title: "参数信息表", desc: "整理商品关键数据" },
|
||||
];
|
||||
|
||||
describe("buildDetailModulePrompt", () => {
|
||||
it("uses the complete-detail prompt when no modules are selected", () => {
|
||||
expect(buildDetailModulePrompt([], detailModules)).toContain("complete A+ detail layout");
|
||||
});
|
||||
|
||||
it("includes only selected modules", () => {
|
||||
const prompt = buildDetailModulePrompt(["hero", "spec"], detailModules);
|
||||
expect(prompt).toContain("首页焦点图: 集中呈现核心利益点");
|
||||
expect(prompt).toContain("参数信息表: 整理商品关键数据");
|
||||
expect(prompt).not.toContain("使用情境图");
|
||||
});
|
||||
|
||||
it("returns an empty prompt for unknown selected ids", () => {
|
||||
expect(buildDetailModulePrompt(["missing"], detailModules)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSetSubPrompt", () => {
|
||||
it("builds white-background prompts with strict background guidance", () => {
|
||||
const prompt = buildSetSubPrompt("white", 0, 1, "淘宝/天猫", "1:1", "中文", "中国");
|
||||
expect(prompt).toContain(setCountLabels.white.label);
|
||||
expect(prompt).toContain("clean white-background product image");
|
||||
expect(prompt).toContain("Platform: 淘宝/天猫. Aspect ratio: 1:1. Language/copy: 中文. Market: 中国.");
|
||||
});
|
||||
|
||||
it("adds variant guidance when generating multiple images", () => {
|
||||
expect(buildSetSubPrompt("scene", 1, 3, "Amazon", "3:4", "英文", "美国")).toContain("variant 2 of 3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildEcommerceImagePrompt", () => {
|
||||
it("builds detail prompts with selected A+ modules", () => {
|
||||
const prompt = buildEcommerceImagePrompt(
|
||||
"detail",
|
||||
"突出轻量化",
|
||||
"京东",
|
||||
"3:4",
|
||||
"中文",
|
||||
"中国",
|
||||
{ detailModules: ["usage"] },
|
||||
detailModules,
|
||||
);
|
||||
expect(prompt).toContain("professional A+ detail page");
|
||||
expect(prompt).toContain("使用情境图: 还原实际使用画面");
|
||||
expect(prompt).toContain("Additional user requirements: 突出轻量化");
|
||||
});
|
||||
|
||||
it("builds model prompts with model attributes and scenes", () => {
|
||||
const prompt = buildEcommerceImagePrompt(
|
||||
"model",
|
||||
"",
|
||||
"Shopee",
|
||||
"3:4",
|
||||
"英文",
|
||||
"美国",
|
||||
{ gender: "女", age: "青年", ethnicity: "亚洲人", body: "标准", appearance: "短发", scenes: ["都市街头"], smartScene: true },
|
||||
);
|
||||
expect(prompt).toContain("Model gender: 女.");
|
||||
expect(prompt).toContain("Background scenes: 都市街头.");
|
||||
expect(prompt).toContain("Use smart scene matching");
|
||||
});
|
||||
|
||||
it("builds hot-replication prompts", () => {
|
||||
const prompt = buildEcommerceImagePrompt("hot", "", "TikTok Shop", "9:16", "英文", "美国");
|
||||
expect(prompt).toContain("closely replicates the style");
|
||||
expect(prompt).toContain("TikTok Shop marketplace standards");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { CloneOutputKey } from "./platformRules";
|
||||
|
||||
export type EcommerceSetCountKey = "selling" | "white" | "scene";
|
||||
|
||||
export interface EcommercePromptDetailModule {
|
||||
id: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface EcommerceImagePromptOptions {
|
||||
gender?: string;
|
||||
age?: string;
|
||||
ethnicity?: string;
|
||||
body?: string;
|
||||
appearance?: string;
|
||||
scenes?: string[];
|
||||
customScene?: string;
|
||||
smartScene?: boolean;
|
||||
detailModules?: string[];
|
||||
}
|
||||
|
||||
export const setCountLabels: Record<EcommerceSetCountKey, { label: string; promptDesc: string }> = {
|
||||
selling: { label: "卖点图", promptDesc: "selling-point infographic image highlighting core product advantages and detail close-ups" },
|
||||
white: { label: "白底图", promptDesc: "clean white-background product photo showing the item from its best angle, studio lighting, no props" },
|
||||
scene: { label: "场景图", promptDesc: "lifestyle scene image showing the product in a realistic usage environment with natural surroundings" },
|
||||
};
|
||||
|
||||
export const buildDetailModulePrompt = (moduleIds: string[], modules: EcommercePromptDetailModule[]): string => {
|
||||
if (!moduleIds.length) {
|
||||
return "Generate a complete A+ detail layout with hero, selling points, usage scene, product detail, and specification modules.";
|
||||
}
|
||||
|
||||
const selectedModules = modules.filter((module) => moduleIds.includes(module.id));
|
||||
if (!selectedModules.length) return "";
|
||||
|
||||
const moduleList = selectedModules.map((module) => `${module.title}: ${module.desc}`).join("; ");
|
||||
return `Only generate these selected A+ detail modules, no extra modules: ${moduleList}. Keep the output focused even if only one or two modules are selected.`;
|
||||
};
|
||||
|
||||
export const buildSetSubPrompt = (
|
||||
countKey: EcommerceSetCountKey,
|
||||
index: number,
|
||||
totalCount: number,
|
||||
platform: string,
|
||||
ratio: string,
|
||||
language: string,
|
||||
market: string,
|
||||
): string => {
|
||||
const info = setCountLabels[countKey];
|
||||
const parts: string[] = [];
|
||||
parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`);
|
||||
parts.push(info.promptDesc);
|
||||
if (countKey === "white") {
|
||||
parts.push("The output must be a clean white-background product image. Do not use lifestyle backgrounds, props, text overlays, or people.");
|
||||
}
|
||||
if (countKey === "scene") {
|
||||
parts.push("The output must be a realistic usage scene image. Keep the product clearly visible and preserve its shape, color, and key details.");
|
||||
}
|
||||
if (countKey === "selling") {
|
||||
parts.push("The output must be a selling-point graphic with clear hierarchy, concise copy, and product detail callouts.");
|
||||
}
|
||||
if (totalCount > 1) {
|
||||
parts.push(`This is variant ${index + 1} of ${totalCount} —vary the angle, composition, or emphasis to make each distinct.`);
|
||||
}
|
||||
parts.push(`Platform: ${platform}. Aspect ratio: ${ratio}. Language/copy: ${language}. Market: ${market}.`);
|
||||
parts.push("Must comply with platform image guidelines —proper margins, no watermark, professional quality.");
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
export const buildEcommerceImagePrompt = (
|
||||
outputKey: CloneOutputKey,
|
||||
userText: string,
|
||||
platform: string,
|
||||
ratio: string,
|
||||
language: string,
|
||||
market: string,
|
||||
options?: EcommerceImagePromptOptions,
|
||||
detailModules: EcommercePromptDetailModule[] = [],
|
||||
): string => {
|
||||
const parts: string[] = [];
|
||||
if (outputKey === "detail") {
|
||||
parts.push("Generate a professional A+ detail page hero image for an e-commerce product listing.");
|
||||
parts.push("Create a high-impact first-screen visual that combines the product photo with key selling points, usage scenes, and detailed specifications in a cohesive layout.");
|
||||
parts.push(`Platform: ${platform}. Aspect ratio: ${ratio}. Language/copy: ${language}. Market: ${market}.`);
|
||||
if (options?.detailModules) parts.push(buildDetailModulePrompt(options.detailModules, detailModules));
|
||||
parts.push("Follow platform A+ page best practices —clear hierarchy, professional typography, high visual impact.");
|
||||
} else if (outputKey === "model") {
|
||||
parts.push("Generate model/try-on lifestyle images for an e-commerce product listing.");
|
||||
parts.push("Show the product being used or worn by a model in attractive lifestyle settings.");
|
||||
parts.push(`Platform: ${platform}. Aspect ratio: ${ratio}. Language/copy: ${language}. Market: ${market}.`);
|
||||
if (options) {
|
||||
if (options.gender) parts.push(`Model gender: ${options.gender}.`);
|
||||
if (options.age) parts.push(`Model age: ${options.age}.`);
|
||||
if (options.ethnicity) parts.push(`Model ethnicity: ${options.ethnicity}.`);
|
||||
if (options.body) parts.push(`Model body type: ${options.body}.`);
|
||||
if (options.appearance) parts.push(`Model appearance details: ${options.appearance}.`);
|
||||
if (options.scenes?.length) parts.push(`Background scenes: ${options.scenes.join(", ")}.`);
|
||||
if (options.customScene) parts.push(`Custom background scene: ${options.customScene}.`);
|
||||
if (options.smartScene) parts.push("Use smart scene matching to select the best background context.");
|
||||
}
|
||||
parts.push("Model should appear natural and appealing. Background should complement the product. Image must meet platform standards.");
|
||||
} else if (outputKey === "hot") {
|
||||
parts.push("Generate a high-conversion e-commerce product image that closely replicates the style and composition of the reference image while adapting it to the target platform.");
|
||||
parts.push(`Replicate the visual style, color palette, and layout feel of the source product image, then adapt it for ${platform} marketplace standards.`);
|
||||
parts.push(`Platform: ${platform}. Aspect ratio: ${ratio}. Language/copy: ${language}. Market: ${market}.`);
|
||||
parts.push("The result must look professional and optimized for high click-through rate and conversion on the specified platform.");
|
||||
}
|
||||
if (userText.trim()) {
|
||||
parts.push(`Additional user requirements: ${userText.trim()}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
normalizeRatioToken,
|
||||
greatestCommonDivisor,
|
||||
formatAspectRatio,
|
||||
getQuickSetRatioValue,
|
||||
formatRatioDisplayValue,
|
||||
getRatioDisplayParts,
|
||||
parseRatioToAspectCss,
|
||||
toSupportedImageApiRatio,
|
||||
normalizeRatioForApi,
|
||||
} from "./ratioUtils";
|
||||
|
||||
describe("normalizeRatioToken", () => {
|
||||
it("normalizes non-breaking spaces", () => {
|
||||
expect(normalizeRatioToken("800\u00a0\u00a0px")).toBe("800 px");
|
||||
});
|
||||
|
||||
it("replaces plain separators with the normal multiplication sign", () => {
|
||||
expect(normalizeRatioToken("800*800")).toBe("800×800");
|
||||
});
|
||||
|
||||
it("replaces legacy mojibake multiply signs", () => {
|
||||
expect(normalizeRatioToken("800\u8133800")).toBe("800×800");
|
||||
});
|
||||
|
||||
it("replaces fullwidth and legacy mojibake colons", () => {
|
||||
expect(normalizeRatioToken("1:1")).toBe("1:1");
|
||||
expect(normalizeRatioToken("1\u951b?1")).toBe("1:1");
|
||||
});
|
||||
|
||||
it("collapses whitespace and trims", () => {
|
||||
expect(normalizeRatioToken(" 1 : 1 ")).toBe("1 : 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("greatestCommonDivisor", () => {
|
||||
it("computes GCD", () => {
|
||||
expect(greatestCommonDivisor(12, 8)).toBe(4);
|
||||
expect(greatestCommonDivisor(1920, 1080)).toBe(120);
|
||||
});
|
||||
|
||||
it("handles zero with fallback to 1", () => {
|
||||
expect(greatestCommonDivisor(0, 5)).toBe(5);
|
||||
expect(greatestCommonDivisor(0, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it("handles negatives via abs", () => {
|
||||
expect(greatestCommonDivisor(-12, 8)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAspectRatio", () => {
|
||||
it("reduces 1920x1080 to 16:9", () => {
|
||||
expect(formatAspectRatio(1920, 1080)).toBe("16:9");
|
||||
});
|
||||
|
||||
it("reduces 750x1000 to 3:4", () => {
|
||||
expect(formatAspectRatio(750, 1000)).toBe("3:4");
|
||||
});
|
||||
|
||||
it("reduces 800x800 to 1:1", () => {
|
||||
expect(formatAspectRatio(800, 800)).toBe("1:1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQuickSetRatioValue", () => {
|
||||
it("passes through a canonical quick-set value", () => {
|
||||
expect(getQuickSetRatioValue("1:1")).toBe("1:1");
|
||||
});
|
||||
|
||||
it("derives from a WxH size string", () => {
|
||||
expect(getQuickSetRatioValue("1920×1080px")).toBe("16:9");
|
||||
expect(getQuickSetRatioValue("750×1000px")).toBe("3:4");
|
||||
});
|
||||
|
||||
it("derives from a raw ratio string", () => {
|
||||
expect(getQuickSetRatioValue("9:16")).toBe("9:16");
|
||||
});
|
||||
|
||||
it("falls back to 1:1 for unparseable input", () => {
|
||||
expect(getQuickSetRatioValue("unknown")).toBe("1:1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRatioDisplayValue", () => {
|
||||
it("formats a WxHpx string with aspect suffix", () => {
|
||||
expect(formatRatioDisplayValue("1000×1000px 1:1")).toBe("1000×1000px\u00a0\u00a0\u00a01:1");
|
||||
});
|
||||
|
||||
it("reformats 800x800px without explicit aspect", () => {
|
||||
expect(formatRatioDisplayValue("800x800px")).toBe("800×800px\u00a0\u00a0\u00a01:1");
|
||||
});
|
||||
|
||||
it("replaces legacy mojibake product image labels", () => {
|
||||
expect(formatRatioDisplayValue("\u935f\u55d7\u6427\u9365?")).toBe("商品图");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRatioDisplayParts", () => {
|
||||
it("splits size and aspect", () => {
|
||||
expect(getRatioDisplayParts("1000×1000px 1:1")).toEqual({
|
||||
size: "1000×1000px",
|
||||
aspect: "1:1",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses 自适应 when no aspect present", () => {
|
||||
const parts = getRatioDisplayParts("原图");
|
||||
expect(parts.aspect).toBe("自适应");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRatioToAspectCss", () => {
|
||||
it("extracts CSS aspect-ratio", () => {
|
||||
expect(parseRatioToAspectCss("1000×1000px 1:1")).toBe("1000 / 1000");
|
||||
});
|
||||
|
||||
it("falls back to 1 / 1", () => {
|
||||
expect(parseRatioToAspectCss("no numbers")).toBe("1 / 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSupportedImageApiRatio", () => {
|
||||
it("snaps square to 1:1", () => {
|
||||
expect(toSupportedImageApiRatio(800, 800)).toBe("1:1");
|
||||
});
|
||||
|
||||
it("snaps 750x1000 to 3:4", () => {
|
||||
expect(toSupportedImageApiRatio(750, 1000)).toBe("3:4");
|
||||
});
|
||||
|
||||
it("snaps 1920x1080 to 16:9", () => {
|
||||
expect(toSupportedImageApiRatio(1920, 1080)).toBe("16:9");
|
||||
});
|
||||
|
||||
it("snaps 1080x1920 to 9:16", () => {
|
||||
expect(toSupportedImageApiRatio(1080, 1920)).toBe("9:16");
|
||||
});
|
||||
|
||||
it("snaps 800x600 to 4:3", () => {
|
||||
expect(toSupportedImageApiRatio(800, 600)).toBe("4:3");
|
||||
});
|
||||
|
||||
it("returns 1:1 for non-finite or non-positive", () => {
|
||||
expect(toSupportedImageApiRatio(NaN, 100)).toBe("1:1");
|
||||
expect(toSupportedImageApiRatio(0, 100)).toBe("1:1");
|
||||
expect(toSupportedImageApiRatio(-1, 100)).toBe("1:1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeRatioForApi", () => {
|
||||
it("extracts the explicit ratio from a display string", () => {
|
||||
expect(normalizeRatioForApi("1000×1000px 1:1")).toBe("1:1");
|
||||
expect(normalizeRatioForApi("750×1000px 3:4")).toBe("3:4");
|
||||
});
|
||||
|
||||
it("derives ratio from a bare size string", () => {
|
||||
expect(normalizeRatioForApi("1920×1080px")).toBe("16:9");
|
||||
});
|
||||
|
||||
it("returns 1:1 for unparseable input", () => {
|
||||
expect(normalizeRatioForApi("")).toBe("1:1");
|
||||
expect(normalizeRatioForApi("无尺寸信息")).toBe("1:1");
|
||||
});
|
||||
|
||||
it("uses the last explicit ratio when multiple present", () => {
|
||||
expect(normalizeRatioForApi("4:3 16:9")).toBe("16:9");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
// Ratio and dimension formatting helpers.
|
||||
// Keep compatibility with a few legacy mojibake tokens, but never emit them.
|
||||
// normalizeRatioForPlatform / formatUploadedImageRatio 因依赖平台规格表与 CloneImageItem,
|
||||
// 暂留在 EcommercePage.tsx,后续随 platformSpec 一起整理。
|
||||
|
||||
const LEGACY_MULTIPLY_SIGN = "\u8133";
|
||||
const LEGACY_FULLWIDTH_COLON = "\u951b?";
|
||||
const LEGACY_PRODUCT_IMAGE_LABEL = "\u935f\u55d7\u6427\u9365?";
|
||||
|
||||
export const normalizeRatioToken = (value: string) =>
|
||||
value
|
||||
.replaceAll("\u00a0", " ")
|
||||
.replaceAll(LEGACY_MULTIPLY_SIGN, "×")
|
||||
.replaceAll("*", "×")
|
||||
.replaceAll(":", ":")
|
||||
.replaceAll(LEGACY_FULLWIDTH_COLON, ":")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
export const quickSetRatioOptions = ["1:1", "3:4", "4:3", "9:16", "16:9"];
|
||||
|
||||
export const greatestCommonDivisor = (left: number, right: number): number => {
|
||||
let a = Math.abs(left);
|
||||
let b = Math.abs(right);
|
||||
while (b) {
|
||||
[a, b] = [b, a % b];
|
||||
}
|
||||
return a || 1;
|
||||
};
|
||||
|
||||
export const formatAspectRatio = (width: number, height: number) => {
|
||||
const divisor = greatestCommonDivisor(width, height);
|
||||
return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`;
|
||||
};
|
||||
|
||||
export const getQuickSetRatioValue = (value: string) => {
|
||||
const normalizedValue = normalizeRatioToken(value);
|
||||
if (quickSetRatioOptions.includes(normalizedValue)) return normalizedValue;
|
||||
const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)/u);
|
||||
if (sizeMatch) {
|
||||
const width = Number(sizeMatch[1]);
|
||||
const height = Number(sizeMatch[2]);
|
||||
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
|
||||
const aspect = formatAspectRatio(width, height);
|
||||
if (quickSetRatioOptions.includes(aspect)) return aspect;
|
||||
}
|
||||
}
|
||||
const ratioMatch = normalizedValue.match(/(\d+)\s*[::]\s*(\d+)/u);
|
||||
if (ratioMatch) {
|
||||
const aspect = `${Number(ratioMatch[1])}:${Number(ratioMatch[2])}`;
|
||||
if (quickSetRatioOptions.includes(aspect)) return aspect;
|
||||
}
|
||||
return quickSetRatioOptions[0]!;
|
||||
};
|
||||
|
||||
export const formatRatioDisplayValue = (value: string) => {
|
||||
const normalizedValue = normalizeRatioToken(value);
|
||||
const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)\s*px?/u);
|
||||
if (sizeMatch) {
|
||||
const width = Number(sizeMatch[1]);
|
||||
const height = Number(sizeMatch[2]);
|
||||
return `${width}×${height}px\u00a0\u00a0\u00a0${formatAspectRatio(width, height)}`;
|
||||
}
|
||||
return normalizedValue
|
||||
.replace("淘宝主图 / SKU 图 ", "淘宝主图 / SKU 图 ")
|
||||
.replace("京东主图 / SKU 图 ", "京东主图 / SKU 图 ")
|
||||
.replace("详情页宽", "详情页宽")
|
||||
.replace("短视频", "短视频")
|
||||
.replace("主图", "主图")
|
||||
.replace("商品主图", "商品主图")
|
||||
.replace(LEGACY_PRODUCT_IMAGE_LABEL, "商品图")
|
||||
.replace(/\s+:/g, ":")
|
||||
.replace(/:\s+/g, ":");
|
||||
};
|
||||
|
||||
export const getRatioDisplayParts = (value: string) => {
|
||||
const display = formatRatioDisplayValue(value).replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
|
||||
const aspectMatch = display.match(/(\d+\s*[::]\s*\d+)(?!.*\d+\s*[::]\s*\d+)/u);
|
||||
const aspect = aspectMatch?.[1]?.replace(/\s+/g, "") ?? "自适应";
|
||||
const size = aspectMatch ? display.replace(aspectMatch[0], "").trim() : display;
|
||||
return {
|
||||
size: size || "原图比例",
|
||||
aspect,
|
||||
};
|
||||
};
|
||||
|
||||
/** Extract CSS aspect-ratio from a ratio string like "1000x1000px 1:1" -> "1 / 1" */
|
||||
export const parseRatioToAspectCss = (ratioStr: string): string => {
|
||||
const match = ratioStr.match(/(\d+)\D+(\d+)/u);
|
||||
if (!match) return "1 / 1";
|
||||
return `${match[1]} / ${match[2]}`;
|
||||
};
|
||||
|
||||
export const supportedImageApiRatios = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const;
|
||||
export type SupportedImageApiRatio = typeof supportedImageApiRatios[number];
|
||||
|
||||
export const toSupportedImageApiRatio = (width: number, height: number): SupportedImageApiRatio => {
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return "1:1";
|
||||
let bestRatio: SupportedImageApiRatio = "1:1";
|
||||
let bestScore = Number.POSITIVE_INFINITY;
|
||||
const target = Math.log(width / height);
|
||||
for (const ratio of supportedImageApiRatios) {
|
||||
const [left, right] = ratio.split(":").map(Number);
|
||||
const score = Math.abs(target - Math.log(left / right));
|
||||
if (score < bestScore) {
|
||||
bestRatio = ratio;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return bestRatio;
|
||||
};
|
||||
|
||||
/** Normalize ratio display string ("1000×1000px 1:1") to an image API aspect ratio ("1:1"). */
|
||||
export const normalizeRatioForApi = (ratioStr: string): string => {
|
||||
const normalizedValue = normalizeRatioToken(ratioStr);
|
||||
const explicitRatios = Array.from(normalizedValue.matchAll(/(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)/g));
|
||||
const explicitRatio = explicitRatios.at(-1);
|
||||
if (explicitRatio) {
|
||||
return toSupportedImageApiRatio(Number(explicitRatio[1]), Number(explicitRatio[2]));
|
||||
}
|
||||
|
||||
const sizeMatch = normalizedValue.match(/(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)/u);
|
||||
if (!sizeMatch) return "1:1";
|
||||
return toSupportedImageApiRatio(Number(sizeMatch[1]), Number(sizeMatch[2]));
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./workbenchDownload.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useGenerationTasks.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useTypewriter.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./backgroundTaskRunner.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./index.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useAppStore.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useGenerationStore.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useProjectStore.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useSessionStore.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./useTaskStore.ts";
|
||||
@@ -12,9 +12,7 @@
|
||||
}
|
||||
|
||||
.ecommerce-standalone__topbar {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
z-index: 80;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -22,8 +20,7 @@
|
||||
min-height: 64px;
|
||||
padding: 10px clamp(16px, 3vw, 32px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 12, 10, 0.78);
|
||||
backdrop-filter: blur(18px);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ecommerce-standalone__brand,
|
||||
@@ -66,7 +63,6 @@
|
||||
|
||||
.ecommerce-standalone__content {
|
||||
height: 100vh;
|
||||
padding-top: 64px;
|
||||
}
|
||||
|
||||
/* 工作台与个人中心常驻同层,用 hidden 切换以保活生成任务状态。
|
||||
@@ -230,7 +226,6 @@
|
||||
}
|
||||
|
||||
.ecommerce-standalone__content {
|
||||
padding-top: 58px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,8 +244,7 @@
|
||||
|
||||
.ecommerce-standalone__topbar {
|
||||
border-bottom-color: rgba(126, 235, 255, 0.22);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(7, 72, 121, 0.94), rgba(4, 37, 75, 0.92));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ecommerce-standalone__brand::before {
|
||||
@@ -318,7 +312,7 @@
|
||||
|
||||
.ecommerce-standalone__topbar {
|
||||
border-bottom-color: rgba(30, 189, 219, 0.16) !important;
|
||||
background: #f8f9fa !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone__brand::before {
|
||||
@@ -2781,7 +2775,7 @@
|
||||
position: absolute !important;
|
||||
left: 50% !important;
|
||||
z-index: 12 !important;
|
||||
width: min(1036px, calc(100% - 56px)) !important;
|
||||
width: min(1220px, calc(100% - 40px)) !important;
|
||||
margin-right: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
;
|
||||
@@ -4537,6 +4531,275 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page {
|
||||
display: block !important;
|
||||
height: 100% !important;
|
||||
min-height: calc(100vh - 58px) !important;
|
||||
overflow: hidden !important;
|
||||
background: #f3f5f8 !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .product-clone-shell {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .product-clone-rail,
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .product-clone-panel,
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .clone-ai-settings-toggle,
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-command-history {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Hot Clone: requirement input in left panel ── */
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-requirement {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 8px !important;
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-requirement__head {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-requirement__head > strong {
|
||||
font-size: 13px !important;
|
||||
font-weight: 800 !important;
|
||||
color: #1a2b3c !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-requirement__input {
|
||||
position: relative !important;
|
||||
min-height: 158px !important;
|
||||
border: 1px dashed rgba(30, 189, 219, 0.34) !important;
|
||||
border-radius: 8px !important;
|
||||
background: linear-gradient(180deg, rgba(237, 248, 255, 0.72), rgba(255, 255, 255, 0.94)) !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-requirement__input textarea {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
min-height: 140px !important;
|
||||
max-height: 240px !important;
|
||||
resize: none !important;
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
padding: 14px 14px 24px !important;
|
||||
color: #172636 !important;
|
||||
background: transparent !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 1.6 !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-requirement__input textarea::placeholder {
|
||||
color: #9badb9 !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-requirement__input > span {
|
||||
position: absolute !important;
|
||||
right: 12px !important;
|
||||
bottom: 6px !important;
|
||||
color: #9badb9 !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-requirement__ai {
|
||||
padding: 3px 12px !important;
|
||||
border: 1.5px solid rgba(16, 115, 204, 0.18) !important;
|
||||
border-radius: 20px !important;
|
||||
background: linear-gradient(135deg, rgba(16, 115, 204, 0.06), rgba(25, 173, 200, 0.06)) !important;
|
||||
color: #1073cc !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 800 !important;
|
||||
cursor: pointer !important;
|
||||
white-space: nowrap !important;
|
||||
flex-shrink: 0 !important;
|
||||
transition: background 160ms ease, border-color 160ms ease !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-requirement__ai:hover {
|
||||
background: linear-gradient(135deg, rgba(16, 115, 204, 0.12), rgba(25, 173, 200, 0.12)) !important;
|
||||
border-color: rgba(16, 115, 204, 0.3) !important;
|
||||
}
|
||||
|
||||
/* ── Hot Clone: material upload with images ── */
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-material.has-images {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
align-items: flex-start !important;
|
||||
justify-content: flex-start !important;
|
||||
gap: 10px !important;
|
||||
padding: 12px !important;
|
||||
place-items: unset !important;
|
||||
background: #f9fafa !important;
|
||||
border: 1px solid rgba(30, 189, 219, 0.22) !important;
|
||||
border-style: solid !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-quick-upload-thumbs {
|
||||
display: contents !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-quick-upload-thumbs figure {
|
||||
position: relative !important;
|
||||
width: 80px !important;
|
||||
height: 80px !important;
|
||||
margin: 0 !important;
|
||||
border: 1px solid #e8edf0 !important;
|
||||
border-radius: 10px !important;
|
||||
overflow: hidden !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-quick-upload-thumbs figure > img {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: cover !important;
|
||||
border-radius: 9px !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-quick-upload-thumbs figure > button {
|
||||
position: absolute !important;
|
||||
top: 4px !important;
|
||||
right: 4px !important;
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
border: none !important;
|
||||
border-radius: 50% !important;
|
||||
background: rgba(0, 0, 0, 0.48) !important;
|
||||
color: #fff !important;
|
||||
font-size: 11px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
cursor: pointer !important;
|
||||
opacity: 0 !important;
|
||||
transition: opacity 140ms ease, background 140ms ease !important;
|
||||
padding: 0 !important;
|
||||
z-index: 3 !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-quick-upload-thumbs figure:hover > button {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-quick-upload-thumbs figure > button:hover {
|
||||
background: rgba(220, 53, 69, 0.85) !important;
|
||||
}
|
||||
|
||||
/* Hide old CSS zoom in material section (portal replaces it) */
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-command-asset-zoom {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-add-btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 80px !important;
|
||||
height: 80px !important;
|
||||
border: 1px solid #e8edf0 !important;
|
||||
border-radius: 10px !important;
|
||||
color: #3a4555 !important;
|
||||
background: #f5f5f5 !important;
|
||||
font-size: 22px !important;
|
||||
cursor: pointer !important;
|
||||
transition: background 160ms ease, border-color 160ms ease, color 160ms ease !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-add-btn:hover {
|
||||
color: #ffffff !important;
|
||||
background: linear-gradient(135deg, #1073cc, #1ebddb) !important;
|
||||
border-color: #1073cc !important;
|
||||
}
|
||||
|
||||
/* ── Hot Clone: sticky bottom action buttons ── */
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-actions {
|
||||
position: sticky !important;
|
||||
bottom: 0 !important;
|
||||
z-index: 5 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 8px !important;
|
||||
margin: 0 -14px -86px -14px !important;
|
||||
padding: 14px 14px 16px !important;
|
||||
background: linear-gradient(to top, #feffff 60%, rgba(254, 255, 255, 0.92) 80%, transparent) !important;
|
||||
backdrop-filter: blur(6px) !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-actions .ecom-quick-set-primary {
|
||||
position: static !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-actions .ecom-quick-set-primary--cancel.is-disabled {
|
||||
color: #c0ccd4 !important;
|
||||
background: #f0f3f5 !important;
|
||||
border-color: #e4e9ec !important;
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.55 !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-hot-actions .ecom-quick-set-primary--cancel.is-disabled:hover {
|
||||
color: #c0ccd4 !important;
|
||||
background: #f0f3f5 !important;
|
||||
border-color: #e4e9ec !important;
|
||||
}
|
||||
|
||||
/* ── Hot Clone: stage fills space without prompt ── */
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-set-stage {
|
||||
grid-template-rows: auto minmax(0, 1fr) !important;
|
||||
}
|
||||
|
||||
/* ── Hot Clone: portal zoom preview (avoids overflow clipping) ── */
|
||||
.ecom-hot-material-zoom-portal {
|
||||
position: fixed !important;
|
||||
z-index: 2147483647 !important;
|
||||
width: min(280px, calc(100vw - 24px)) !important;
|
||||
max-height: 340px !important;
|
||||
border: 1px solid rgba(30, 189, 219, 0.2) !important;
|
||||
border-radius: 14px !important;
|
||||
background: #ffffff !important;
|
||||
padding: 8px !important;
|
||||
box-shadow: 0 22px 48px rgba(20, 80, 100, 0.22) !important;
|
||||
pointer-events: none !important;
|
||||
isolation: isolate !important;
|
||||
}
|
||||
|
||||
.ecom-hot-material-zoom-portal.is-above {
|
||||
transform: translate(-50%, -100%) !important;
|
||||
}
|
||||
|
||||
.ecom-hot-material-zoom-portal.is-below {
|
||||
transform: translate(-50%, 0) !important;
|
||||
}
|
||||
|
||||
.ecom-hot-material-zoom-portal img {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
max-height: 324px !important;
|
||||
border-radius: 8px !important;
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"].is-watermark-page {
|
||||
display: block !important;
|
||||
height: 100% !important;
|
||||
@@ -6783,31 +7046,36 @@
|
||||
background: rgba(16, 115, 204, 0.28) !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-detail-page .ecom-quick-set-panel {
|
||||
.ecommerce-standalone .ecom-quick-detail-page .ecom-quick-set-panel,
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-set-panel {
|
||||
overflow-y: auto !important;
|
||||
padding-bottom: 16px !important;
|
||||
scrollbar-width: auto !important;
|
||||
scrollbar-color: rgba(16, 115, 204, 0.56) rgba(16, 115, 204, 0.08) !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-detail-page .ecom-quick-set-panel::-webkit-scrollbar {
|
||||
.ecommerce-standalone .ecom-quick-detail-page .ecom-quick-set-panel::-webkit-scrollbar,
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-set-panel::-webkit-scrollbar {
|
||||
display: block !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-detail-page .ecom-quick-set-panel::-webkit-scrollbar-track {
|
||||
.ecommerce-standalone .ecom-quick-detail-page .ecom-quick-set-panel::-webkit-scrollbar-track,
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-set-panel::-webkit-scrollbar-track {
|
||||
border-radius: 999px !important;
|
||||
background: rgba(16, 115, 204, 0.08) !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-detail-page .ecom-quick-set-panel::-webkit-scrollbar-thumb {
|
||||
.ecommerce-standalone .ecom-quick-detail-page .ecom-quick-set-panel::-webkit-scrollbar-thumb,
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-set-panel::-webkit-scrollbar-thumb {
|
||||
border: 3px solid rgba(248, 249, 250, 0.95) !important;
|
||||
border-radius: 999px !important;
|
||||
background: rgba(16, 115, 204, 0.56) !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .ecom-quick-detail-page .ecom-quick-set-panel::-webkit-scrollbar-thumb:hover {
|
||||
.ecommerce-standalone .ecom-quick-detail-page .ecom-quick-set-panel::-webkit-scrollbar-thumb:hover,
|
||||
.ecommerce-standalone .ecom-quick-hot-page .ecom-quick-set-panel::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(16, 115, 204, 0.72) !important;
|
||||
}
|
||||
|
||||
@@ -8766,6 +9034,58 @@
|
||||
z-index: 0 !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"] .ecom-command-template-strip {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
gap: 10px !important;
|
||||
width: min(100%, 1088px) !important;
|
||||
margin: 16px auto 12px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"] .ecom-command-template-card {
|
||||
position: relative !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
aspect-ratio: 3.06 / 1 !important;
|
||||
min-height: 100px !important;
|
||||
padding: 0 !important;
|
||||
border: 1px solid rgba(30, 189, 219, 0.18) !important;
|
||||
border-radius: 14px !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(246, 251, 253, 0.98)),
|
||||
#ffffff !important;
|
||||
box-shadow:
|
||||
0 16px 34px rgba(16, 115, 204, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.94) !important;
|
||||
overflow: hidden !important;
|
||||
cursor: pointer !important;
|
||||
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease, background 180ms ease !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"] .ecom-command-template-card:hover {
|
||||
transform: translateY(-1px) !important;
|
||||
border-color: rgba(30, 189, 219, 0.34) !important;
|
||||
box-shadow:
|
||||
0 20px 42px rgba(16, 115, 204, 0.11),
|
||||
0 0 0 1px rgba(30, 189, 219, 0.12) inset !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"] .ecom-command-template-card:focus-visible {
|
||||
outline: none !important;
|
||||
border-color: rgba(30, 189, 219, 0.48) !important;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(30, 189, 219, 0.15),
|
||||
0 20px 42px rgba(16, 115, 204, 0.11) !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"] .ecom-command-template-card__blank {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
background: #f8f9fa !important;
|
||||
transition: padding-top 520ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
@@ -8984,6 +9304,10 @@
|
||||
min-height: 224px !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"] .ecom-command-template-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"] .ecom-command-quick-board button:not(:nth-child(6n + 1))::before {
|
||||
content: none !important;
|
||||
}
|
||||
@@ -9014,6 +9338,18 @@
|
||||
min-height: 60px !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"] .ecom-command-template-strip {
|
||||
grid-template-columns: minmax(0, 1fr) !important;
|
||||
gap: 8px !important;
|
||||
width: min(100%, 540px) !important;
|
||||
margin: 12px auto 10px !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"] .ecom-command-template-card {
|
||||
aspect-ratio: 2.9 / 1 !important;
|
||||
min-height: 84px !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
padding-inline: 18px !important;
|
||||
}
|
||||
@@ -9050,6 +9386,50 @@
|
||||
content: "" !important;
|
||||
background: rgba(30, 189, 219, 0.12) !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images {
|
||||
gap: 8px !important;
|
||||
padding: 8px !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images .ecom-quick-hot-add-btn,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-quick-hot-add-btn {
|
||||
width: 56px !important;
|
||||
height: 56px !important;
|
||||
min-width: 56px !important;
|
||||
min-height: 56px !important;
|
||||
flex-basis: 56px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"] .ecom-command-template-strip {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.ecommerce-standalone .product-clone-page[data-tool="clone"] .ecom-command-template-card {
|
||||
min-height: 76px !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images {
|
||||
gap: 6px !important;
|
||||
padding: 6px !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images .ecom-quick-hot-add-btn,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-quick-hot-add-btn {
|
||||
width: 50px !important;
|
||||
height: 50px !important;
|
||||
min-width: 50px !important;
|
||||
min-height: 50px !important;
|
||||
flex-basis: 50px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ecommerce-soft-popover-out {
|
||||
@@ -11773,6 +12153,15 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
||||
--quick-text: #164e63;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-quick-board .ecom-command-quick-card--hot {
|
||||
--quick-accent: #e8590c;
|
||||
--quick-bg: #fff4e6;
|
||||
--quick-text: #5c2d0e;
|
||||
--quick-icon: #d9480f;
|
||||
--quick-border: rgba(232, 89, 12, 0.12);
|
||||
--quick-shadow: rgba(232, 89, 12, 0.1);
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-quick-board,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-quick-board button,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-quick-board button > span,
|
||||
@@ -11868,26 +12257,12 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
||||
/* #/imageWorkbench detail popover and topbar blend: no inner scrollbar, no hard header split. */
|
||||
html body #root .ecommerce-standalone.web-shell .ecommerce-standalone__topbar {
|
||||
border-bottom-color: transparent !important;
|
||||
background:
|
||||
radial-gradient(48rem 14rem at 50% 100%, rgba(30, 189, 219, 0.09), transparent 72%),
|
||||
radial-gradient(28rem 12rem at 12% 100%, rgba(16, 115, 204, 0.045), transparent 68%),
|
||||
linear-gradient(180deg, #fbfdfe 0%, #f8fbfc 100%) !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.web-shell .ecommerce-standalone__topbar::after {
|
||||
position: absolute !important;
|
||||
right: 0 !important;
|
||||
bottom: -1px !important;
|
||||
left: 0 !important;
|
||||
height: 1px !important;
|
||||
background: linear-gradient(90deg, transparent, rgba(30, 189, 219, 0.08), transparent) !important;
|
||||
content: "" !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--settings-detail {
|
||||
width: min(468px, calc(100vw - 48px)) !important;
|
||||
max-width: min(468px, calc(100vw - 48px)) !important;
|
||||
@@ -13172,6 +13547,7 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-quick-board .ecom-command-quick-card--detail,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-quick-board .ecom-command-quick-card--hot,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-quick-board .ecom-command-quick-card--edit,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-quick-board .ecom-command-quick-card--cutout,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-quick-board .ecom-command-quick-card--watermark {
|
||||
@@ -13397,6 +13773,16 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
||||
--quick-shadow: rgba(122, 90, 248, 0.1) !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-quick-board .ecom-command-quick-card--hot {
|
||||
--quick-accent: #e8590c !important;
|
||||
--quick-bg: #fff4e6 !important;
|
||||
--quick-text: #5c2d0e !important;
|
||||
--quick-icon: #d9480f !important;
|
||||
--quick-icon-bg: rgba(232, 89, 12, 0.13) !important;
|
||||
--quick-border: rgba(232, 89, 12, 0.12) !important;
|
||||
--quick-shadow: rgba(232, 89, 12, 0.1) !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-quick-board .ecom-command-quick-card--edit {
|
||||
--quick-accent: #cc6b14 !important;
|
||||
--quick-bg: #fff2e5 !important;
|
||||
@@ -13492,7 +13878,7 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
||||
|
||||
/* #/imageWorkbench quick actions: soften each action so the tones blend into the page. */
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap.ecom-command-composer-wrap:has(.ecom-inspiration-lab) .ecom-command-quick-board {
|
||||
background: rgba(255, 255, 255, 0.3) !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
@@ -13600,7 +13986,7 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
||||
/* #/imageWorkbench composer redesign: mode tabs outside, settings and assets inside. */
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-mode-tabs {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr)) !important;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
gap: 8px !important;
|
||||
width: min(100%, 760px) !important;
|
||||
margin: 0 auto 12px !important;
|
||||
@@ -13887,7 +14273,7 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
||||
|
||||
@media (max-width: 900px) {
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-mode-tabs {
|
||||
grid-template-columns: repeat(5, minmax(94px, 1fr)) !important;
|
||||
grid-template-columns: repeat(4, minmax(104px, 1fr)) !important;
|
||||
width: 100% !important;
|
||||
overflow-x: auto !important;
|
||||
overscroll-behavior-x: contain !important;
|
||||
@@ -13901,7 +14287,7 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
||||
|
||||
@media (max-width: 640px) {
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-mode-tabs {
|
||||
grid-template-columns: repeat(5, minmax(88px, 1fr)) !important;
|
||||
grid-template-columns: repeat(4, minmax(92px, 1fr)) !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
@@ -14082,6 +14468,261 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
||||
font-size: 22px !important;
|
||||
}
|
||||
|
||||
/* Hot clone uploaded material thumbnails: compact grid and consistent delete control. */
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
align-items: flex-start !important;
|
||||
justify-content: flex-start !important;
|
||||
align-content: flex-start !important;
|
||||
gap: 10px !important;
|
||||
width: 100% !important;
|
||||
min-height: 0 !important;
|
||||
height: auto !important;
|
||||
padding: 10px !important;
|
||||
border: 1px solid #e8edf0 !important;
|
||||
border-radius: 8px !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images:hover,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images:focus-visible,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images.is-dragging {
|
||||
border-color: rgba(30, 189, 219, 0.42) !important;
|
||||
background: #fbfdff !important;
|
||||
box-shadow: 0 10px 24px rgba(16, 115, 204, 0.08) !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images .ecom-quick-upload-thumbs {
|
||||
display: contents !important;
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb {
|
||||
position: relative !important;
|
||||
flex: 0 0 64px !important;
|
||||
width: 64px !important;
|
||||
height: 64px !important;
|
||||
min-width: 64px !important;
|
||||
min-height: 64px !important;
|
||||
margin: 0 !important;
|
||||
overflow: hidden !important;
|
||||
border: 1px solid #e8edf0 !important;
|
||||
border-radius: 8px !important;
|
||||
background: #f6f8fa !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb > img {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 7px !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb > button {
|
||||
position: absolute !important;
|
||||
top: 4px !important;
|
||||
right: 4px !important;
|
||||
z-index: 6 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 22px !important;
|
||||
height: 22px !important;
|
||||
min-width: 22px !important;
|
||||
min-height: 22px !important;
|
||||
padding: 0 !important;
|
||||
border: 1px solid rgba(239, 68, 68, 0.42) !important;
|
||||
border-radius: 999px !important;
|
||||
color: #ef4444 !important;
|
||||
background: rgba(255, 255, 255, 0.92) !important;
|
||||
box-shadow: 0 8px 18px rgba(239, 68, 68, 0.16) !important;
|
||||
cursor: pointer !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
transform: scale(0.92) !important;
|
||||
visibility: hidden !important;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 150ms ease,
|
||||
background 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
visibility 150ms ease !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb:hover > button,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb:focus-within > button {
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
transform: scale(1) !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb > button:hover {
|
||||
border-color: rgba(220, 38, 38, 0.72) !important;
|
||||
color: #dc2626 !important;
|
||||
background: #fff1f2 !important;
|
||||
box-shadow: 0 10px 22px rgba(220, 38, 38, 0.22) !important;
|
||||
transform: scale(1.04) !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb > button .anticon {
|
||||
display: inline-flex !important;
|
||||
font-size: 13px !important;
|
||||
line-height: 1 !important;
|
||||
color: currentColor !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-set-upload.has-images .ecom-quick-hot-add-btn {
|
||||
flex: 0 0 64px !important;
|
||||
width: 64px !important;
|
||||
height: 64px !important;
|
||||
min-width: 64px !important;
|
||||
min-height: 64px !important;
|
||||
border: 1px solid #e8edf0 !important;
|
||||
border-radius: 8px !important;
|
||||
color: #111827 !important;
|
||||
background: #f3f4f6 !important;
|
||||
box-shadow: none !important;
|
||||
font-size: 22px !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Keep hot material upload controls visible after files are added. */
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
align-items: flex-start !important;
|
||||
justify-content: flex-start !important;
|
||||
gap: 10px !important;
|
||||
min-height: 0 !important;
|
||||
height: auto !important;
|
||||
padding: 10px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-quick-upload-thumbs {
|
||||
display: contents !important;
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb {
|
||||
flex: 0 0 64px !important;
|
||||
width: 64px !important;
|
||||
height: 64px !important;
|
||||
min-width: 64px !important;
|
||||
min-height: 64px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb > img {
|
||||
overflow: hidden !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb > button {
|
||||
top: -7px !important;
|
||||
right: -7px !important;
|
||||
width: 22px !important;
|
||||
height: 22px !important;
|
||||
min-width: 22px !important;
|
||||
min-height: 22px !important;
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
pointer-events: auto !important;
|
||||
transform: none !important;
|
||||
color: #ef4444 !important;
|
||||
background: #ffffff !important;
|
||||
border: 1px solid rgba(239, 68, 68, 0.5) !important;
|
||||
box-shadow: 0 8px 18px rgba(239, 68, 68, 0.16) !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb > button .anticon,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb > button svg {
|
||||
display: block !important;
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
color: currentColor !important;
|
||||
fill: none !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-quick-hot-add-btn {
|
||||
display: inline-flex !important;
|
||||
flex: 0 0 64px !important;
|
||||
width: 64px !important;
|
||||
height: 64px !important;
|
||||
min-width: 64px !important;
|
||||
min-height: 64px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb > button.ecom-hot-material-delete {
|
||||
position: absolute !important;
|
||||
top: -8px !important;
|
||||
right: -8px !important;
|
||||
z-index: 20 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
min-width: 24px !important;
|
||||
min-height: 24px !important;
|
||||
padding: 0 !important;
|
||||
overflow: visible !important;
|
||||
border: 1px solid rgba(239, 68, 68, 0.62) !important;
|
||||
border-radius: 999px !important;
|
||||
color: #ef4444 !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 8px 18px rgba(239, 68, 68, 0.16) !important;
|
||||
cursor: pointer !important;
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
transform: none !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb > button.ecom-hot-material-delete:hover {
|
||||
border-color: #dc2626 !important;
|
||||
color: #dc2626 !important;
|
||||
background: #fff1f2 !important;
|
||||
box-shadow: 0 10px 22px rgba(220, 38, 38, 0.24) !important;
|
||||
transform: scale(1.04) !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material.has-images .ecom-command-asset-thumb.ecom-quick-upload-thumb > button.ecom-hot-material-delete svg {
|
||||
display: block !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
stroke: currentColor !important;
|
||||
stroke-width: 1.9 !important;
|
||||
stroke-linecap: round !important;
|
||||
stroke-linejoin: round !important;
|
||||
fill: none !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-material:not(.has-images) {
|
||||
min-height: 94px !important;
|
||||
padding: 12px 14px !important;
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-reference.has-images > .anticon,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-reference.has-images > span,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-reference.has-images > em,
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"].is-hot-clone-page .ecom-quick-hot-page .ecom-quick-hot-reference.has-images > b {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Generation record detail workspace: left chat, center canvas, right history drawer. */
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"].is-history-detail {
|
||||
--clone-chat-width: 352px;
|
||||
@@ -16649,7 +17290,7 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
||||
|
||||
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"].is-history-detail .clone-ai-node-drag-handle {
|
||||
height: 38px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Record detail chat polish: wider drawer and clearer turn-taking. */
|
||||
@@ -16772,3 +17413,37 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide the topbar banner background while keeping brand and account in place. */
|
||||
html body #root div.ecommerce-standalone.web-shell[data-view="ecommerce"] .ecommerce-standalone__topbar {
|
||||
border-bottom: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
html body #root div.ecommerce-standalone.web-shell[data-view="ecommerce"] .ecommerce-standalone__topbar::after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
/* Keep topbar transparent and remove any background/border from inner controls. */
|
||||
html body #root div.ecommerce-standalone.web-shell[data-view="ecommerce"] .ecommerce-standalone__brand,
|
||||
html body #root div.ecommerce-standalone.web-shell[data-view="ecommerce"] .ecommerce-standalone__brand strong,
|
||||
html body #root div.ecommerce-standalone.web-shell[data-view="ecommerce"] .ecommerce-standalone__credits,
|
||||
html body #root div.ecommerce-standalone.web-shell[data-view="ecommerce"] .ecommerce-standalone__account button,
|
||||
html body #root div.ecommerce-standalone.web-shell[data-view="ecommerce"] .ecommerce-profile-menu__trigger {
|
||||
color: #10202c !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
border: none !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
html body #root div.ecommerce-standalone.web-shell[data-view="ecommerce"] .ecommerce-standalone__credits {
|
||||
color: #3a5a6a !important;
|
||||
}
|
||||
|
||||
@@ -7861,7 +7861,7 @@
|
||||
.product-set-preview-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
z-index: 4000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(17 24 39 / 58%);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export * from "./types.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./enterpriseVideoPolicy.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./happyHorseRouting.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./pixverseRouting.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./resolveVideoModel.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./taskLifecycle.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./translateTaskError.ts";
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { classifyTaskError, translateTaskError, type TaskErrorCategory } from "./translateTaskError";
|
||||
|
||||
// 每条规则至少一个正例,按规则顺序排列(classifyTaskError 先匹配先返回)。
|
||||
const RULE_CASES: Array<{ name: string; input: string; category: TaskErrorCategory }> = [
|
||||
{ name: "content policy", input: "content violated our policies", category: "content_policy" },
|
||||
{ name: "nsfw", input: "image flagged as nsfw", category: "content_policy" },
|
||||
{ name: "auth 401", input: "401 Unauthorized", category: "auth_failure" },
|
||||
{ name: "token expired", input: "token expired", category: "auth_failure" },
|
||||
{ name: "insufficient balance 402", input: "402 Payment Required", category: "insufficient_balance" },
|
||||
{ name: "余额不足", input: "余额不足", category: "insufficient_balance" },
|
||||
{ name: "concurrency pool full", input: "concurrency pool is full", category: "concurrency_busy" },
|
||||
{ name: "rate limit 429", input: "429 Too Many Requests", category: "concurrency_busy" },
|
||||
{ name: "unsupported model", input: "model not found", category: "unsupported_model" },
|
||||
{ name: "invalid asset", input: "invalid image format", category: "invalid_asset" },
|
||||
{ name: "network ECONNREFUSED", input: "fetch failed: ECONNREFUSED", category: "network_failure" },
|
||||
{ name: "timeout ETIMEDOUT", input: "ETIMEDOUT", category: "timeout" },
|
||||
{ name: "quota exceeded", input: "quota exceeded", category: "insufficient_balance" },
|
||||
{ name: "cancelled", input: "task was cancelled", category: "cancelled" },
|
||||
{ name: "已取消", input: "任务已取消", category: "cancelled" },
|
||||
{ name: "all providers failed", input: "all providers failed", category: "concurrency_busy" },
|
||||
{ name: "500 server error", input: "500 Internal Server Error", category: "network_failure" },
|
||||
{ name: "forbidden 403", input: "403 Forbidden", category: "auth_failure" },
|
||||
{ name: "aborted", input: "request aborted", category: "timeout" },
|
||||
];
|
||||
|
||||
describe("classifyTaskError rule coverage", () => {
|
||||
for (const { name, input, category } of RULE_CASES) {
|
||||
it(`classifies "${name}" as ${category}`, () => {
|
||||
const result = classifyTaskError(input);
|
||||
expect(result.category).toBe(category);
|
||||
expect(result.message).toBeTruthy();
|
||||
expect(result.action).toBeTruthy();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("classifyTaskError edge cases", () => {
|
||||
it("returns unknown for empty/null/undefined", () => {
|
||||
expect(classifyTaskError("").category).toBe("unknown");
|
||||
expect(classifyTaskError(undefined).category).toBe("unknown");
|
||||
expect(classifyTaskError(null).category).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns the raw (truncated) message for unrecognized Chinese errors", () => {
|
||||
const result = classifyTaskError("这是一条未知的中文错误信息");
|
||||
expect(result.category).toBe("unknown");
|
||||
expect(result.message).toContain("未知");
|
||||
expect(result.message).not.toContain("服务异常");
|
||||
});
|
||||
|
||||
it("truncates long Chinese errors to 80 chars + ellipsis", () => {
|
||||
const long = "错误".repeat(50);
|
||||
const result = classifyTaskError(long);
|
||||
expect(result.message.endsWith("...")).toBe(true);
|
||||
expect(result.message.length).toBeLessThanOrEqual(83);
|
||||
});
|
||||
|
||||
it("returns generic service message for unrecognized English errors", () => {
|
||||
const result = classifyTaskError("something completely unexpected");
|
||||
expect(result.category).toBe("unknown");
|
||||
expect(result.message).toBe("服务异常,请稍后重试");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyTaskError rule ordering (first match wins)", () => {
|
||||
it("content_policy beats auth_failure when both patterns present", () => {
|
||||
// "nsfw" appears before "401" in rule order
|
||||
const result = classifyTaskError("nsfw content with 401");
|
||||
expect(result.category).toBe("content_policy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateTaskError", () => {
|
||||
it("returns the message from classifyTaskError", () => {
|
||||
expect(translateTaskError("401")).toBe("登录已过期,请重新登录");
|
||||
});
|
||||
it("returns generic message for empty input", () => {
|
||||
expect(translateTaskError("")).toBe("任务失败,请重试");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveHappyHorseRequestModel, HAPPY_HORSE_UI_MODEL, HAPPY_HORSE_T2V_MODEL, HAPPY_HORSE_I2V_MODEL, HAPPY_HORSE_R2V_MODEL } from "./happyHorseRouting";
|
||||
import { resolveViduRequestModel, VIDU_UI_MODEL, VIDU_T2V_MODEL, VIDU_I2V_MODEL } from "./viduRouting";
|
||||
import { resolvePixverseRequestModel, PIXVERSE_UI_MODEL, PIXVERSE_T2V_MODEL, PIXVERSE_I2V_MODEL, PIXVERSE_KF2V_MODEL } from "./pixverseRouting";
|
||||
|
||||
type ResolveFn = (input: { model: string; referenceUrls?: string[]; imageReferenceCount?: number }) => string;
|
||||
|
||||
// 三家路由在参考图数量上的分支差异是回归测试重点。
|
||||
// HappyHorse: 0->t2v, 1->i2v, >=2->r2v
|
||||
// Vidu: 0->t2v, >=1->i2v (无 r2v)
|
||||
// Pixverse: 0->t2v, 1->i2v, >=2->kf2v
|
||||
describe.each([
|
||||
{ name: "HappyHorse", resolve: resolveHappyHorseRequestModel, ui: HAPPY_HORSE_UI_MODEL, t2v: HAPPY_HORSE_T2V_MODEL, i2v: HAPPY_HORSE_I2V_MODEL, third: HAPPY_HORSE_R2V_MODEL },
|
||||
{ name: "Vidu", resolve: resolveViduRequestModel, ui: VIDU_UI_MODEL, t2v: VIDU_T2V_MODEL, i2v: VIDU_I2V_MODEL, third: null },
|
||||
{ name: "Pixverse", resolve: resolvePixverseRequestModel, ui: PIXVERSE_UI_MODEL, t2v: PIXVERSE_T2V_MODEL, i2v: PIXVERSE_I2V_MODEL, third: PIXVERSE_KF2V_MODEL },
|
||||
] as Array<{ name: string; resolve: ResolveFn; ui: string; t2v: string; i2v: string; third: string | null }>)(
|
||||
"$name routing by imageReferenceCount",
|
||||
({ resolve, ui, t2v, i2v, third }) => {
|
||||
it("returns the input model unchanged when it is not this provider", () => {
|
||||
expect(resolve({ model: "some-other-model" })).toBe("some-other-model");
|
||||
});
|
||||
|
||||
it("routes 0 reference images to t2v", () => {
|
||||
expect(resolve({ model: ui, imageReferenceCount: 0 })).toBe(t2v);
|
||||
});
|
||||
|
||||
it("routes 1 reference image to i2v", () => {
|
||||
expect(resolve({ model: ui, imageReferenceCount: 1 })).toBe(i2v);
|
||||
});
|
||||
|
||||
if (third) {
|
||||
it("routes >=2 reference images to the third model", () => {
|
||||
expect(resolve({ model: ui, imageReferenceCount: 2 })).toBe(third);
|
||||
expect(resolve({ model: ui, imageReferenceCount: 5 })).toBe(third);
|
||||
});
|
||||
} else {
|
||||
it("routes >=1 reference images to i2v (no third model for this provider)", () => {
|
||||
expect(resolve({ model: ui, imageReferenceCount: 2 })).toBe(i2v);
|
||||
expect(resolve({ model: ui, imageReferenceCount: 5 })).toBe(i2v);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
describe("reference count fallback (referenceUrls when imageReferenceCount omitted)", () => {
|
||||
it("HappyHorse counts non-empty urls", () => {
|
||||
expect(
|
||||
resolveHappyHorseRequestModel({
|
||||
model: HAPPY_HORSE_UI_MODEL,
|
||||
referenceUrls: ["", " ", "https://example.com/a.png"],
|
||||
}),
|
||||
).toBe(HAPPY_HORSE_I2V_MODEL);
|
||||
});
|
||||
|
||||
it("Vidu falls back to 0 when all urls are empty/whitespace", () => {
|
||||
expect(
|
||||
resolveViduRequestModel({
|
||||
model: VIDU_UI_MODEL,
|
||||
referenceUrls: ["", " "],
|
||||
}),
|
||||
).toBe(VIDU_T2V_MODEL);
|
||||
});
|
||||
|
||||
it("Pixverse counts two non-empty urls as kf2v", () => {
|
||||
expect(
|
||||
resolvePixverseRequestModel({
|
||||
model: PIXVERSE_UI_MODEL,
|
||||
referenceUrls: ["https://a.png", "https://b.png"],
|
||||
}),
|
||||
).toBe(PIXVERSE_KF2V_MODEL);
|
||||
});
|
||||
|
||||
it("imageReferenceCount takes precedence over referenceUrls length", () => {
|
||||
// Even though referenceUrls has 3 entries, explicit count of 0 wins.
|
||||
expect(
|
||||
resolveHappyHorseRequestModel({
|
||||
model: HAPPY_HORSE_UI_MODEL,
|
||||
referenceUrls: ["a", "b", "c"],
|
||||
imageReferenceCount: 0,
|
||||
}),
|
||||
).toBe(HAPPY_HORSE_T2V_MODEL);
|
||||
});
|
||||
|
||||
it("handles undefined referenceUrls with undefined count", () => {
|
||||
expect(resolveViduRequestModel({ model: VIDU_UI_MODEL })).toBe(VIDU_T2V_MODEL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./viduRouting.ts";
|
||||
+1
-1
@@ -16,5 +16,5 @@
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
"include": ["src", "vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Vitest 配置独立于 vite.config.ts,避免影响 dev/build。
|
||||
// 本轮只测纯函数(颜色/比例/平台/路由/错误翻译),用 node 环境即可,无需 jsdom。
|
||||
// 后续要做组件测试时,再在 test.environment 切到 jsdom 并装 @testing-library/react。
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: ["src/**/*.test.*", "src/**/*.spec.*", "src/main.tsx", "src/vite-env.d.ts"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user