Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb7b769155 | |||
| 0e24ccf7b1 | |||
| f8ccad52f9 | |||
| 57cf34b0d0 | |||
| ad38a4a0e3 | |||
| c7adbc153b | |||
| 17152efa2c | |||
| a605fad7e0 | |||
| 30222cd830 | |||
| 4ca2ab4a9c | |||
| 588da45902 | |||
| 5466036349 | |||
| 9869c0c5e6 | |||
| 5811cbac16 | |||
| c38f056527 | |||
| 3469071819 | |||
| 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 | |||
| a2875738ce | |||
| 85adcdceef | |||
| 66b761314b | |||
| ab99e3bf2f | |||
| e3b48e2614 | |||
| 62fcf461b6 | |||
| 9a9c7eb86d | |||
| 6dd292207f | |||
| 8985deea0a | |||
| f30e585cfa | |||
| 5b316a2399 | |||
| 3f1954b38d | |||
| 96d335db8a | |||
| 45e6534ee1 | |||
| 307537a7ce | |||
| 48262d6233 | |||
| 062c8b3445 | |||
| 0b2d6b901f | |||
| e1fdbe5f9b | |||
| f51dfb17e1 | |||
| 76ae9ab0ac | |||
| e88edbe165 | |||
| 863f1f075e | |||
| aa133d0f5c |
@@ -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
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" data-theme="dark" data-ui-theme="dark-green" style="color-scheme: dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
@@ -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",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --host 127.0.0.1",
|
"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": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "5.3.0",
|
"@ant-design/icons": "5.3.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"scheduler": "0.23.0",
|
||||||
"zustand": "5.0.13"
|
"zustand": "5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "18.2.0",
|
"@types/react": "18.2.55",
|
||||||
"@types/react-dom": "18.2.0",
|
"@types/react-dom": "18.2.18",
|
||||||
"@vitejs/plugin-react": "4.2.1",
|
"@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",
|
"typescript": "5.3.3",
|
||||||
"vite": "5.1.0",
|
"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}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
+50
-139
@@ -1,28 +1,23 @@
|
|||||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BugOutlined,
|
BugOutlined,
|
||||||
CheckCircleFilled,
|
CheckCircleFilled,
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
IdcardOutlined,
|
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
LoginOutlined,
|
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MailOutlined,
|
MailOutlined,
|
||||||
MobileOutlined,
|
MobileOutlined,
|
||||||
PictureOutlined,
|
|
||||||
SafetyOutlined,
|
SafetyOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
VideoCameraOutlined,
|
|
||||||
WalletOutlined,
|
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
|
import { LocalAvatar } from "./components/LocalAvatar";
|
||||||
|
import { Topbar } from "./components/Topbar";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import ToastContainer from "./components/toast/ToastContainer";
|
import ToastContainer from "./components/toast/ToastContainer";
|
||||||
import { toast } from "./components/toast/toastStore";
|
import { toast } from "./components/toast/toastStore";
|
||||||
import EcommercePage from "./features/ecommerce/EcommercePage";
|
|
||||||
import { flushPendingGenerationRecords } from "./api/generationRecordClient";
|
import { flushPendingGenerationRecords } from "./api/generationRecordClient";
|
||||||
import { ossAssets } from "./data/ossAssets";
|
|
||||||
import { keyServerClient } from "./api/keyServerClient";
|
import { keyServerClient } from "./api/keyServerClient";
|
||||||
import { setUserMaxConcurrency } from "./api/generationConcurrency";
|
import { setUserMaxConcurrency } from "./api/generationConcurrency";
|
||||||
import {
|
import {
|
||||||
@@ -38,8 +33,13 @@ import { useAppStore, useSessionStore } from "./stores";
|
|||||||
import type { WebUserSession } from "./types";
|
import type { WebUserSession } from "./types";
|
||||||
import "./styles/ecommerce-standalone.css";
|
import "./styles/ecommerce-standalone.css";
|
||||||
|
|
||||||
|
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
|
||||||
|
|
||||||
type AuthMode = "login" | "register";
|
type AuthMode = "login" | "register";
|
||||||
type AuthMethod = "account" | "email" | "phone";
|
type AuthMethod = "account" | "email" | "phone";
|
||||||
|
type WorkspaceChromeState = {
|
||||||
|
isToolPage: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
interface LocalProfilePageProps {
|
interface LocalProfilePageProps {
|
||||||
session: WebUserSession;
|
session: WebUserSession;
|
||||||
@@ -51,33 +51,11 @@ interface LocalProfilePageProps {
|
|||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileWorks = [
|
|
||||||
{ title: "主图套图生成", desc: "电商主图与场景图自动生成", image: ossAssets.ecommerce.templateCases[0], type: "图像", time: "6/9 18:13" },
|
|
||||||
{ title: "A+详情页设计", desc: "产品卖点与长图详情版式", image: ossAssets.ecommerce.templateCases[1], type: "图像", time: "6/9 10:11" },
|
|
||||||
{ title: "短视频广告", desc: "产品展示短视频脚本与画面", image: ossAssets.ecommerce.productSet.hosting, type: "视频", time: "6/9 10:05" },
|
|
||||||
{ title: "模特图生成", desc: "服饰商品真人上身展示", image: ossAssets.ecommerce.tryOn.tryA, type: "图像", time: "6/9 10:03" },
|
|
||||||
{ title: "商品场景图", desc: "按平台比例输出营销素材", image: ossAssets.ecommerce.detail.gridA, type: "图像", time: "6/9 10:01" },
|
|
||||||
{ title: "高度复刻", desc: "参考图结构复刻与商品替换", image: ossAssets.ecommerce.detail.gridB, type: "图像", time: "6/9 09:39" },
|
|
||||||
{ title: "详情模块", desc: "功能卖点、参数和包装模块", image: ossAssets.ecommerce.detail.gridC, type: "图像", time: "6/8 21:20" },
|
|
||||||
{ title: "平台素材", desc: "淘宝/天猫投放图批量生成", image: ossAssets.ecommerce.detail.gridD, type: "图像", time: "6/8 18:26" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function LocalAvatar({ session, size = "md" }: { session: WebUserSession; size?: "sm" | "md" | "lg" }) {
|
|
||||||
const displayName = session.user.displayName || session.user.username || "用户";
|
|
||||||
const label = displayName.trim().slice(0, 1).toUpperCase() || "用";
|
|
||||||
const avatarUrl = session.user.avatarUrl;
|
|
||||||
return (
|
|
||||||
<span className={`local-user-avatar local-user-avatar--${size}`}>
|
|
||||||
{avatarUrl ? <img src={avatarUrl} alt={displayName} /> : <span>{label}</span>}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, onBugFeedback, onLogout }: LocalProfilePageProps) {
|
function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, onBugFeedback, onLogout }: LocalProfilePageProps) {
|
||||||
const displayName = session.user.displayName || session.user.username || "用户";
|
const displayName = session.user.displayName || session.user.username || "用户";
|
||||||
const workCount = Math.max(imageCount + videoCount, profileWorks.length);
|
const workCount = Math.max(imageCount + videoCount, 0);
|
||||||
const projectCount = Math.max(1, Math.round(workCount / 18));
|
const projectCount = 0;
|
||||||
const assetCount = Math.max(1, Math.round(workCount / 20));
|
const assetCount = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="local-profile-page">
|
<section className="local-profile-page">
|
||||||
@@ -142,22 +120,15 @@ function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, on
|
|||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<strong>代表作</strong>
|
<strong>代表作</strong>
|
||||||
<span>最近完成的高质量生成内容</span>
|
<span>后续将展示接口返回的真实作品</span>
|
||||||
</div>
|
</div>
|
||||||
<em>{workCount} 项</em>
|
<em>{workCount} 项</em>
|
||||||
</header>
|
</header>
|
||||||
<div className="local-profile-work-grid">
|
<div className="local-profile-work-grid local-profile-work-grid--empty">
|
||||||
{profileWorks.map((work) => (
|
<div className="local-profile-empty">
|
||||||
<article key={`${work.title}-${work.time}`} className="local-profile-work-card">
|
<strong>暂无代表作数据</strong>
|
||||||
<img src={work.image} alt="" />
|
<span>作品接口接入后,这里会显示你的真实生成内容。</span>
|
||||||
<div>
|
|
||||||
<span>{work.type}</span>
|
|
||||||
<strong>{work.title}</strong>
|
|
||||||
<p>{work.desc}</p>
|
|
||||||
<em>已完成 · {work.time}</em>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -184,7 +155,9 @@ function App() {
|
|||||||
const [sessionNotice, setSessionNotice] = useState<string | null>(null);
|
const [sessionNotice, setSessionNotice] = useState<string | null>(null);
|
||||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace");
|
const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace");
|
||||||
const [workspaceKey, setWorkspaceKey] = useState(0);
|
const [workspaceChrome, setWorkspaceChrome] = useState<WorkspaceChromeState>({
|
||||||
|
isToolPage: false,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadDarkGreenTheme();
|
void loadDarkGreenTheme();
|
||||||
@@ -337,20 +310,6 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const balance = Math.max(usage.balanceCents, 0) / 100;
|
const balance = Math.max(usage.balanceCents, 0) / 100;
|
||||||
const displayName = session?.user.displayName || session?.user.username || "用户";
|
|
||||||
const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0);
|
|
||||||
const shownWorkCount = Math.max(actualWorkCount, profileWorks.length);
|
|
||||||
|
|
||||||
const avatarMenuStats = useMemo(
|
|
||||||
() => [
|
|
||||||
{ icon: <IdcardOutlined />, label: "UID", value: session?.user.id ?? "-" },
|
|
||||||
{ icon: <WalletOutlined />, label: "积分", value: `${balance.toFixed(2)} 积分` },
|
|
||||||
{ icon: <PictureOutlined />, label: "图片", value: usage.imageUsed },
|
|
||||||
{ icon: <VideoCameraOutlined />, label: "视频", value: usage.videoUsed },
|
|
||||||
{ icon: <PictureOutlined />, label: "作品", value: shownWorkCount },
|
|
||||||
],
|
|
||||||
[balance, session?.user.id, shownWorkCount, usage.imageUsed, usage.videoUsed],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpenProfile = () => {
|
const handleOpenProfile = () => {
|
||||||
setProfileMenuOpen(false);
|
setProfileMenuOpen(false);
|
||||||
@@ -360,7 +319,6 @@ function App() {
|
|||||||
const handleOpenWorkspace = () => {
|
const handleOpenWorkspace = () => {
|
||||||
setProfileMenuOpen(false);
|
setProfileMenuOpen(false);
|
||||||
setCurrentPage("workspace");
|
setCurrentPage("workspace");
|
||||||
setWorkspaceKey((k) => k + 1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBugFeedback = () => {
|
const handleBugFeedback = () => {
|
||||||
@@ -369,85 +327,31 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ecommerce-standalone web-shell" data-theme="dark" data-ui-theme="dark-green" data-view="ecommerce">
|
<div
|
||||||
<header className="ecommerce-standalone__topbar">
|
className="ecommerce-standalone web-shell"
|
||||||
<button type="button" className="ecommerce-standalone__brand" onClick={handleOpenWorkspace}>
|
data-theme="dark"
|
||||||
<span className="ecommerce-standalone__logo" aria-hidden="true">
|
data-ui-theme="dark-green"
|
||||||
<img src="https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png" alt="" />
|
data-view="ecommerce"
|
||||||
</span>
|
data-workspace-tool-page={workspaceChrome.isToolPage ? "true" : "false"}
|
||||||
<strong>OmniAI 电商智能体</strong>
|
|
||||||
</button>
|
|
||||||
<div className="ecommerce-standalone__account">
|
|
||||||
{session ? (
|
|
||||||
<div className="ecommerce-profile-menu">
|
|
||||||
<span className="ecommerce-standalone__credits">
|
|
||||||
{(Math.max(usage.balanceCents, 0) / 100).toFixed(2)} 积分
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ecommerce-profile-menu__trigger"
|
|
||||||
onClick={() => setProfileMenuOpen((open) => !open)}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-expanded={profileMenuOpen}
|
|
||||||
>
|
>
|
||||||
<LocalAvatar session={session} size="sm" />
|
<Topbar
|
||||||
<span>{displayName}</span>
|
session={session}
|
||||||
</button>
|
usage={usage}
|
||||||
{profileMenuOpen ? (
|
profileMenuOpen={profileMenuOpen}
|
||||||
<>
|
onProfileMenuOpenChange={setProfileMenuOpen}
|
||||||
<button
|
onOpenWorkspace={handleOpenWorkspace}
|
||||||
type="button"
|
onOpenProfile={handleOpenProfile}
|
||||||
className="ecommerce-profile-popover__backdrop"
|
onOpenAuth={openAuth}
|
||||||
aria-label="关闭账户信息"
|
onLogout={handleLogout}
|
||||||
onClick={() => setProfileMenuOpen(false)}
|
onBugFeedback={handleBugFeedback}
|
||||||
/>
|
/>
|
||||||
<section className="ecommerce-profile-popover" role="dialog" aria-label="账户信息">
|
|
||||||
<div className="ecommerce-profile-popover__head">
|
|
||||||
<LocalAvatar session={session} size="md" />
|
|
||||||
<div>
|
|
||||||
<strong>{displayName}</strong>
|
|
||||||
<span>{session.user.username}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="ecommerce-profile-popover__stats">
|
|
||||||
{avatarMenuStats.map((item) => (
|
|
||||||
<div key={item.label}>
|
|
||||||
<dt>{item.icon}{item.label}</dt>
|
|
||||||
<dd>{item.value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="ecommerce-profile-popover__actions">
|
|
||||||
<button type="button" className="is-primary" onClick={handleOpenProfile}>
|
|
||||||
<UserOutlined />
|
|
||||||
个人中心
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={handleBugFeedback}>
|
|
||||||
<BugOutlined />
|
|
||||||
Bug 反馈
|
|
||||||
</button>
|
|
||||||
<button type="button" className="is-danger" onClick={handleLogout}>
|
|
||||||
<LogoutOutlined />
|
|
||||||
退出
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button type="button" onClick={() => openAuth("login")}>
|
|
||||||
<LoginOutlined />
|
|
||||||
<span>登录 / 注册</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="ecommerce-standalone__content">
|
<main className="ecommerce-standalone__content">
|
||||||
{currentPage === "profile" && session ? (
|
{session ? (
|
||||||
|
<div
|
||||||
|
className="ecommerce-standalone__page ecommerce-standalone__page--profile"
|
||||||
|
hidden={currentPage !== "profile"}
|
||||||
|
>
|
||||||
<LocalProfilePage
|
<LocalProfilePage
|
||||||
session={session}
|
session={session}
|
||||||
balance={balance}
|
balance={balance}
|
||||||
@@ -457,7 +361,14 @@ function App() {
|
|||||||
onBugFeedback={handleBugFeedback}
|
onBugFeedback={handleBugFeedback}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* 工作台常驻挂载,仅用 hidden 切换。切到个人中心时不卸载,
|
||||||
|
生成任务、进度动画、已上传图片等本地状态全部保留,切回即继续。 */}
|
||||||
|
<div
|
||||||
|
className="ecommerce-standalone__page ecommerce-standalone__page--workspace"
|
||||||
|
hidden={Boolean(session) && currentPage === "profile"}
|
||||||
|
>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
@@ -468,9 +379,9 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<EcommercePage
|
<EcommercePage
|
||||||
key={workspaceKey}
|
|
||||||
projects={[]}
|
projects={[]}
|
||||||
isAuthenticated={Boolean(session)}
|
isAuthenticated={Boolean(session)}
|
||||||
|
onWorkspaceChromeChange={setWorkspaceChrome}
|
||||||
onStartCreate={() => undefined}
|
onStartCreate={() => undefined}
|
||||||
onOpenProject={() => undefined}
|
onOpenProject={() => undefined}
|
||||||
onDeleteProject={() => undefined}
|
onDeleteProject={() => undefined}
|
||||||
@@ -482,7 +393,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)}
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{authOpen ? (
|
{authOpen ? (
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./aiGenerationClient.ts";
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
buildApiUrl,
|
buildApiUrl,
|
||||||
buildAuthHeaders,
|
buildAuthHeaders,
|
||||||
isRecord,
|
|
||||||
readJsonResponse,
|
readJsonResponse,
|
||||||
serverRequest,
|
serverRequest,
|
||||||
throwResponseError,
|
throwResponseError,
|
||||||
} from "./serverConnection";
|
} from "./serverConnection";
|
||||||
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
||||||
|
import {
|
||||||
|
parseAiTaskStatus,
|
||||||
|
parseAiTaskStatusList,
|
||||||
|
parseImageTaskCreateResponse,
|
||||||
|
parseSseTaskFrame,
|
||||||
|
parseTaskCreateResponse,
|
||||||
|
} from "./dtoParsers";
|
||||||
import type { WebGenerationPreviewTask } from "../types";
|
import type { WebGenerationPreviewTask } from "../types";
|
||||||
|
|
||||||
export interface ImageGenInput {
|
export interface ImageGenInput {
|
||||||
@@ -190,13 +196,6 @@ function parseContentDispositionFilename(value: string | null): string | undefin
|
|||||||
return plainMatch?.[1]?.trim() || undefined;
|
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 {
|
function getStoredSessionRole(): string {
|
||||||
try {
|
try {
|
||||||
if (typeof window === "undefined") return "";
|
if (typeof window === "undefined") return "";
|
||||||
@@ -251,67 +250,73 @@ export const aiGenerationClient = {
|
|||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
conversationId: input.conversationId,
|
conversationId: input.conversationId,
|
||||||
});
|
});
|
||||||
const payload = await serverRequest<ImageTaskCreateResponse>("ai/image", {
|
const payload = await serverRequest<unknown>("ai/image", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: input,
|
body: input,
|
||||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
fallbackMessage: "Image generation request failed",
|
fallbackMessage: "Image generation request failed",
|
||||||
});
|
});
|
||||||
if (payload.providerDebug) {
|
const parsed = parseImageTaskCreateResponse(payload);
|
||||||
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
|
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 }> {
|
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
|
||||||
return serverRequest<{ taskId: string }>("ai/video", {
|
const payload = await serverRequest<unknown>("ai/video", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: input,
|
body: input,
|
||||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
fallbackMessage: "Video generation request failed",
|
fallbackMessage: "Video generation request failed",
|
||||||
});
|
});
|
||||||
|
return parseTaskCreateResponse(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
|
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",
|
method: "POST",
|
||||||
body: input,
|
body: input,
|
||||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
fallbackMessage: "Video super-resolution request failed",
|
fallbackMessage: "Video super-resolution request failed",
|
||||||
});
|
});
|
||||||
|
return parseTaskCreateResponse(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
|
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",
|
method: "POST",
|
||||||
body: input,
|
body: input,
|
||||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
fallbackMessage: "Subtitle removal request failed",
|
fallbackMessage: "Subtitle removal request failed",
|
||||||
});
|
});
|
||||||
|
return parseTaskCreateResponse(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
|
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",
|
method: "POST",
|
||||||
body: input,
|
body: input,
|
||||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
fallbackMessage: "Image super-resolution request failed",
|
fallbackMessage: "Image super-resolution request failed",
|
||||||
});
|
});
|
||||||
|
return parseTaskCreateResponse(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
|
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
|
||||||
return serverRequest<{ taskId: string }>("ai/image/edit", {
|
const payload = await serverRequest<unknown>("ai/image/edit", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: input,
|
body: input,
|
||||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||||
fallbackMessage: "Image edit request failed",
|
fallbackMessage: "Image edit request failed",
|
||||||
});
|
});
|
||||||
|
return parseTaskCreateResponse(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async cancelTask(taskId: string): Promise<void> {
|
async cancelTask(taskId: string): Promise<void> {
|
||||||
@@ -328,10 +333,11 @@ export const aiGenerationClient = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
|
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,
|
timeoutMs: TASK_STATUS_TIMEOUT_MS,
|
||||||
fallbackMessage: "Task status request failed",
|
fallbackMessage: "Task status request failed",
|
||||||
});
|
});
|
||||||
|
return parseAiTaskStatus(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> {
|
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}` : ""}`, {
|
const payload = await serverRequest<unknown>(`ai/tasks${search.toString() ? `?${search}` : ""}`, {
|
||||||
fallbackMessage: "Task history request failed",
|
fallbackMessage: "Task history request failed",
|
||||||
});
|
});
|
||||||
return extractTaskList(payload).map(toPreviewTask);
|
return parseAiTaskStatusList(payload).map(toPreviewTask);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isOptionalApiRouteMissing(error)) {
|
if (isOptionalApiRouteMissing(error)) {
|
||||||
taskHistoryRouteMissing = true;
|
taskHistoryRouteMissing = true;
|
||||||
@@ -451,7 +457,7 @@ export const aiGenerationClient = {
|
|||||||
if (!line.startsWith("data: ")) continue;
|
if (!line.startsWith("data: ")) continue;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(line.slice(6));
|
const data = JSON.parse(line.slice(6));
|
||||||
onUpdate(data);
|
onUpdate(parseSseTaskFrame(data));
|
||||||
} catch { /* ignore */ }
|
} 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";
|
||||||
@@ -38,6 +38,45 @@ export interface SaveGenerationRecordResult {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同一 clientRecordId 的保存去重:套图主流程、backgroundTaskRunner、useGenerationTasks
|
||||||
|
// 三处都可能对同一条终态任务调用 saveGenerationRecord,SSE 重复推送 completed 时
|
||||||
|
// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截,
|
||||||
|
// 避免后端在缺少去重时插入重复记录。
|
||||||
|
const inFlightSaves = new Map<string, Promise<SaveGenerationRecordResult>>();
|
||||||
|
const recentlySavedRecords = new Map<string, { savedAt: number; signature: string }>();
|
||||||
|
const SAVE_DEDUPE_WINDOW_MS = 60_000;
|
||||||
|
|
||||||
|
function pruneRecentlySaved(now: number): void {
|
||||||
|
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[] {
|
function readPendingRecords(): SaveGenerationRecordInput[] {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(PENDING_RECORDS_KEY);
|
const raw = window.localStorage.getItem(PENDING_RECORDS_KEY);
|
||||||
@@ -60,6 +99,39 @@ function writePendingRecord(input: SaveGenerationRecordInput): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveGenerationRecord(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> {
|
export async function saveGenerationRecord(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> {
|
||||||
|
const now = Date.now();
|
||||||
|
pruneRecentlySaved(now);
|
||||||
|
|
||||||
|
const recordId = input.clientRecordId;
|
||||||
|
const signature = buildSaveSignature(input);
|
||||||
|
if (recordId) {
|
||||||
|
const saveKey = `${recordId}:${signature}`;
|
||||||
|
const inFlight = inFlightSaves.get(saveKey);
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
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) {
|
||||||
|
const saveKey = `${recordId}:${signature}`;
|
||||||
|
inFlightSaves.set(saveKey, promise);
|
||||||
|
void promise
|
||||||
|
.then((result) => {
|
||||||
|
if (result.source === "server") recentlySavedRecords.set(recordId, { savedAt: Date.now(), signature });
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
inFlightSaves.delete(saveKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGenerationRecordInternal(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> {
|
||||||
try {
|
try {
|
||||||
const response = await serverRequest<{ id?: string | number }>("ai/generation-records", {
|
const response = await serverRequest<{ id?: string | number }>("ai/generation-records", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./serverConnection.ts";
|
||||||
@@ -82,9 +82,19 @@ function parseStoredSession(raw: string | null): WebUserSession | null {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
return isRecord(parsed) && typeof parsed.token === "string" && isRecord(parsed.user)
|
// Require token + a user object with at least an id, so a malformed/partial
|
||||||
? (parsed as unknown as WebUserSession)
|
// cached session does not get cast wholesale into WebUserSession and then
|
||||||
: null;
|
// 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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./taskSubscription.ts";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./webGenerationGateway.ts";
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { WebUserSession } from "../types";
|
||||||
|
|
||||||
|
interface LocalAvatarProps {
|
||||||
|
session: WebUserSession;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalAvatar({ session, size = "md" }: LocalAvatarProps) {
|
||||||
|
const displayName = session.user.displayName || session.user.username || "用户";
|
||||||
|
const label = displayName.trim().slice(0, 1).toUpperCase() || "用";
|
||||||
|
const avatarUrl = session.user.avatarUrl;
|
||||||
|
return (
|
||||||
|
<span className={`local-user-avatar local-user-avatar--${size}`}>
|
||||||
|
{avatarUrl ? <img src={avatarUrl} alt={displayName} /> : <span>{label}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
BugOutlined,
|
||||||
|
IdcardOutlined,
|
||||||
|
LoginOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
WalletOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { LocalAvatar } from "./LocalAvatar";
|
||||||
|
import type { WebUserSession } from "../types";
|
||||||
|
|
||||||
|
interface TopbarProps {
|
||||||
|
session: WebUserSession | null;
|
||||||
|
usage: { balanceCents: number; imageUsed: number; videoUsed: number };
|
||||||
|
profileMenuOpen: boolean;
|
||||||
|
onProfileMenuOpenChange: (open: boolean) => void;
|
||||||
|
onOpenWorkspace: () => void;
|
||||||
|
onOpenProfile: () => void;
|
||||||
|
onOpenAuth: (mode: "login" | "register") => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
onBugFeedback: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Topbar({
|
||||||
|
session,
|
||||||
|
usage,
|
||||||
|
profileMenuOpen,
|
||||||
|
onProfileMenuOpenChange,
|
||||||
|
onOpenWorkspace,
|
||||||
|
onOpenProfile,
|
||||||
|
onOpenAuth,
|
||||||
|
onLogout,
|
||||||
|
onBugFeedback,
|
||||||
|
}: TopbarProps) {
|
||||||
|
const [isTopbarHidden, setIsTopbarHidden] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let restoreTimer: number | undefined;
|
||||||
|
|
||||||
|
const handleScroll = (event: Event) => {
|
||||||
|
if (profileMenuOpen) return;
|
||||||
|
const target = event.target;
|
||||||
|
const activeWorkspace = document.querySelector<HTMLElement>(".ecommerce-standalone__page--workspace:not([hidden])");
|
||||||
|
if (!activeWorkspace) return;
|
||||||
|
const isWorkspacePreviewScroll =
|
||||||
|
target instanceof HTMLElement && target.classList.contains("clone-ai-preview") && activeWorkspace.contains(target);
|
||||||
|
const isPageScroll =
|
||||||
|
target === document ||
|
||||||
|
target === document.scrollingElement ||
|
||||||
|
target === document.documentElement ||
|
||||||
|
target === document.body;
|
||||||
|
if (!isWorkspacePreviewScroll && !isPageScroll) return;
|
||||||
|
|
||||||
|
setIsTopbarHidden(true);
|
||||||
|
if (restoreTimer) window.clearTimeout(restoreTimer);
|
||||||
|
restoreTimer = window.setTimeout(() => {
|
||||||
|
setIsTopbarHidden(false);
|
||||||
|
}, 240);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScroll, { capture: true, passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", handleScroll, { capture: true });
|
||||||
|
if (restoreTimer) window.clearTimeout(restoreTimer);
|
||||||
|
};
|
||||||
|
}, [profileMenuOpen]);
|
||||||
|
|
||||||
|
const balance = Math.max(usage.balanceCents, 0) / 100;
|
||||||
|
const displayName = session?.user.displayName || session?.user.username || "用户";
|
||||||
|
const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0);
|
||||||
|
const shownWorkCount = actualWorkCount;
|
||||||
|
|
||||||
|
const avatarMenuStats = useMemo(
|
||||||
|
() => [
|
||||||
|
{ icon: <IdcardOutlined />, label: "UID", value: session?.user.id ?? "-" },
|
||||||
|
{ icon: <WalletOutlined />, label: "积分", value: `${balance.toFixed(2)} 积分` },
|
||||||
|
{ icon: <PictureOutlined />, label: "图片", value: usage.imageUsed },
|
||||||
|
{ icon: <VideoCameraOutlined />, label: "视频", value: usage.videoUsed },
|
||||||
|
{ icon: <PictureOutlined />, label: "作品", value: shownWorkCount },
|
||||||
|
],
|
||||||
|
[balance, session?.user.id, shownWorkCount, usage.imageUsed, usage.videoUsed],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className="ecommerce-standalone__topbar"
|
||||||
|
data-scroll-hidden={isTopbarHidden ? "true" : "false"}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: "none",
|
||||||
|
background: "transparent",
|
||||||
|
border: 0,
|
||||||
|
boxShadow: "none",
|
||||||
|
backdropFilter: "none",
|
||||||
|
WebkitBackdropFilter: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ecommerce-standalone__brand"
|
||||||
|
style={{ pointerEvents: "auto" }}
|
||||||
|
onClick={onOpenWorkspace}
|
||||||
|
>
|
||||||
|
<span className="ecommerce-standalone__logo" aria-hidden="true">
|
||||||
|
<img src="https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png" alt="" />
|
||||||
|
</span>
|
||||||
|
<strong>OmniAI 电商智能体</strong>
|
||||||
|
</button>
|
||||||
|
<div className="ecommerce-standalone__account">
|
||||||
|
{session ? (
|
||||||
|
<div className="ecommerce-profile-menu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ecommerce-profile-menu__trigger"
|
||||||
|
style={{ pointerEvents: "auto" }}
|
||||||
|
onClick={() => onProfileMenuOpenChange(!profileMenuOpen)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={profileMenuOpen}
|
||||||
|
>
|
||||||
|
<span className="ecommerce-standalone__credits">
|
||||||
|
{(Math.max(usage.balanceCents, 0) / 100).toFixed(2)} 积分
|
||||||
|
</span>
|
||||||
|
<LocalAvatar session={session} size="sm" />
|
||||||
|
<span className="ecommerce-profile-menu__name">{displayName}</span>
|
||||||
|
</button>
|
||||||
|
{profileMenuOpen ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ecommerce-profile-popover__backdrop"
|
||||||
|
aria-label="关闭账户信息"
|
||||||
|
onClick={() => onProfileMenuOpenChange(false)}
|
||||||
|
/>
|
||||||
|
<section className="ecommerce-profile-popover" role="dialog" aria-label="账户信息">
|
||||||
|
<div className="ecommerce-profile-popover__head">
|
||||||
|
<LocalAvatar session={session} size="md" />
|
||||||
|
<div>
|
||||||
|
<strong>{displayName}</strong>
|
||||||
|
<span>{session.user.username}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="ecommerce-profile-popover__stats">
|
||||||
|
{avatarMenuStats.map((item) => (
|
||||||
|
<div key={item.label}>
|
||||||
|
<dt>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</dt>
|
||||||
|
<dd>{item.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="ecommerce-profile-popover__actions">
|
||||||
|
<button type="button" className="is-primary" onClick={onOpenProfile}>
|
||||||
|
<UserOutlined />
|
||||||
|
个人中心
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onBugFeedback}>
|
||||||
|
<BugOutlined />
|
||||||
|
Bug 反馈
|
||||||
|
</button>
|
||||||
|
<button type="button" className="is-danger" onClick={onLogout}>
|
||||||
|
<LogoutOutlined />
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ecommerce-standalone__login-button"
|
||||||
|
style={{ pointerEvents: "auto" }}
|
||||||
|
onClick={() => onOpenAuth("login")}
|
||||||
|
>
|
||||||
|
<LoginOutlined />
|
||||||
|
<span>登录 / 注册</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,10 +1,11 @@
|
|||||||
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface EcommerceProgressBarProps {
|
interface EcommerceProgressBarProps {
|
||||||
status: "idle" | "generating" | "done" | "failed" | string;
|
status: "idle" | "generating" | "done" | "failed" | string;
|
||||||
label?: string;
|
label?: string;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
/** 0-100 真实进度。传入时进度条按真实值推进;省略时按状态做平滑蠕动。 */
|
||||||
|
progress?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapStatus(status: string): "running" | "completed" | "failed" {
|
function mapStatus(status: string): "running" | "completed" | "failed" {
|
||||||
@@ -14,9 +15,13 @@ function mapStatus(status: string): "running" | "completed" | "failed" {
|
|||||||
return "running";
|
return "running";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EcommerceProgressBar({ status, label, onCancel }: EcommerceProgressBarProps) {
|
export function EcommerceProgressBar({ status, label, onCancel, progress }: EcommerceProgressBarProps) {
|
||||||
const progress = mapStatus(status) === "running" ? 50 : 100;
|
const mapped = mapStatus(status);
|
||||||
const smoothed = useSmoothedProgress(progress, mapStatus(status));
|
// running 时目标取「真实进度」与兜底值 88 的较大者:有真实进度则跟随推进,
|
||||||
|
// 后端不推中间进度时也由平滑器持续蠕动到高位,不再卡死在 75%。
|
||||||
|
const realProgress = typeof progress === "number" ? Math.max(0, Math.min(100, progress)) : 0;
|
||||||
|
const target = mapped === "running" ? Math.max(realProgress, 88) : 100;
|
||||||
|
const smoothed = useSmoothedProgress(target, mapped);
|
||||||
|
|
||||||
if (status === "idle") return null;
|
if (status === "idle") return null;
|
||||||
|
|
||||||
|
|||||||
@@ -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 "../../styles/pages/ecommerce-video.css";
|
||||||
import {
|
import {
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
@@ -13,8 +13,6 @@ import {
|
|||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
runVideoPlan,
|
runVideoPlan,
|
||||||
renderSceneImage,
|
|
||||||
renderScene,
|
|
||||||
buildSceneTasks,
|
buildSceneTasks,
|
||||||
saveVideoHistory,
|
saveVideoHistory,
|
||||||
buildComplianceFailureMessage,
|
buildComplianceFailureMessage,
|
||||||
@@ -29,7 +27,6 @@ import {
|
|||||||
type PlanStep,
|
type PlanStep,
|
||||||
} from "./ecommerceVideoTypes";
|
} from "./ecommerceVideoTypes";
|
||||||
import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
|
import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
|
||||||
import { ServerRequestError } from "../../api/serverConnection";
|
|
||||||
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
|
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
|
||||||
import { useAppStore } from "../../stores";
|
import { useAppStore } from "../../stores";
|
||||||
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
||||||
@@ -39,6 +36,7 @@ import {
|
|||||||
clearEcommerceVideoState,
|
clearEcommerceVideoState,
|
||||||
} from "./ecommerceVideoKeepalive";
|
} from "./ecommerceVideoKeepalive";
|
||||||
import { saveUnifiedEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
|
import { saveUnifiedEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
|
||||||
|
import { useVideoSceneRunner } from "./useVideoSceneRunner";
|
||||||
|
|
||||||
interface EcommerceVideoWorkspaceProps {
|
interface EcommerceVideoWorkspaceProps {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@@ -137,8 +135,6 @@ export default function EcommerceVideoWorkspace({
|
|||||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||||
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
|
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
|
||||||
const [flowZoom, setFlowZoom] = useState(1);
|
const [flowZoom, setFlowZoom] = useState(1);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
const renderAbortRef = useRef({ current: false });
|
|
||||||
const actionNoticeTimerRef = useRef<number | null>(null);
|
const actionNoticeTimerRef = useRef<number | null>(null);
|
||||||
const setView = useAppStore((s) => s.setView);
|
const setView = useAppStore((s) => s.setView);
|
||||||
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
|
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
|
||||||
@@ -150,6 +146,28 @@ export default function EcommerceVideoWorkspace({
|
|||||||
[productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution],
|
[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 ─────────────
|
// ── Keep-alive: restore saved state on mount ─────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return;
|
if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return;
|
||||||
@@ -180,11 +198,11 @@ export default function EcommerceVideoWorkspace({
|
|||||||
setError(buildComplianceFailureMessage(planResult.compliance));
|
setError(buildComplianceFailureMessage(planResult.compliance));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
const timer = setTimeout(() => { void runImagePhase(scenes); }, delay);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
|
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
|
||||||
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
|
const timer = setTimeout(() => { void runVideoPhase(scenes); }, delay);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [stage, scenes, planResult]);
|
}, [stage, scenes, planResult]);
|
||||||
@@ -301,82 +319,11 @@ export default function EcommerceVideoWorkspace({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (keepalivePollingStartedRef.current) return;
|
if (keepalivePollingStartedRef.current) return;
|
||||||
if (!scenes.length || stage === "idle" || stage === "cancelled" || stage === "completed") return;
|
if (!scenes.length || stage === "idle" || stage === "cancelled" || stage === "completed") return;
|
||||||
|
|
||||||
const hasRunningScenes = scenes.some((s) => s.status === "running" || s.status === "pending");
|
const hasRunningScenes = scenes.some((s) => s.status === "running" || s.status === "pending");
|
||||||
if (!hasRunningScenes) return;
|
if (!hasRunningScenes) return;
|
||||||
keepalivePollingStartedRef.current = true;
|
keepalivePollingStartedRef.current = true;
|
||||||
|
void resumePolling(stage, scenes);
|
||||||
// Resume polling for image generation tasks
|
}, [scenes, stage, resumePolling]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// Note: keep-alive is NOT cleared on completion — results persist across page switches.
|
// Note: keep-alive is NOT cleared on completion — results persist across page switches.
|
||||||
// Only cleared when user explicitly starts a new plan via handlePlan.
|
// Only cleared when user explicitly starts a new plan via handlePlan.
|
||||||
@@ -559,157 +506,9 @@ export default function EcommerceVideoWorkspace({
|
|||||||
await runPlanFlow(planProgress);
|
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 ───────────────────────────────────────────
|
// ── Derived state ───────────────────────────────────────────
|
||||||
|
|
||||||
@@ -759,13 +558,13 @@ export default function EcommerceVideoWorkspace({
|
|||||||
) : null}
|
) : null}
|
||||||
{stage === "planned" || stage === "imaged" ? (
|
{stage === "planned" || stage === "imaged" ? (
|
||||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
<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 />}
|
{stage === "imaged" ? <ReloadOutlined /> : <SendOutlined />}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? (
|
{stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? (
|
||||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
<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 />
|
<SendOutlined />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -779,7 +578,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 生成视频中</span>
|
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 生成视频中</span>
|
||||||
) : null}
|
) : null}
|
||||||
{stage === "planning" || stage === "imaging" || stage === "rendering" ? (
|
{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 />
|
<StopOutlined />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -868,7 +667,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
<span className="ecom-video-tree-node__tag">分镜视频{scene.sceneId}</span>
|
<span className="ecom-video-tree-node__tag">分镜视频{scene.sceneId}</span>
|
||||||
{vidFailed ? (
|
{vidFailed ? (
|
||||||
<button type="button" className="ecom-video-tree-node__retry"
|
<button type="button" className="ecom-video-tree-node__retry"
|
||||||
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
|
onClick={(e) => { e.stopPropagation(); void retryScene(scene); }}
|
||||||
title="重试此镜头">
|
title="重试此镜头">
|
||||||
<ReloadOutlined />
|
<ReloadOutlined />
|
||||||
</button>
|
</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>
|
</section>
|
||||||
) : null}
|
) : 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" ? (
|
{cloneOutput === "set" ? (
|
||||||
<section className="clone-ai-count-panel" aria-label="套图图片数量">
|
<section className="clone-ai-count-panel" aria-label="套图图片数量">
|
||||||
<div className="clone-ai-dynamic-head">
|
<div className="clone-ai-dynamic-head">
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
AppstoreOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
FireOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
SmileOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
|
||||||
|
export type CopywritingType =
|
||||||
|
| "self-media"
|
||||||
|
| "universal"
|
||||||
|
| "original"
|
||||||
|
| "imitate"
|
||||||
|
| "wechat"
|
||||||
|
| "crossborder"
|
||||||
|
| "emoji"
|
||||||
|
| "more";
|
||||||
|
|
||||||
|
interface CopywritingTypeOption {
|
||||||
|
key: CopywritingType;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const copywritingTypes: CopywritingTypeOption[] = [
|
||||||
|
{ key: "self-media", label: "自媒体文案", icon: <MessageOutlined />, description: "小红书/抖音/公众号风格" },
|
||||||
|
{ key: "universal", label: "万能写作", icon: <EditOutlined />, description: "通用场景长文短句" },
|
||||||
|
{ key: "original", label: "一键原创", icon: <ThunderboltOutlined />, description: "快速改写去重" },
|
||||||
|
{ key: "imitate", label: "文案仿写", icon: <CopyOutlined />, description: "参照爆款风格重写" },
|
||||||
|
{ key: "wechat", label: "微信营销文案", icon: <FileTextOutlined />, description: "朋友圈/社群转化文案" },
|
||||||
|
{ key: "crossborder", label: "跨境商品文案", icon: <GlobalOutlined />, description: "Amazon/Temu 卖点描述" },
|
||||||
|
{ key: "emoji", label: "文案加Emoji", icon: <SmileOutlined />, description: "自动插入表情符号" },
|
||||||
|
{ key: "more", label: "更多场景", icon: <AppstoreOutlined />, description: "持续更新中" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const wordCountOptions = ["不限", "100字", "300字", "500字", "800字"];
|
||||||
|
|
||||||
|
const exampleResults: Record<CopywritingType, Array<{ title: string; body: string; points: string[] }>> = {
|
||||||
|
"self-media": [
|
||||||
|
{
|
||||||
|
title: "超值干发神器,吸水力 MAX!",
|
||||||
|
body: "家人们,我发现了一款干发帽,双层加厚吸水力超强!而且只要个位数就能到手啊!",
|
||||||
|
points: [
|
||||||
|
"超强吸水力:这款干发帽采用微纤维材质,轻轻一裹,水分立马被吸走,头发快速告别湿漉漉。",
|
||||||
|
"柔软亲肤:触感超级柔软,对皮肤和头发都是温柔的抚摸,不会有摩擦伤害哦。",
|
||||||
|
"加厚设计:比普通干发帽更厚实,吸水效果自然更胜一筹,长发妹子的福音!",
|
||||||
|
"方便携带:轻巧不占空间,不论是去健身房还是旅行,携带都毫无负担。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
universal: [
|
||||||
|
{
|
||||||
|
title: "直接抄作业!科学的减重方法必试!",
|
||||||
|
body: "姐妹们冲鸭!有很多科学有效的方式可以帮助我们实现理想体重,今天就来分享一下必试的方法!",
|
||||||
|
points: [
|
||||||
|
"快乐有氧运动:科学的减重方式,通过有氧运动如慢跑、游泳等,能够促进脂肪燃烧,让身体更健康!",
|
||||||
|
"均衡饮食规划:摄入足够的蛋白质、蔬果以及谷物,避免过多的高糖和高脂食物,帮助达到减重目标!",
|
||||||
|
"科学计算热量:了解自己每日所需的卡路里摄入量,合理安排每餐的热量搭配,控制总摄入量。",
|
||||||
|
"坚持低强度运动:逐渐增加日常活动量,如步行、瑜伽等,通过持续的轻度运动,加速代谢!",
|
||||||
|
"合理休息调节:不要忽视睡眠的重要性,保证每晚充足的睡眠时间,帮助恢复体力和新陈代谢。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
original: [
|
||||||
|
{
|
||||||
|
title: "原创种草|这款干发帽真的值得入!",
|
||||||
|
body: "洗完头最烦的就是湿哒哒滴水?试试这条双层加厚干发帽,吸水速度真的惊艳到我。",
|
||||||
|
points: [
|
||||||
|
"加厚材质,吸水更快更彻底",
|
||||||
|
"柔软不勒头,长发短发都能用",
|
||||||
|
"轻便好收纳,差旅党必备",
|
||||||
|
"性价比超高,入手不亏",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
imitate: [
|
||||||
|
{
|
||||||
|
title: "仿写爆款|让头发速干的小心机",
|
||||||
|
body: "姐妹们有没有发现,最近超火的干发帽真的太好用了!轻轻一裹,几分钟头发就半干了。",
|
||||||
|
points: [
|
||||||
|
"双层加厚,吸水力翻倍",
|
||||||
|
"柔软亲肤,不伤发质",
|
||||||
|
"小巧便携,出门也能带",
|
||||||
|
"颜值在线,多色可选",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
wechat: [
|
||||||
|
{
|
||||||
|
title: "朋友圈文案|个位数到手的干发神器",
|
||||||
|
body: "今天必须给大家安利这个干发帽!双层加厚,吸水超强,个位数就能到手,真的不冲吗?",
|
||||||
|
points: [
|
||||||
|
"微纤维材质,轻柔速干",
|
||||||
|
"加厚设计,吸水更彻底",
|
||||||
|
"小巧便携,旅行出差都能带",
|
||||||
|
"限时好价,手慢无",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
crossborder: [
|
||||||
|
{
|
||||||
|
title: "Amazon Listing|Super Absorbent Hair Turban",
|
||||||
|
body: "Made with ultra-soft microfiber, this double-layer hair turban dries hair quickly while protecting delicate strands.",
|
||||||
|
points: [
|
||||||
|
"Double-layer microfiber for maximum absorbency",
|
||||||
|
"Gentle on hair and skin, no frizz or breakage",
|
||||||
|
"Lightweight and travel-friendly design",
|
||||||
|
"Secure button closure stays in place",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
emoji: [
|
||||||
|
{
|
||||||
|
title: "✨个位数到手的干发神器,吸水力 MAX!",
|
||||||
|
body: "家人们👋,我发现了一款超棒的干发帽💧,双层加厚吸水力超强!而且只要个位数就能到手啊🛒!",
|
||||||
|
points: [
|
||||||
|
"💦 超强吸水力:微纤维材质,轻轻一裹水分吸走",
|
||||||
|
"☁️ 柔软亲肤:触感温柔,不伤头发和皮肤",
|
||||||
|
"🎒 方便携带:轻巧不占空间,旅行健身都能带",
|
||||||
|
"💰 超值价格:个位数到手,性价比拉满",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
more: [
|
||||||
|
{
|
||||||
|
title: "更多场景示例",
|
||||||
|
body: "选择左侧具体文案类型即可生成对应场景内容,更多场景持续更新中。",
|
||||||
|
points: ["选择合适的文案类型", "填写内容需求", "选择生成字数", "点击开始生成"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface EcommerceCopywritingPanelProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EcommerceCopywritingPanel({ onClose }: EcommerceCopywritingPanelProps) {
|
||||||
|
const [selectedType, setSelectedType] = useState<CopywritingType>("self-media");
|
||||||
|
const [requirement, setRequirement] = useState("");
|
||||||
|
const [wordCount, setWordCount] = useState("不限");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [results, setResults] = useState<typeof exampleResults["self-media"]>([]);
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
setLoading(true);
|
||||||
|
setResults([]);
|
||||||
|
// 模拟生成延迟
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setResults(exampleResults[selectedType]);
|
||||||
|
setLoading(false);
|
||||||
|
}, 1200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedTypeLabel = copywritingTypes.find((item) => item.key === selectedType)?.label ?? "文案";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="ecom-copywriting-page ecom-tool-page-enter" aria-label="一键文案">
|
||||||
|
<div className="ecom-copywriting-body">
|
||||||
|
<aside className="ecom-copywriting-panel" aria-label="文案设置">
|
||||||
|
<header className="ecom-copywriting-panel-head">
|
||||||
|
<strong className="ecom-copywriting-page-title">一键文案</strong>
|
||||||
|
<button type="button" className="ecom-copywriting-back" onClick={onClose}>
|
||||||
|
首页
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ecom-copywriting-back" onClick={onClose}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="ecom-copywriting-section">
|
||||||
|
<strong className="ecom-copywriting-section-title">选择文案类型</strong>
|
||||||
|
<div className="ecom-copywriting-type-grid">
|
||||||
|
{copywritingTypes.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
className={`ecom-copywriting-type-card${selectedType === item.key ? " is-active" : ""}`}
|
||||||
|
onClick={() => setSelectedType(item.key)}
|
||||||
|
>
|
||||||
|
<span className="ecom-copywriting-type-icon" aria-hidden="true">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
<span className="ecom-copywriting-type-label">{item.label}</span>
|
||||||
|
<span className="ecom-copywriting-type-desc">{item.description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ecom-copywriting-section">
|
||||||
|
<strong className="ecom-copywriting-section-title">内容需求</strong>
|
||||||
|
<textarea
|
||||||
|
className="ecom-copywriting-textarea"
|
||||||
|
value={requirement}
|
||||||
|
onChange={(event) => setRequirement(event.target.value)}
|
||||||
|
placeholder="例如:主题、核心卖点、适用人群、期望场景等"
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ecom-copywriting-section">
|
||||||
|
<strong className="ecom-copywriting-section-title">生成字数</strong>
|
||||||
|
<div className="ecom-copywriting-wordcount">
|
||||||
|
{wordCountOptions.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
className={wordCount === item ? "is-active" : ""}
|
||||||
|
onClick={() => setWordCount(item)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ecom-copywriting-generate"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="ecom-copywriting-spinner" />
|
||||||
|
生成中…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ThunderboltOutlined />
|
||||||
|
开始生成
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="ecom-copywriting-stage" aria-label="生成文案预览">
|
||||||
|
<header className="ecom-copywriting-preview-head">
|
||||||
|
<h1>生成文案</h1>
|
||||||
|
<p>
|
||||||
|
基于 <span>{selectedTypeLabel}</span> 风格,AI 为你生成高转化文案。
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="ecom-copywriting-results">
|
||||||
|
{results.length === 0 && !loading ? (
|
||||||
|
<div className="ecom-copywriting-empty">
|
||||||
|
<FileTextOutlined />
|
||||||
|
<strong>等待生成</strong>
|
||||||
|
<em>填写需求后点击「开始生成」即可查看文案结果</em>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="ecom-copywriting-loading">
|
||||||
|
<span className="ecom-copywriting-spinner" />
|
||||||
|
<span>AI 正在生成文案,请稍候…</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{results.map((item, index) => (
|
||||||
|
<article key={index} className="ecom-copywriting-result-card">
|
||||||
|
<header>
|
||||||
|
<span>示例 {index + 1}</span>
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
</header>
|
||||||
|
<p className="ecom-copywriting-result-body">{item.body}</p>
|
||||||
|
<ul className="ecom-copywriting-result-points">
|
||||||
|
{item.points.map((point, pointIndex) => (
|
||||||
|
<li key={pointIndex}>
|
||||||
|
<span>{pointIndex + 1}</span>
|
||||||
|
{point}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -2,15 +2,6 @@ import React from "react";
|
|||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import "./styles/index.css";
|
import "./styles/index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { reportError } from "./utils/errorReporting";
|
|
||||||
|
|
||||||
window.addEventListener("unhandledrejection", (event) => {
|
|
||||||
reportError(event.reason, "rejection");
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("error", (event) => {
|
|
||||||
if (event.error) reportError(event.error, "unhandled");
|
|
||||||
});
|
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -24,17 +24,33 @@ interface PersistedQueueSnapshot {
|
|||||||
savedAt: number;
|
savedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "omniai:generation-queue";
|
const STORAGE_KEY_PREFIX = "omniai:generation-queue";
|
||||||
const MAX_ITEMS = 80;
|
const MAX_ITEMS = 80;
|
||||||
const STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
const STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
||||||
|
|
||||||
|
function hashUserId(): string {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("omniai-web-session");
|
||||||
|
if (!raw) return "anon";
|
||||||
|
const parsed = JSON.parse(raw) as { user?: { id?: number | string } };
|
||||||
|
return String(parsed?.user?.id || "anon");
|
||||||
|
} catch {
|
||||||
|
return "anon";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 队列按用户分桶持久化:不同账号读写不同 key,避免登出再登他人账号时读到上一个用户的队列。
|
||||||
|
function getStorageKey(): string {
|
||||||
|
return `${STORAGE_KEY_PREFIX}:${hashUserId()}`;
|
||||||
|
}
|
||||||
|
|
||||||
function loadPersistedQueue(): GenerationQueueItem[] {
|
function loadPersistedQueue(): GenerationQueueItem[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(getStorageKey());
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
const snapshot = JSON.parse(raw) as PersistedQueueSnapshot;
|
const snapshot = JSON.parse(raw) as PersistedQueueSnapshot;
|
||||||
if (Date.now() - (snapshot.savedAt || 0) > STALE_MS) {
|
if (Date.now() - (snapshot.savedAt || 0) > STALE_MS) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(getStorageKey());
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return snapshot.items.filter(
|
return snapshot.items.filter(
|
||||||
@@ -48,7 +64,7 @@ function loadPersistedQueue(): GenerationQueueItem[] {
|
|||||||
function persistQueue(items: GenerationQueueItem[]): void {
|
function persistQueue(items: GenerationQueueItem[]): void {
|
||||||
try {
|
try {
|
||||||
const snapshot: PersistedQueueSnapshot = { version: 1, items, savedAt: Date.now() };
|
const snapshot: PersistedQueueSnapshot = { version: 1, items, savedAt: Date.now() };
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
localStorage.setItem(getStorageKey(), JSON.stringify(snapshot));
|
||||||
} catch { /* quota exceeded */ }
|
} catch { /* quota exceeded */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,17 +79,6 @@ interface GenerationStoreState {
|
|||||||
clearTerminal: () => void;
|
clearTerminal: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashUserId(): string {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem("omniai-web-session");
|
|
||||||
if (!raw) return "anon";
|
|
||||||
const parsed = JSON.parse(raw) as { user?: { id?: number | string } };
|
|
||||||
return String(parsed?.user?.id || "anon");
|
|
||||||
} catch {
|
|
||||||
return "anon";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialQueue = loadPersistedQueue();
|
const initialQueue = loadPersistedQueue();
|
||||||
|
|
||||||
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
|
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./useProjectStore.ts";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./useSessionStore.ts";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./useTaskStore.ts";
|
||||||
+5264
-10
File diff suppressed because it is too large
Load Diff
@@ -2950,6 +2950,15 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-clone-page[data-tool="clone"] .clone-ai-result-stack > .clone-ai-node-label {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 5;
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-source-corner-action {
|
.product-clone-page[data-tool="clone"] .clone-ai-source-corner-action {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -6px;
|
top: -6px;
|
||||||
@@ -7852,7 +7861,7 @@
|
|||||||
.product-set-preview-backdrop {
|
.product-set-preview-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 100;
|
z-index: 4000;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: rgb(17 24 39 / 58%);
|
background: rgb(17 24 39 / 58%);
|
||||||
@@ -12093,3 +12102,110 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
|
|||||||
grid-row: auto !important;
|
grid-row: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Composer menu anchors: place option popovers under the clicked control, not under the whole composer. */
|
||||||
|
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 {
|
||||||
|
position: absolute !important;
|
||||||
|
inset: var(--composer-popover-top, 48px) auto auto var(--composer-popover-left, 0px) !important;
|
||||||
|
right: auto !important;
|
||||||
|
bottom: auto !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transform: none !important;
|
||||||
|
translate: none !important;
|
||||||
|
z-index: 160 !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--platform {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 平台弹窗宽度仅桌面/平板固定;≤640px 由移动端断点的全宽规则接管。 */
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
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--platform {
|
||||||
|
width: min(460px, calc(100% - 24px)) !important;
|
||||||
|
max-width: min(460px, calc(100% - 24px)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 平台选项:logo + 名称横排,名称过长省略,避免在窄网格里溢出弹窗。 */
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-popover--platform button {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-popover--platform button .ecom-platform-name {
|
||||||
|
min-width: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
text-overflow: ellipsis !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
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--languages,
|
||||||
|
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--ratio-picker {
|
||||||
|
width: max-content !important;
|
||||||
|
min-width: 200px !important;
|
||||||
|
max-width: min(420px, calc(100% - var(--composer-popover-left, 0px))) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 宽设置面板:固定宽度并靠右对齐 composer,避免从靠右的"设置"按钮左对齐展开时顶出右边缘被裁。
|
||||||
|
仅桌面/平板生效;≤640px 由移动端断点的全宽规则接管。 */
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
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 {
|
||||||
|
width: min(520px, calc(100% - 24px)) !important;
|
||||||
|
max-width: min(520px, calc(100% - 24px)) !important;
|
||||||
|
left: auto !important;
|
||||||
|
inset: var(--composer-popover-top, 48px) 12px auto auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uploaded assets stay as compact attachments inside the composer hierarchy. */
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) {
|
||||||
|
min-height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-popover,
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-popover {
|
||||||
|
position: static !important;
|
||||||
|
grid-column: 1 !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
justify-self: start !important;
|
||||||
|
gap: 10px !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
max-height: none !important;
|
||||||
|
padding: 2px 2px 0 !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-thumb,
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-thumb {
|
||||||
|
flex: 0 0 64px !important;
|
||||||
|
width: 64px !important;
|
||||||
|
height: 64px !important;
|
||||||
|
border-radius: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-add,
|
||||||
|
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-add {
|
||||||
|
flex: 0 0 44px !important;
|
||||||
|
width: 44px !important;
|
||||||
|
height: 64px !important;
|
||||||
|
min-height: 44px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
font-size: 24px !important;
|
||||||
|
}
|
||||||
|
|||||||
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,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src", "vite.config.ts"]
|
"include": ["src", "vite.config.ts", "vitest.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-5
@@ -2,8 +2,14 @@ import react from "@vitejs/plugin-react";
|
|||||||
import { compression } from "vite-plugin-compression2";
|
import { compression } from "vite-plugin-compression2";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig(() => {
|
export default defineConfig(({ command }) => {
|
||||||
const devApiTarget = process.env.OMNIAI_DEV_API_TARGET?.trim();
|
// dev 模式下默认把 /api 代理到线上电商后端,本地 `npm run dev` 即可直接登录/生成。
|
||||||
|
// 想连本地或 SSH 隧道的后端时,用环境变量覆盖:
|
||||||
|
// $env:OMNIAI_DEV_API_TARGET="http://127.0.0.1:3601"; npm run dev
|
||||||
|
// 仅 dev 代理用途,不会打进生产构建产物。
|
||||||
|
const devApiTarget =
|
||||||
|
process.env.OMNIAI_DEV_API_TARGET?.trim() ||
|
||||||
|
(command === "serve" ? "https://omniai.com.cn" : "");
|
||||||
const apiProxy = devApiTarget
|
const apiProxy = devApiTarget
|
||||||
? {
|
? {
|
||||||
"/api": {
|
"/api": {
|
||||||
@@ -27,9 +33,7 @@ export default defineConfig(() => {
|
|||||||
port: 4174,
|
port: 4174,
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
},
|
},
|
||||||
esbuild: {
|
...(command === "build" ? { esbuild: { drop: ["console", "debugger"] } } : {}),
|
||||||
drop: ["console", "debugger"],
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|||||||
@@ -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