diff --git a/server-patches/patch-email-verification.js b/server-patches/patch-email-verification.js new file mode 100644 index 0000000..bfc1967 --- /dev/null +++ b/server-patches/patch-email-verification.js @@ -0,0 +1,291 @@ +const fs = require("fs"); + +// ── Patch 1: context.js ────────────────────────────────────── +const ctxPath = "/opt/omniai-server/src/routes/context.js"; +let ctx = fs.readFileSync(ctxPath, "utf8"); + +const smsMaxLine = "const SMS_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.SMS_CODE_MAX_ATTEMPTS) || 5);"; +const emailConsts = ` +const EMAIL_PURPOSES = new Set(["register", "login", "reset"]); +const EMAIL_CODE_TTL_MINUTES = Math.max(1, Number(process.env.EMAIL_CODE_TTL_MINUTES) || 10); +const EMAIL_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.EMAIL_CODE_COOLDOWN_SECONDS) || 60); +const EMAIL_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.EMAIL_CODE_MAX_ATTEMPTS) || 5);`; + +if (!ctx.includes("EMAIL_PURPOSES")) { + ctx = ctx.replace(smsMaxLine, smsMaxLine + emailConsts); + console.log("[ctx] added EMAIL_PURPOSES"); +} + +const afterConsume = ' await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);\n return true;\n}'; +const emailFuncs = ` +function hashEmailCode(email, code) { + const secret = process.env.EMAIL_CODE_SECRET || process.env.JWT_SECRET || "omniai-dev-email-secret"; + return crypto.createHash("sha256").update(email + ":" + code + ":" + secret).digest("hex"); +} + +async function sendEmailCode(email, code, purpose) { + const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase(); + + if (provider === "smtp") { + const nodemailer = require("nodemailer"); + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT) || 587, + secure: process.env.SMTP_SECURE === "1", + auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, + }); + + const purposeText = purpose === "register" ? "\u6ce8\u518c" : purpose === "login" ? "\u767b\u5f55" : "\u91cd\u7f6e\u5bc6\u7801"; + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: email, + subject: "[OmniAI] \u90ae\u7bb1\u9a8c\u8bc1\u7801", + text: "\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a" + code + "\n\u7528\u9014\uff1a" + purposeText + "\n\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f\n\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002", + html: '

OmniAI \u90ae\u7bb1\u9a8c\u8bc1

\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a

' + code + '

\u7528\u9014\uff1a' + purposeText + '

\u6709\u6548\u671f\uff1a' + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + ' \u5206\u949f


\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002

', + }); + return { provider: "smtp" }; + } + + console.log("[email:" + purpose + "] " + email + " verification code: " + code + " (mock provider)"); + return { provider: "mock", devCode: process.env.EMAIL_DEV_RETURN_CODE === "1" ? code : undefined }; +} + +async function consumeEmailCode(email, code, purpose) { + const { rows } = await pool.query( + "SELECT id, code_hash, attempts FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1", + [email, purpose] + ); + const row = rows[0]; + if (!row) return false; + if (Number(row.attempts || 0) >= EMAIL_CODE_MAX_ATTEMPTS) return false; + + const expectedHash = hashEmailCode(email, String(code || "").trim()); + if (row.code_hash !== expectedHash) { + await pool.query("UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1", [row.id]); + return false; + } + await pool.query("UPDATE email_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]); + return true; +}`; + +if (!ctx.includes("hashEmailCode")) { + ctx = ctx.replace(afterConsume, afterConsume + emailFuncs); + console.log("[ctx] added email functions"); +} + +// Update exports +if (!ctx.includes("EMAIL_PURPOSES,")) { + ctx = ctx.replace(" EMAIL_PATTERN,\n SMS_PURPOSES,", " EMAIL_PATTERN,\n EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n SMS_PURPOSES,"); +} +if (!ctx.includes("hashEmailCode,")) { + ctx = ctx.replace(" sendSmsCode,\n createLoginResultForUserId,", " sendSmsCode,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n createLoginResultForUserId,"); +} + +fs.writeFileSync(ctxPath, ctx, "utf8"); +console.log("[ctx] written"); + +// ── Patch 2: auth.js ───────────────────────────────────────── +const authPath = "/opt/omniai-server/src/routes/auth.js"; +let auth = fs.readFileSync(authPath, "utf8"); + +// 2a. Add imports inside context.js destructuring +if (!auth.includes("hashEmailCode,")) { + auth = auth.replace( + '} = require("./context");', + ' EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n} = require("./context");' + ); + console.log("[auth] added imports"); +} + +// 2b. Insert new routes before module.exports +const newRoutes = ` + // ============================================================ + // Email verification routes + // ============================================================ + + router.post("/auth/email/send-code", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const purpose = String(req.body?.purpose || "register"); + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" }); + + if (purpose === "register") { + const inviteOk = await ensureBetaInviteCode(req, res); + if (!inviteOk) return; + } + + try { + const { rows: recentCodes } = await pool.query( + "SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1", + [email, purpose, EMAIL_CODE_COOLDOWN_SECONDS] + ); + if (recentCodes.length > 0) { + return res.status(429).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" }); + } + + if (purpose === "register") { + const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", [email]); + if (existing.length > 0) return res.status(409).json({ error: "\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c" }); + } + + if (purpose === "login" || purpose === "reset") { + const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]); + if (existing.length === 0) return res.status(404).json({ error: "\u8be5\u90ae\u7bb1\u5c1a\u672a\u6ce8\u518c" }); + } + + const code = generateSmsCode(); + const codeHash = hashEmailCode(email, code); + await pool.query( + "INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval)", + [email, purpose, codeHash, EMAIL_CODE_TTL_MINUTES] + ); + + const sendResult = await sendEmailCode(email, code, purpose); + res.json({ + success: true, + provider: sendResult.provider, + ttlSeconds: EMAIL_CODE_TTL_MINUTES * 60, + cooldownSeconds: EMAIL_CODE_COOLDOWN_SECONDS, + ...(sendResult.devCode ? { devCode: sendResult.devCode } : {}), + }); + } catch (error) { + console.error("[auth/email/send-code] failed", error); + res.status(500).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u5931\u8d25" }); + } + }); + + router.post("/auth/email/verify", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const code = String(req.body?.code || "").trim(); + const purpose = String(req.body?.purpose || "register"); + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" }); + if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" }); + + try { + const verified = await consumeEmailCode(email, code, purpose); + if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" }); + if (purpose === "register" || purpose === "login") { + await pool.query("UPDATE users SET email_verified = 1 WHERE LOWER(email) = LOWER($1)", [email]); + } + res.json({ success: true }); + } catch (error) { + console.error("[auth/email/verify] failed", error); + res.status(500).json({ error: "\u9a8c\u8bc1\u5931\u8d25" }); + } + }); + + router.post("/auth/forgot-password", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + + try { + const { rows } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]); + if (rows.length === 0) { + return res.json({ success: true, message: "\u5982\u679c\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c\uff0c\u91cd\u7f6e\u94fe\u63a5\u5df2\u53d1\u9001" }); + } + + const { rows: recentCodes } = await pool.query( + "SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = 'reset' AND created_at > NOW() - ($2::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1", + [email, EMAIL_CODE_COOLDOWN_SECONDS] + ); + if (recentCodes.length > 0) { + return res.status(429).json({ error: "\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" }); + } + + const code = generateSmsCode(); + const codeHash = hashEmailCode(email, code); + await pool.query( + "INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, 'reset', $2, NOW() + ($3::text || ' minutes')::interval)", + [email, codeHash, EMAIL_CODE_TTL_MINUTES] + ); + await sendEmailCode(email, code, "reset"); + res.json({ success: true, message: "\u91cd\u7f6e\u9a8c\u8bc1\u7801\u5df2\u53d1\u9001\u5230\u60a8\u7684\u90ae\u7bb1" }); + } catch (error) { + console.error("[auth/forgot-password] failed", error); + res.status(500).json({ error: "\u53d1\u9001\u5931\u8d25" }); + } + }); + + router.post("/auth/reset-password", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const code = String(req.body?.code || "").trim(); + const newPassword = String(req.body?.newPassword || ""); + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" }); + const passwordError = validatePassword(newPassword); + if (passwordError) return res.status(400).json({ error: passwordError }); + + try { + const verified = await consumeEmailCode(email, code, "reset"); + if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" }); + const hash = await bcrypt.hash(newPassword, 10); + await pool.query("UPDATE users SET password_hash = $1 WHERE LOWER(email) = LOWER($2)", [hash, email]); + res.json({ success: true, message: "\u5bc6\u7801\u91cd\u7f6e\u6210\u529f\uff0c\u8bf7\u91cd\u65b0\u767b\u5f55" }); + } catch (error) { + console.error("[auth/reset-password] failed", error); + res.status(500).json({ error: "\u5bc6\u7801\u91cd\u7f6e\u5931\u8d25" }); + } + }); + +`; + +if (!auth.includes("/auth/email/send-code")) { + const endMarker = "\n}\n\nmodule.exports = {"; + auth = auth.replace(endMarker, "\n" + newRoutes + "}\n\nmodule.exports = {"); + console.log("[auth] added new routes"); +} + +// 2c. Update register-email to require verification code +// Replace: router.post("/auth/register-email" ... without code check +// With: router.post("/auth/register-email" ... with code verification + +const oldRegisterEmail = ` router.post("/auth/register-email", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const usernameInput = String(req.body?.username || "").trim(); + const password = String(req.body?.password || ""); + + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + const passwordError = validatePassword(password); + if (passwordError) return res.status(400).json({ error: passwordError }); + const registrationInvite = await ensureRegistrationInvite(req, res); + if (!registrationInvite) return; + + try { + const { rows: existingEmail }`; + +const newRegisterEmail = ` router.post("/auth/register-email", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const usernameInput = String(req.body?.username || "").trim(); + const password = String(req.body?.password || ""); + const code = String(req.body?.code || "").trim(); + + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" }); + const passwordError = validatePassword(password); + if (passwordError) return res.status(400).json({ error: passwordError }); + const registrationInvite = await ensureRegistrationInvite(req, res); + if (!registrationInvite) return; + + try { + const verified = await consumeEmailCode(email, code, "register"); + if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" }); + + const { rows: existingEmail }`; + +if (auth.includes(oldRegisterEmail)) { + auth = auth.replace(oldRegisterEmail, newRegisterEmail); + console.log("[auth] updated register-email with verification"); +} else { + console.log("[auth] WARNING: register-email pattern not found, skipping"); +} + +fs.writeFileSync(authPath, auth, "utf8"); +console.log("[auth] written"); +console.log("\nDone."); diff --git a/src/App.tsx b/src/App.tsx index f301622..062e150 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { reportError } from "./utils/errorReporting"; import { initNotificationPermission } from "./utils/generationNotifier"; import PageTransition from "./components/PageTransition"; import ToastContainer from "./components/toast/ToastContainer"; +import { toast } from "./components/toast/toastStore"; import { aiGenerationClient } from "./api/aiGenerationClient"; import { keyServerClient } from "./api/keyServerClient"; import { notificationClient } from "./api/notificationClient"; @@ -32,7 +33,10 @@ import { } from "./api/serverConnection"; import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway"; import { translateTaskError } from "./utils/translateTaskError"; +import { recoverAndResumeTasks } from "./services/backgroundTaskRunner"; import AppShell from "./components/AppShell"; +const NotFoundPage = lazy(() => import("./components/NotFoundPage")); +const CompliancePage = lazy(() => import("./features/compliance/CompliancePage")); import { cloneWorkflow, createBlankWorkflow } from "./data/workflows"; const AgentPage = lazy(() => import("./features/agent/AgentPage")); const AssetsPage = lazy(() => import("./features/assets/AssetsPage")); @@ -55,7 +59,6 @@ const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/Wat const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage")); const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage")); const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage")); -const SettingsPage = lazy(() => import("./features/settings/SettingsPage")); const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage")); import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage"; import { @@ -102,7 +105,6 @@ const VIEW_KEYS = new Set([ "ecommerce", "scriptTokens", "tokenUsage", - "settings", "imageWorkbench", "resolutionUpscale", "watermarkRemoval", @@ -115,22 +117,29 @@ const VIEW_KEYS = new Set([ "communityCaseAdd", "report", "providerHealth", + "userAgreement", + "privacyPolicy", + "not-found", ]); -const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more"]); +const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]); function normalizeViewKey(rawView: string): WebViewKey { const normalized = rawView === "profile" || rawView === "auth" ? "login" - : rawView === "ecommerceHub" - ? "ecommerce" + : rawView === "ecommerceHub" + ? "ecommerce" + : rawView === "terms" || rawView === "agreement" || rawView === "user-agreement" + ? "userAgreement" + : rawView === "privacy" || rawView === "privacy-policy" + ? "privacyPolicy" : rawView === "community-review" ? "communityReview" : rawView === "community-case-add" ? "communityCaseAdd" : rawView; - return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "home"; + return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found"; } function readViewFromHash(): WebViewKey { @@ -146,7 +155,8 @@ function isWorkspaceView(view: WebViewKey): boolean { view !== "ecommerceHub" && view !== "ecommerce" && view !== "scriptTokens" && - view !== "login" + view !== "login" && + view !== "not-found" ); } @@ -318,6 +328,11 @@ function App() { } }, []); // eslint-disable-line react-hooks/exhaustive-deps + // ── Recover background tasks on app start ────────── + useEffect(() => { + recoverAndResumeTasks(); + }, []); + const navItems = useMemo( () => [ { key: "home", label: "首页", hint: "项目入口", icon: }, @@ -835,6 +850,10 @@ function App() { setSession(nextSession); await hydrateAccountData(nextSession); + if (nextSession.user.email && !nextSession.user.emailVerified) { + toast.info("邮箱尚未验证,部分功能可能受限,请在登录页通过邮箱验证码完成验证"); + } + const action = pendingAction; closeLoginPrompt(); if (action) { @@ -1109,8 +1128,6 @@ function App() { onSelectView={handleSetView} /> ); - case "settings": - return ; case "imageWorkbench": return ( ; case "providerHealth": return ; + case "userAgreement": + return ; + case "privacyPolicy": + return ; case "communityReview": return ( ); case "home": - default: return ( handleSetView("workbench")} @@ -1190,6 +1210,9 @@ function App() { onOpenImageTool={handleOpenImageWorkbenchTool} /> ); + case "not-found": + default: + return handleSetView("home")} />; } })(); diff --git a/src/api/adVideoPlanClient.ts b/src/api/adVideoPlanClient.ts index 2bae189..f767d95 100644 --- a/src/api/adVideoPlanClient.ts +++ b/src/api/adVideoPlanClient.ts @@ -1,8 +1,7 @@ import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; -const TEXT_MODEL = "qwen-max"; -const VISION_MODEL = "qwen3.7-plus"; -const VISION_FALLBACK_MODEL = "qwen-vl-plus"; +const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"]; +const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"]; export interface AdVideoUserConfig { platform: string; @@ -110,27 +109,41 @@ interface ChatMessage { const MAX_RETRIES = 3; const RETRY_BASE_MS = 2000; -const CHAT_TIMEOUT_MS = 120_000; // 2 minutes per AI call +const CHAT_TIMEOUT_MS = 180_000; // 3 minutes per AI call (server times out at 120s + network slack) +// 5xx, 429, network failures, timeouts, and AbortError-from-timeout are all retryable function isTransientError(err: unknown): boolean { if (!(err instanceof Error)) return false; const msg = err.message.toLowerCase(); - return /\b429\b/.test(msg) || msg.includes("signal timed out") || msg.includes("aborted") || msg.includes("timeout"); + if (/\b(429|500|502|503|504|520|521|522|524)\b/.test(msg)) return true; + if (msg.includes("signal timed out") || msg.includes("timeout")) return true; + if (msg.includes("failed to fetch") || msg.includes("networkerror") || msg.includes("network error")) return true; + if (msg.includes("ai 调用失败") || msg.includes("图片理解调用失败")) return true; // generic upstream failures + return false; } async function retryOnTransient(fn: () => Promise, signal?: AbortSignal): Promise { + let lastErr: unknown; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { return await fn(); } catch (err) { + lastErr = err; if (signal?.aborted) throw err; + // External AbortError caused by our timeoutSignal — retryable + if (err instanceof Error && err.name === "AbortError" && !signal?.aborted) { + if (attempt === MAX_RETRIES) throw err; + const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000; + await new Promise((r) => setTimeout(r, delay)); + continue; + } if (attempt === MAX_RETRIES) throw err; if (!isTransientError(err)) throw err; const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000; await new Promise((r) => setTimeout(r, delay)); } } - throw new Error("unreachable"); + throw lastErr instanceof Error ? lastErr : new Error("AI 调用失败:已重试多次"); } async function chat( @@ -138,33 +151,45 @@ async function chat( userContent: string, options?: { model?: string; signal?: AbortSignal }, ): Promise { - return retryOnTransient(async () => { - const messages: ChatMessage[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: userContent }, - ]; - const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); - const combinedSignal = options?.signal - ? AbortSignal.any([options.signal, timeoutSignal]) - : timeoutSignal; - const res = await fetch(buildApiUrl("ai/chat"), { - method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify({ - model: options?.model ?? TEXT_MODEL, - messages, - stream: false, - temperature: 0.4, - }), - signal: combinedSignal, - }); - if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`); - const payload = await res.json(); - const content: string = - payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; - if (!content) throw new Error("模型未返回有效内容"); - return content; - }, options?.signal); + const candidateModels = options?.model ? [options.model] : TEXT_MODELS; + let lastError: Error | null = null; + + for (const model of candidateModels) { + try { + return await retryOnTransient(async () => { + const messages: ChatMessage[] = [ + { role: "system", content: systemPrompt }, + { role: "user", content: userContent }, + ]; + const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); + const combinedSignal = options?.signal + ? AbortSignal.any([options.signal, timeoutSignal]) + : timeoutSignal; + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ model, messages, stream: false, temperature: 0.4 }), + signal: combinedSignal, + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`); + } + const payload = await res.json(); + const content: string = + payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + if (!content) throw new Error("模型未返回有效内容"); + return content; + }, options?.signal); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (options?.signal?.aborted) throw lastError; + // If user pinned a specific model, don't fall back to others + if (options?.model) throw lastError; + // Try next model in fallback chain + } + } + throw lastError ?? new Error("所有候选模型均不可用"); } async function visionChat( @@ -182,7 +207,8 @@ async function visionChat( { role: "user", content }, ]; - for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) { + let lastError: Error | null = null; + for (const model of VISION_MODELS) { const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) @@ -197,8 +223,8 @@ async function visionChat( }); if (!res.ok) { const errBody = await res.text().catch(() => ""); - if (model === VISION_MODEL && errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK"); - throw new Error(`图片理解调用失败 (${res.status})`); + if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK"); + throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`); } const payload = await res.json(); const result: string = @@ -208,12 +234,16 @@ async function visionChat( }, signal); return out; } catch (err) { - if (err instanceof Error && err.message === "IMAGE_FORMAT_FALLBACK") continue; - if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue; - throw err; + lastError = err instanceof Error ? err : new Error(String(err)); + if (signal?.aborted) throw lastError; + // Continue trying next vision model on transient failures, image format errors, or upstream errors + if (lastError.message === "IMAGE_FORMAT_FALLBACK") continue; + if (lastError.message.includes("图片理解调用失败")) continue; + if (isTransientError(lastError)) continue; + throw lastError; } } - throw new Error("图片理解调用失败,所有模型均不可用"); + throw lastError ?? new Error("图片理解调用失败,所有模型均不可用"); } const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`; diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index 1335847..4bd4d31 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -63,6 +63,17 @@ export interface VideoGenInput { style?: "speech" | "sing" | "performance" | string; } +export interface VideoEditInput { + projectId?: string; + conversationId?: number; + videoUrl: string; + referenceUrls: string[]; + prompt?: string; + model?: string; + ratio?: string; + resolution?: string; +} + export interface VideoSuperResolveInput { projectId?: string; conversationId?: number; @@ -290,6 +301,18 @@ export const aiGenerationClient = { return readJsonResponse<{ taskId: string }>(res, "Subtitle removal response failed"); }, + async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> { + const res = await fetch(buildApiUrl("ai/video/edit"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ ...input, model: input.model || "happyhorse-1.0-video-edit" }), + }); + if (!res.ok) { + await throwResponseError(res, "Video edit request failed"); + } + return readJsonResponse<{ taskId: string }>(res, "Video edit response failed"); + }, + async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> { const res = await fetch(buildApiUrl("ai/image/super-resolve"), { method: "POST", diff --git a/src/api/keyServerClient.ts b/src/api/keyServerClient.ts index 16b02e0..1acf077 100644 --- a/src/api/keyServerClient.ts +++ b/src/api/keyServerClient.ts @@ -30,9 +30,26 @@ interface EmailAuthInput { email: string; password: string; username?: string; + code?: string; betaCode?: string; } +interface EmailCodeInput { + email: string; + code: string; + purpose?: "register" | "login"; +} + +interface ForgotPasswordInput { + email: string; +} + +interface ResetPasswordInput { + email: string; + code: string; + newPassword: string; +} + interface PhoneAuthInput { phone: string; code: string; @@ -52,6 +69,19 @@ interface DeleteProjectOptions { cleanupUserData?: boolean; } +export interface RechargeOrderInput { + planId: string; + paymentMethod: "wechat" | "alipay" | "bank"; +} + +export interface RechargeOrderResult { + orderId: string; + status: string; + payUrl?: string | null; + qrCodeUrl?: string | null; + message?: string | null; +} + export interface WechatLoginTicket { configured: boolean; url?: string; @@ -624,6 +654,21 @@ function normalizeEnterpriseUsageSummary(payload: unknown): WebEnterpriseUsageSu }; } +function normalizeRechargeOrder(payload: unknown): RechargeOrderResult { + const raw = unwrapApiPayload(payload); + if (!isRecord(raw)) { + return { orderId: `local-${Date.now()}`, status: "pending", message: "订单已提交,请联系客服确认到账。" }; + } + + return { + orderId: toStringValue(raw.orderId ?? raw.order_id ?? raw.id, `local-${Date.now()}`), + status: toStringValue(raw.status, "pending"), + payUrl: toNullableString(raw.payUrl ?? raw.pay_url ?? raw.checkoutUrl ?? raw.checkout_url), + qrCodeUrl: toNullableString(raw.qrCodeUrl ?? raw.qr_code_url ?? raw.qrcodeUrl), + message: toNullableString(raw.message ?? raw.notice), + }; +} + function buildProjectUpsertPayload(workflow: WebCanvasWorkflow, session: WebUserSession): Record { const userId = String(session.user.id).replace(/[^a-zA-Z0-9_-]/g, ""); const projectId = workflow.id.trim(); @@ -714,6 +759,7 @@ export const keyServerClient = { email: input.email.trim(), username: input.username?.trim() || undefined, password: input.password, + code: input.code?.trim() || undefined, betaCode: input.betaCode?.trim() || undefined, }, }), @@ -731,6 +777,30 @@ export const keyServerClient = { body: { phone: phone.trim(), purpose, betaCode: betaCode?.trim() || undefined }, }); }, + async sendEmailCode(email: string, purpose: "login" | "register" | "reset", betaCode?: string): Promise<{ cooldownSeconds?: number; ttlSeconds?: number; devCode?: string }> { + return request<{ cooldownSeconds?: number; ttlSeconds?: number; devCode?: string }>("/auth/email/send-code", { + method: "POST", + body: { email: email.trim(), purpose, betaCode: betaCode?.trim() || undefined }, + }); + }, + async verifyEmail(input: EmailCodeInput): Promise<{ success: boolean }> { + return request<{ success: boolean }>("/auth/email/verify", { + method: "POST", + body: { email: input.email.trim(), code: input.code.trim(), purpose: input.purpose || "register" }, + }); + }, + async forgotPassword(input: ForgotPasswordInput): Promise<{ success: boolean; message?: string }> { + return request<{ success: boolean; message?: string }>("/auth/forgot-password", { + method: "POST", + body: { email: input.email.trim() }, + }); + }, + async resetPassword(input: ResetPasswordInput): Promise<{ success: boolean; message?: string }> { + return request<{ success: boolean; message?: string }>("/auth/reset-password", { + method: "POST", + body: { email: input.email.trim(), code: input.code.trim(), newPassword: input.newPassword }, + }); + }, async loginPhone(input: PhoneAuthInput): Promise { const session = normalizeLoginResult( await request("/auth/login-phone", { @@ -855,13 +925,23 @@ export const keyServerClient = { return normalizeProjectContent(response, projectId); }, async getUsageSummary(): Promise { - return normalizeUsageSummary(await request("/user/usage/summary")); + const stored = readStoredSession(); + return normalizeUsageSummary(await request("/user/usage/summary", { token: stored?.token })); }, async getEnterpriseUsageSummary(): Promise { - return normalizeEnterpriseUsageSummary(await request("/enterprise/usage/summary")); + const stored = readStoredSession(); + return normalizeEnterpriseUsageSummary(await request("/enterprise/usage/summary", { token: stored?.token })); }, async getPersonalUsageSummary(): Promise { - return normalizeEnterpriseUsageSummary(await request("/user/usage/credits")); + const stored = readStoredSession(); + return normalizeEnterpriseUsageSummary(await request("/user/usage/credits", { token: stored?.token })); + }, + async createRechargeOrder(input: RechargeOrderInput): Promise { + const response = await request("/payments/recharge-orders", { + method: "POST", + body: input, + }); + return normalizeRechargeOrder(response); }, async createProjectSpace(workflow: WebCanvasWorkflow): Promise { const stored = readStoredSession(); @@ -929,8 +1009,8 @@ export const keyServerClient = { }); }, - async getClientErrors(page = 1): Promise<{ items: unknown[]; total: number }> { - const data = await request<{ items: unknown[]; total: number }>(`/client-errors?page=${page}`); + async getClientErrors(page = 1): Promise<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }> { + const data = await request<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }>(`/client-errors?page=${page}`); return data; }, }; diff --git a/src/api/scriptEvalClient.ts b/src/api/scriptEvalClient.ts index c4f1dc5..9a8f836 100644 --- a/src/api/scriptEvalClient.ts +++ b/src/api/scriptEvalClient.ts @@ -1,3 +1,5 @@ +import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; + export interface ScriptEvalResult { totalScore: number; grade: string; @@ -8,8 +10,6 @@ export interface ScriptEvalResult { suggestions: string[]; } -const DASHSCOPE_API_KEY = import.meta.env.VITE_DASHSCOPE_API_KEY || ""; -const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions"; const MODEL = "qwen3.7-max"; const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。 @@ -69,16 +69,9 @@ function extractJson(text: string): unknown { } export async function evaluateScript(script: string, signal?: AbortSignal): Promise { - if (!DASHSCOPE_API_KEY) { - throw new Error("DashScope API key 未配置,请在 .env.local 中设置 VITE_DASHSCOPE_API_KEY"); - } - - const res = await fetch(DASHSCOPE_ENDPOINT, { + const res = await fetch(buildApiUrl("ai/chat"), { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${DASHSCOPE_API_KEY}`, - }, + headers: buildAuthHeaders(), body: JSON.stringify({ model: MODEL, messages: [ @@ -98,11 +91,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom } const payload = await res.json(); - const content: string = payload?.choices?.[0]?.message?.content - ?? payload?.result?.content - ?? payload?.content - ?? payload?.text - ?? (typeof payload === "string" ? payload : ""); + const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; if (!content) throw new Error("模型未返回有效内容"); diff --git a/src/components/AdminMonitor.tsx b/src/components/AdminMonitor.tsx index 4fc41f1..d5a7bbc 100644 --- a/src/components/AdminMonitor.tsx +++ b/src/components/AdminMonitor.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { keyServerClient } from "../api/keyServerClient"; -interface ClientErrorItem { +export interface ClientErrorItem { id: number; message: string; stack?: string; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 9c344fd..b59f897 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -22,6 +22,7 @@ import NotificationCenter from "./NotificationCenter"; import { RechargeModal } from "./RechargeModal/RechargeModal"; import { AnimatedPanel } from "./AnimatedPanel"; import AdminMonitor from "./AdminMonitor"; +import CookieConsentBanner from "./CookieConsentBanner"; interface AppShellProps { activeView: WebViewKey; @@ -40,6 +41,7 @@ interface AppShellProps { } const BRAND_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png"; +const CLIENT_ERROR_MONITOR_ENABLED = import.meta.env.VITE_ENABLE_CLIENT_ERROR_MONITOR === "1"; function formatBalance(cents: number): string { const value = Math.max(0, cents) / 100; @@ -73,7 +75,7 @@ function AppShell({ const [navJustActivated, setNavJustActivated] = useState(null); const isAuthView = activeView === "login"; const isImmersiveView = activeView === "agent" || activeView === "avatarConsole"; - const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home"; + const showFloatingNav = (!isAuthView || !!session) && !isImmersiveView && activeView !== "home"; const toolSurfaceViews = [ "workbench", "canvas", @@ -344,8 +346,8 @@ function AppShell({
15155073618
@@ -356,7 +358,7 @@ function AppShell({ onClick={() => setRechargeOpen(true)} > - {displayedBalanceLabel} + {displayedBalanceLabel}
- {session?.user.role === "admin" ? : null} + {CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? : null} setRechargeOpen(false)} currentBalance={displayedBalanceCents} /> + ); } diff --git a/src/components/NotFoundPage.tsx b/src/components/NotFoundPage.tsx new file mode 100644 index 0000000..2a09a9c --- /dev/null +++ b/src/components/NotFoundPage.tsx @@ -0,0 +1,24 @@ +import { HomeOutlined } from "@ant-design/icons"; +import { useCallback } from "react"; + +interface NotFoundPageProps { + onGoHome: () => void; +} + +function NotFoundPage({ onGoHome }: NotFoundPageProps) { + return ( +
+
+
404
+

页面未找到

+

您访问的页面不存在或已被移除。

+ +
+
+ ); +} + +export default NotFoundPage; diff --git a/src/components/PageTransition.tsx b/src/components/PageTransition.tsx index 3f6a491..6f66283 100644 --- a/src/components/PageTransition.tsx +++ b/src/components/PageTransition.tsx @@ -27,7 +27,6 @@ const NAV_ORDER: string[] = [ "avatarConsole", "characterMix", "agent", - "settings", "login", "profile", "report", diff --git a/src/components/RechargeModal/RechargeModal.tsx b/src/components/RechargeModal/RechargeModal.tsx index 2254028..95be2eb 100644 --- a/src/components/RechargeModal/RechargeModal.tsx +++ b/src/components/RechargeModal/RechargeModal.tsx @@ -1,7 +1,10 @@ import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons"; import { useMemo, useState, type ReactNode } from "react"; +import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient"; +import { toast } from "../toast/toastStore"; type RechargeAudience = "personal" | "enterprise"; +type PaymentMethod = "wechat" | "alipay" | "bank"; interface MembershipPlan { id: string; @@ -107,6 +110,12 @@ const rechargeRules = [ "退费规则:充值积分到账后不支持退换、折现,仅限平台内消费", ]; +const paymentMethods: Array<{ id: PaymentMethod; label: string; hint: string }> = [ + { id: "wechat", label: "微信支付", hint: "生成支付链接或二维码" }, + { id: "alipay", label: "支付宝", hint: "生成支付链接或二维码" }, + { id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" }, +]; + interface RechargeModalProps { open: boolean; onClose: () => void; @@ -116,14 +125,43 @@ interface RechargeModalProps { export function RechargeModal({ open, onClose, currentBalance }: RechargeModalProps) { const [activeAudience, setActiveAudience] = useState("personal"); const [selectedPlanIds, setSelectedPlanIds] = useState>(defaultSelectedPlanIds); + const [paymentMethod, setPaymentMethod] = useState("wechat"); + const [submitting, setSubmitting] = useState(false); + const [order, setOrder] = useState(null); const visiblePlans = useMemo(() => membershipPlans.filter((plan) => plan.audience === activeAudience), [activeAudience]); const selectedPlanId = selectedPlanIds[activeAudience]; + const selectedPlan = membershipPlans.find((plan) => plan.id === selectedPlanId) ?? visiblePlans[0]; const handlePlanSelect = (plan: MembershipPlan) => { setSelectedPlanIds((current) => ({ ...current, [plan.audience]: plan.id, })); + setOrder(null); + }; + + const handleCreateOrder = async () => { + if (!selectedPlan || submitting) return; + + setSubmitting(true); + try { + const nextOrder = await keyServerClient.createRechargeOrder({ planId: selectedPlan.id, paymentMethod }); + setOrder(nextOrder); + if (nextOrder.payUrl) { + window.open(nextOrder.payUrl, "_blank", "noopener,noreferrer"); + } + toast.success("充值订单已创建"); + } catch (error) { + const message = error instanceof Error ? error.message : "订单创建失败,请联系客服处理。"; + toast.error(message); + setOrder({ + orderId: `support-${Date.now()}`, + status: "manual-review", + message: "支付接口暂不可用,请通过页面联系方式联系客服完成充值。", + }); + } finally { + setSubmitting(false); + } }; if (!open) return null; @@ -224,6 +262,44 @@ export function RechargeModal({ open, onClose, currentBalance }: RechargeModalPr ))} + +
+
+ 支付确认 +

{selectedPlan.name} · {selectedPlan.period}

+

{selectedPlan.price},{selectedPlan.grant}

+
+
+ {paymentMethods.map((method) => ( + + ))} +
+ + {order ? ( +
+ 订单号:{order.orderId} + 状态:{order.status} + {order.qrCodeUrl ? 支付二维码 : null} + {order.payUrl ? 打开支付链接 : null} +

{order.message || "支付完成后积分将自动入账,如长时间未到账请联系客服。"}

+
+ ) : null} +
); diff --git a/src/features/assets/AssetsPage.tsx b/src/features/assets/AssetsPage.tsx index 5c94c07..e3cf85c 100644 --- a/src/features/assets/AssetsPage.tsx +++ b/src/features/assets/AssetsPage.tsx @@ -100,14 +100,14 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) { setContextMenu({ x: e.clientX, y: e.clientY, asset }); }, []); - const handleDeleteAsset = useCallback(async () => { - if (!contextMenu) return; - const { asset } = contextMenu; + const handleDeleteAsset = useCallback(async (asset?: LibraryAssetItem) => { + const target = asset || contextMenu?.asset; + if (!target) return; setContextMenu(null); try { - await assetClient.delete(asset.id); - setServerAssets((prev) => prev.filter((a) => a.id !== asset.id)); - setServerNotice(`已删除 ${asset.name}`); + await assetClient.delete(target.id); + setServerAssets((prev) => prev.filter((a) => a.id !== target.id)); + setServerNotice(`已删除 ${target.name}`); } catch (err) { setServerNotice(err instanceof Error ? err.message : "删除失败"); } @@ -287,32 +287,42 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) { {visibleAssets.length ? (
{visibleAssets.map((asset) => ( - + + +
))} ) : isLoading ? ( diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index 64c8510..5510f2a 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -3717,6 +3717,9 @@ function CanvasPage({ event.stopPropagation()} onContextMenu={(event) => event.preventDefault()} + onMouseMove={(event) => { + if (pendingLinkPort) { + setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY)); + } + }} >
新建节点并连接
)} {resultVideoUrl && ( -
+ + )} + {resultVideoUrl && ( +
-
)}
diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 6f9a3ab..c3b6416 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -3,10 +3,12 @@ import { CloudUploadOutlined, CloseOutlined, FileImageOutlined, + FrownOutlined, LoadingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, QuestionCircleOutlined, + ReloadOutlined, SettingOutlined, SkinOutlined, } from "@ant-design/icons"; @@ -19,46 +21,42 @@ const ecommerceSlide4 = `${OSS_MUBAN}/slide-4.png`; const ecommerceSlide5 = `${OSS_MUBAN}/slide-5.png`; import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu"; import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace"; +import EcommerceDetailPanel from "./panels/EcommerceDetailPanel"; +import EcommerceSetPanel from "./panels/EcommerceSetPanel"; +import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel"; +import EcommerceClonePanel from "./panels/EcommerceClonePanel"; import { aiGenerationClient } from "../../api/aiGenerationClient"; import { ServerRequestError } from "../../api/serverConnection"; import { waitForTask } from "../../api/taskSubscription"; +import { toast } from "../../components/toast/toastStore"; +import { useGenerationTasks } from "../../hooks/useGenerationTasks"; +import { useAppStore } from "../../stores"; import { - analyzeProductImages, - buildProductSummary, - extractSellingPoints, - generateCreativeOptions, - generateStoryboard, - generateVideoPrompts, - checkCompliance, - type AdVideoUserConfig, - type ProductSummary, - type SellingPointResult, - type CreativeOption, - type Storyboard, - type VideoPrompt, - type ComplianceCheck, -} from "../../api/adVideoPlanClient"; + normalizeEcommerceImageMime, + summarizeRejectedImages, + validateEcommerceImageFiles, +} from "./ecommerceImageValidation"; interface ProductClonePageProps { [key: string]: unknown; } -type ProductCloneStatus = "idle" | "ready" | "generating" | "done"; +type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed"; type ProductSetOutputKey = "set" | "detail" | "model" | "video"; -type CloneOutputKey = ProductSetOutputKey | "hot"; +type CloneOutputKey = ProductSetOutputKey | "hot" | "video-outfit"; type CloneSetCountKey = "selling" | "white" | "scene"; type CloneModelPanelTab = "scene" | "model"; type CloneVideoQualityKey = "standard" | "high" | "ultra"; -type ProductSetStatus = "idle" | "ready" | "generating" | "done"; +type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed"; type ProductKitToolKey = "set" | "detail" | "wear" | "clone"; type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio"; type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body"; type CloneReferenceMode = "upload" | "link"; type CloneReplicateLevelKey = "style" | "high"; type TryOnModelSource = "ai" | "library"; -type TryOnStatus = "idle" | "modeling" | "ready" | "generating" | "done"; -type DetailStatus = "idle" | "ready" | "generating" | "done"; +type TryOnStatus = "idle" | "modeling" | "ready" | "generating" | "done" | "failed"; +type DetailStatus = "idle" | "ready" | "generating" | "done" | "failed"; interface CloneImageItem { id: string; @@ -102,7 +100,7 @@ interface CloneSavedSetting { requirement: string; } -type PlatformRatioModeKey = ProductSetOutputKey | "hot"; +type PlatformRatioModeKey = ProductSetOutputKey | "hot" | "video-outfit"; interface PlatformRatioGroup { ratios: string[]; @@ -553,6 +551,7 @@ const productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string } const cloneOutputOptions: Array<{ key: CloneOutputKey; label: string }> = [ ...productSetOutputOptions, { key: "hot", label: "爆款图复刻" }, + { key: "video-outfit", label: "视频换装" }, ]; const cloneSetCountOptions: Array<{ key: CloneSetCountKey; @@ -704,7 +703,6 @@ function readImageDimensions(src: string): Promise<{ width: number; height: numb function createObjectImageItems(files: File[], limit: number, prefix: string) { return Array.from(files) - .filter((file) => file.type.startsWith("image/")) .slice(0, limit) .map((file, index) => ({ id: `${prefix}-${Date.now()}-${index}`, @@ -714,6 +712,13 @@ function createObjectImageItems(files: File[], limit: number, prefix: string) { })); } +function notifyRejectedImages(files: File[]): File[] { + const { accepted, rejected } = validateEcommerceImageFiles(files); + const message = summarizeRejectedImages(rejected); + if (message) toast.error(message); + return accepted; +} + function isCloneSavedSetting(item: unknown): item is CloneSavedSetting { const candidate = item as Partial; return ( @@ -761,6 +766,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const detailInputRef = useRef(null); const countHoldTimeoutRef = useRef(null); const countHoldIntervalRef = useRef(null); + const imageGen = useGenerationTasks({ sourceView: "ecommerce" }); + const appUsage = useAppStore((s) => s.usage); const latestCloneSettingRef = useRef(null); const skipInitialCloneAutoSaveRef = useRef(true); const skipNextCloneAutoSaveRef = useRef(false); @@ -799,19 +806,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [cloneVideoQuality, setCloneVideoQuality] = useState("high"); const [cloneVideoDuration, setCloneVideoDuration] = useState(10); const [cloneVideoSmart, setCloneVideoSmart] = useState(true); - const [adVideoStep, setAdVideoStep] = useState<"idle" | "planning" | "planned" | "rendering">("idle"); - const [adVideoBusy, setAdVideoBusy] = useState(false); - const [adVideoError, setAdVideoError] = useState(null); - const [adVideoProgress, setAdVideoProgress] = useState(""); - const [adVideoSummary, setAdVideoSummary] = useState(null); - const [adVideoSelling, setAdVideoSelling] = useState(null); - const [adVideoCreatives, setAdVideoCreatives] = useState([]); - const [adVideoStoryboard, setAdVideoStoryboard] = useState(null); - const [adVideoPrompts, setAdVideoPrompts] = useState([]); - const [adVideoCompliance, setAdVideoCompliance] = useState(null); - const [adVideoScenes, setAdVideoScenes] = useState< - Array<{ sceneId: number; taskId?: string; status: string; progress: number; resultUrl?: string | null; error?: string }> - >([]); + const [videoOutfitVideoFile, setVideoOutfitVideoFile] = useState(null); + const [videoOutfitRefFile, setVideoOutfitRefFile] = useState(null); const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false); const [requirement, setRequirement] = useState(""); const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState(null); @@ -823,6 +819,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [status, setStatus] = useState("idle"); const [results, setResults] = useState([]); const imageAbortRef = useRef({ current: false }); + const lastFailedActionRef = useRef<(() => void) | null>(null); const [garmentImages, setGarmentImages] = useState([]); const [modelSource, setModelSource] = useState("ai"); const [modelGender, setModelGender] = useState(tryOnModelOptions.gender[0]); @@ -865,7 +862,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const productSetPreviewReady = productSetStatus === "done"; const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0); const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating"; - const canGenerate = productImages.length > 0 && status !== "generating"; + const canGenerate = (cloneOutput === "video-outfit" + ? videoOutfitVideoFile && videoOutfitRefFile + : productImages.length > 0) && status !== "generating"; const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling"; const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating"; const cloneVideoDurationProgress = @@ -892,7 +891,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const addSetImages = (files: File[]) => { if (setImages.length >= 3) return; - const imageFiles = files.filter((file) => file.type.startsWith("image/")); + const imageFiles = notifyRejectedImages(files); if (!imageFiles.length) return; setSetImages((current) => { const nextImages = createObjectImageItems(imageFiles, 3 - current.length, "set"); @@ -924,7 +923,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }; const addProductImages = (files: File[]) => { - const imageFiles = files.filter((file) => file.type.startsWith("image/")); + const imageFiles = notifyRejectedImages(files); if (!imageFiles.length) return; setProductImages((current) => { if (current.length >= maxCloneProductImages) return current; @@ -973,7 +972,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }; const addCloneReferenceImages = (files: File[]) => { - const imageFiles = files.filter((file) => file.type.startsWith("image/")); + const imageFiles = notifyRejectedImages(files); if (!imageFiles.length) return; const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length; if (remainingSlots <= 0) return; @@ -1299,7 +1298,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const handleGarmentUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - const uploadedFiles = Array.from(files); + const uploadedFiles = notifyRejectedImages(Array.from(files)); + if (!uploadedFiles.length) { + event.target.value = ""; + return; + } setGarmentImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 5 - current.length, "garment")].slice(0, 5)); setTryOnStatus("ready"); event.target.value = ""; @@ -1308,52 +1311,34 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const handleDetailUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - const uploadedFiles = Array.from(files); + const uploadedFiles = notifyRejectedImages(Array.from(files)); + if (!uploadedFiles.length) { + event.target.value = ""; + return; + } setDetailProductImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 3 - current.length, "detail")].slice(0, 3)); setDetailStatus("ready"); event.target.value = ""; }; - const buildAdVideoConfig = (): AdVideoUserConfig => ({ - platform, - aspectRatio: ratio.includes("9:16") || ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") || ratio.includes("16:9") ? "16:9" : "1:1", - durationSeconds: cloneVideoDuration, - style: "痛点解决", - language, - market, - needVoiceover: true, - needSubtitle: true, - conversionFocus: "conversion", - }); - - const uploadProductImages = async (): Promise => { - const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); - const urls: string[] = []; - for (const item of productImages) { - try { - const resp = await fetch(item.src); - const rawBlob = await resp.blob(); - const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png"; - const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType }); - const { url } = await aiGenerationClient.uploadAssetBinary(blob, { name: item.name, mimeType, scope: "ecommerce-product" }); - urls.push(url); - } catch { - // skip images that fail to upload - } - } - return urls; - }; + const blobToDataUrl = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("文件读取失败")); + reader.readAsDataURL(blob); + }); const uploadCloneImages = async (images: CloneImageItem[]): Promise => { - const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); const urls: string[] = []; for (const item of images) { try { const resp = await fetch(item.src); const rawBlob = await resp.blob(); - const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png"; + const mimeType = normalizeEcommerceImageMime(rawBlob.type); const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType }); - const { url } = await aiGenerationClient.uploadAssetBinary(blob, { name: item.name, mimeType, scope: "ecommerce-product" }); + const dataUrl = await blobToDataUrl(blob); + const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType, scope: "ecommerce-product" }); urls.push(url); } catch { // skip images that fail to upload @@ -1383,7 +1368,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return parts.join(" "); }; - const buildEcommerceImagePrompt = (outputKey: CloneOutputKey, userText: string, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => { + const buildEcommerceImagePrompt = ( + outputKey: CloneOutputKey, userText: string, + pPlatform: string, pRatio: string, pLanguage: string, pMarket: string, + tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean }, + ): string => { const parts: string[] = []; if (outputKey === "detail") { parts.push("Generate a professional A+ detail page hero image for an e-commerce product listing."); @@ -1394,6 +1383,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { 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: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); + if (tryOnOptions) { + if (tryOnOptions.gender) parts.push(`Model gender: ${tryOnOptions.gender}.`); + if (tryOnOptions.age) parts.push(`Model age: ${tryOnOptions.age}.`); + if (tryOnOptions.ethnicity) parts.push(`Model ethnicity: ${tryOnOptions.ethnicity}.`); + if (tryOnOptions.body) parts.push(`Model body type: ${tryOnOptions.body}.`); + if (tryOnOptions.appearance) parts.push(`Model appearance details: ${tryOnOptions.appearance}.`); + if (tryOnOptions.scenes?.length) parts.push(`Background scenes: ${tryOnOptions.scenes.join(", ")}.`); + if (tryOnOptions.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."); @@ -1445,6 +1443,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { referenceUrls, }); + const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId }); + const resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current, onProgress: () => {}, @@ -1452,8 +1452,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { if (resultUrl) { generatedUrls.push(resultUrl); + imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl }); } else { generatedUrls.push(""); + imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); } } } @@ -1463,8 +1465,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { } catch (err) { if (err instanceof ServerRequestError && err.status === 402) { setResultFn([]); + toast.error("余额不足,请充值后继续"); + } else { + const msg = err instanceof Error ? err.message : "生成失败"; + toast.error(msg); } - setStatusFn("idle"); + setStatusFn("failed"); } }; @@ -1476,8 +1482,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { pRatio: string, pLanguage: string, pMarket: string, - setStatusFn: (status: "generating" | "done" | "idle") => void, - setResultFn: (results: CloneResult[]) => void, + tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean }, + statusFn?: (status: "generating" | "done" | "idle" | "failed") => void, + resultFn?: (results: CloneImageItem[]) => void, ): Promise => { setStatusFn("generating"); try { @@ -1487,7 +1494,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return; } - const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket); + const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions); const stamp = Date.now(); const { taskId } = await aiGenerationClient.createImageTask({ @@ -1499,6 +1506,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { referenceUrls, }); + const storeId = imageGen.submitTask({ title: `电商${outputKey}图`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId }); + const resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current, onProgress: () => {}, @@ -1507,220 +1516,82 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { if (resultUrl) { setResultFn([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]); setStatusFn("done"); + imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl }); } else { setStatusFn("idle"); + imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); } } catch (err) { if (err instanceof ServerRequestError && err.status === 402) { setResultFn([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]); + toast.error("余额不足,请充值后继续"); + } else { + const msg = err instanceof Error ? err.message : "生成失败"; + toast.error(msg); } - setStatusFn("idle"); + setStatusFn("failed"); } }; - const adVideoUploadedUrlsRef = useRef([]); - - const handleAdVideoPlan = async () => { - if (productImages.length === 0 && !requirement.trim()) { - setAdVideoError("请先上传产品图片或填写商品说明"); - return; - } - setAdVideoBusy(true); - setAdVideoError(null); - setAdVideoStep("planning"); + const handleVideoOutfitGenerate = async () => { + if (!videoOutfitVideoFile || !videoOutfitRefFile) return; + setStatus("generating"); try { - setAdVideoProgress("正在上传产品图片…"); - const imageUrls = await uploadProductImages(); - adVideoUploadedUrlsRef.current = imageUrls; + const readAsDataUrl = (file: File): Promise => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("文件读取失败")); + reader.readAsDataURL(file); + }); - setAdVideoProgress("正在分析产品图片…"); - const imageDesc = await analyzeProductImages(imageUrls); + const videoDataUrl = await readAsDataUrl(videoOutfitVideoFile); + const refDataUrl = await readAsDataUrl(videoOutfitRefFile); - setAdVideoProgress("正在生成商品理解…"); - const summary = await buildProductSummary(imageDesc, requirement); - setAdVideoSummary(summary); + const videoAsset = await aiGenerationClient.uploadAsset({ + dataUrl: videoDataUrl, name: videoOutfitVideoFile.name, + mimeType: videoOutfitVideoFile.type || "video/mp4", scope: "video-outfit", + }); + const refAsset = await aiGenerationClient.uploadAsset({ + dataUrl: refDataUrl, name: videoOutfitRefFile.name, + mimeType: videoOutfitRefFile.type || "image/png", scope: "video-outfit", + }); - setAdVideoProgress("正在提炼卖点…"); - const selling = await extractSellingPoints(summary); - setAdVideoSelling(selling); + const { taskId } = await aiGenerationClient.createVideoEditTask({ + videoUrl: videoAsset.url, + referenceUrls: [refAsset.url], + prompt: requirement || undefined, + }); - const config = buildAdVideoConfig(); - setAdVideoProgress("正在生成广告创意…"); - const creatives = await generateCreativeOptions(selling, config); - setAdVideoCreatives(creatives); - const chosen = creatives[0]; - if (!chosen) throw new Error("未能生成有效的广告创意"); - - setAdVideoProgress("正在生成视频分镜…"); - const storyboard = await generateStoryboard(chosen, summary, config); - setAdVideoStoryboard(storyboard); - - setAdVideoProgress("正在生成镜头提示词…"); - const prompts = await generateVideoPrompts(storyboard, summary); - setAdVideoPrompts(prompts); - - setAdVideoProgress("正在进行合规检查…"); - const compliance = await checkCompliance(summary, selling, storyboard); - setAdVideoCompliance(compliance); - - setAdVideoStep("planned"); + const { waitForTask } = await import("../../api/taskSubscription"); + abortRef.current = { current: false }; + const resultUrl = await waitForTask(taskId, { abortRef: abortRef.current }); + if (resultUrl) { + setResults([{ id: crypto.randomUUID(), name: "换装视频", src: resultUrl, type: "video", size: 0 }]); + } + setStatus("done"); } catch (err) { - setAdVideoError(err instanceof Error ? err.message : "广告策划生成失败"); - setAdVideoStep("idle"); - } finally { - setAdVideoBusy(false); - setAdVideoProgress(""); + setStatus("failed"); + toast.error(err instanceof Error ? err.message : "视频换装生成失败"); } }; - const pollAdVideoTask = async (sceneId: number, taskId: string) => { - for (let i = 0; i < 150; i++) { - await new Promise((r) => setTimeout(r, 4000)); - let st; - try { - st = await aiGenerationClient.getTaskStatus(taskId); - } catch { - continue; - } - setAdVideoScenes((prev) => - prev.map((s) => - s.sceneId === sceneId - ? { ...s, status: st.status === "cancelled" ? "failed" : st.status, progress: st.progress, resultUrl: st.resultUrl } - : s, - ), - ); - if (st.status === "completed" || st.status === "failed" || st.status === "cancelled") return; - } - }; - - const handleAdVideoRender = async () => { - if (!adVideoStoryboard) return; - setAdVideoStep("rendering"); - setAdVideoError(null); - const referenceUrl = adVideoUploadedUrlsRef.current[0]; - setAdVideoScenes( - adVideoStoryboard.scenes.map((s) => ({ sceneId: s.scene_id, status: "idle", progress: 0 })), - ); - for (const scene of adVideoStoryboard.scenes) { - const prompt = adVideoPrompts.find((p) => p.scene_id === scene.scene_id); - const positivePrompt = prompt?.positive_prompt || scene.visual_description; - const sceneDuration = Number.parseInt(scene.duration, 10) || 5; - try { - const { taskId } = await aiGenerationClient.createVideoTask({ - model: "happyhorse-1.0-i2v", - prompt: positivePrompt, - ratio: buildAdVideoConfig().aspectRatio, - duration: sceneDuration, - imageUrl: referenceUrl, - referenceUrls: referenceUrl ? [referenceUrl] : undefined, - }); - setAdVideoScenes((prev) => - prev.map((s) => (s.sceneId === scene.scene_id ? { ...s, taskId, status: "pending" } : s)), - ); - void pollAdVideoTask(scene.scene_id, taskId); - } catch (err) { - setAdVideoScenes((prev) => - prev.map((s) => - s.sceneId === scene.scene_id - ? { ...s, status: "failed", error: err instanceof Error ? err.message : "提交失败" } - : s, - ), - ); - } - } - }; - - const renderAdVideoCompliance = () => { - if (!adVideoCompliance) return null; - const level = adVideoCompliance.risk_level; - const canRender = adVideoCompliance.allow_video_generation; - const rendering = adVideoStep === "rendering"; - return ( -
- - 合规风险:{level === "low" ? "低" : level === "medium" ? "中" : "高"} - - {adVideoCompliance.issues.length > 0 ? ( -
    - {adVideoCompliance.issues.map((issue, i) => ( -
  • {issue.field}:{issue.problem} → {issue.suggestion}
  • - ))} -
- ) : null} - -
- ); - }; - - const renderAdVideoPlan = () => { - if (adVideoStep !== "planned" && adVideoStep !== "rendering") return null; - return ( -
- {adVideoSummary ? ( -
- {adVideoSummary.product_name} · {adVideoSummary.category} -

{adVideoSummary.appearance}

-
- {adVideoSummary.selling_points.map((sp, i) => ( - {sp} - ))} -
-
- ) : null} - {adVideoCreatives[0] ? ( -
- 广告创意:{adVideoCreatives[0].creative_type} -

{adVideoCreatives[0].hook}

-
- ) : null} - {adVideoStoryboard ? ( -
- 分镜:{adVideoStoryboard.video_title} -
- {adVideoStoryboard.scenes.map((scene) => { - const sceneVideo = adVideoScenes.find((s) => s.sceneId === scene.scene_id); - return ( -
-
- 镜头 {scene.scene_id} · {scene.duration} - {sceneVideo ? ( - - {sceneVideo.status === "completed" - ? "完成" - : sceneVideo.status === "failed" - ? "失败" - : sceneVideo.status === "idle" - ? "等待" - : `${sceneVideo.progress}%`} - - ) : null} -
-

{scene.visual_description}

- {sceneVideo?.resultUrl ? ( -
- ); - })} -
-
- ) : null} - {renderAdVideoCompliance()} -
- ); - }; - const handleGenerate = () => { if (!canGenerate) return; + + if ((appUsage?.balanceCents ?? 0) <= 0) { + toast.error("积分不足,请充值后继续"); + return; + } + + if (cloneOutput === "set" && cloneSetTotal > 5) { + if (!window.confirm(`将生成 ${cloneSetTotal} 张图片,可能消耗较多积分,是否继续?`)) return; + } + imageAbortRef.current = { current: false }; - if (cloneOutput === "set") { + lastFailedActionRef.current = null; + if (cloneOutput === "video-outfit") { + void handleVideoOutfitGenerate(); + } else if (cloneOutput === "set") { void generateSetImages( productImages, cloneSetCounts, requirement, platform, ratio, language, market, @@ -1733,32 +1604,39 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { platform, ratio, language, market, (s) => setStatus(s as ProductCloneStatus), setResults, ); + lastFailedActionRef.current = () => handleGenerate(); } }; const handleGenerateModel = () => { imageAbortRef.current = { current: false }; + lastFailedActionRef.current = null; setTryOnStatus("modeling"); void generateEcommerceImage( "model", garmentImages, requirement, platform, ratio, language, market, + { gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene }, (s) => { if (s === "done") setTryOnStatus("ready"); else setTryOnStatus(s as TryOnStatus); }, () => { setTryOnStatus("ready"); }, ); + lastFailedActionRef.current = () => handleGenerateModel(); }; const handleTryOnGenerate = () => { if (!canGenerateTryOn) return; imageAbortRef.current = { current: false }; + lastFailedActionRef.current = null; void generateEcommerceImage( "model", garmentImages, requirement, platform, ratio, language, market, + { gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene }, (s) => setTryOnStatus(s as TryOnStatus), (res) => setTryOnResultImages(res.map((r) => r.src).filter(Boolean)), ); + lastFailedActionRef.current = () => handleTryOnGenerate(); }; const toggleScene = (scene: string) => { @@ -1776,12 +1654,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const handleSetGenerate = () => { if (!canGenerateSet) return; imageAbortRef.current = { current: false }; + lastFailedActionRef.current = null; void generateSetImages( setImages, cloneSetCounts, productSetRequirement, productSetPlatform, productSetRatio, productSetLanguage, productSetMarket, (s) => setProductSetStatus(s as ProductSetStatus), (urls) => setProductSetResultImages(urls), ); + lastFailedActionRef.current = () => handleSetGenerate(); }; const openProductSetPreview = (card: { src: string; label: string }) => { @@ -1797,6 +1677,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const handleDetailGenerate = () => { if (!canGenerateDetail) return; imageAbortRef.current = { current: false }; + lastFailedActionRef.current = null; void generateEcommerceImage( "detail", detailProductImages, detailRequirement, detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket, @@ -1932,858 +1813,166 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ]; const setPanel = ( - <> -
-
-

- 上传商品原图 - -

- - - {setImages.length ? ( -
- {setImages.map((item) => ( -
- {item.name} - - -
- ))} -
- ) : null} -
- -
-

- 生成设置 - -

-
- 生成内容 -
- {productSetOutputOptions.map((option) => ( - - ))} -
-
-
- 基础设置 -
- - - - -
-
-
-
- + ); const clonePanel = ( - <> -
-
- AI - 电商生成 -
- -
-

- - 上传商品原图 -

-
productInputRef.current?.click()} - onKeyDown={(event) => { - if (event.target !== event.currentTarget) return; - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - productInputRef.current?.click(); - } - }} - onDragEnter={(event) => { - event.preventDefault(); - setIsProductUploadDragging(true); - }} - onDragOver={(event) => event.preventDefault()} - onDragLeave={() => setIsProductUploadDragging(false)} - onDrop={handleProductDrop} - > -
- - - - 拖拽或点击上传 - - - 上传图片 - - 同一产品,最多 7 张 -
- {productImages.length ? ( -
- {productImages.map((item) => ( -
- {item.name} - - -
- ))} -
- ) : null} -
- -
- -
-

- - 生成设置 -

-
- 生成内容 -
- {cloneOutputOptions.map((option) => ( - - ))} -
-
-
- 基础设置 -
- {cloneBasicSelects.map((item) => { - const hasMultipleOptions = item.options.length > 1; - const isOpen = hasMultipleOptions && openCloneBasicSelect === item.key; - return ( -
-