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 e9e0e97..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; @@ -88,7 +90,7 @@ function AppShell({ "avatarConsole", "characterMix", ] as WebViewKey[]; - const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView); + const showPageScrollActions = false; const visibleNavItems = useMemo( () => { @@ -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 eaa6543..68c758c 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)); + } + }} >
新建节点并连接
- - ); - }; - - 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 ( -
-