From f5a75074a4b6080e1fb39850ea33ef5e75c2b5bd Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Wed, 3 Jun 2026 20:19:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=82=AE=E7=AE=B1=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=20+=209=E9=A1=B9=E5=8A=9F=E8=83=BD=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=B8=8E=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【认证系统】 - 新增邮箱验证码注册/登录流程 (sendEmailCode / verifyEmail / forgotPassword / resetPassword) - register-email 现在需要验证码 - 服务端新增 email_verification_codes 表 + patch-email-verification.js - App.tsx 登录后 emailVerified 检查提醒 - keyServerClient token 显式传递修复 401 错误 【电商模块】 - 自动推进: 策划完成后自动生成分镜图/视频 - 模特图选项 (性别/年龄/种族/体型/场景) 注入 AI 提示词 - 任务持久化指纹修复 (图片数量替代 blob URL) - 新增「视频换装」入口 (happyhorse-1.0-video-edit) 【剧本评分】 - 新增 .docx/.doc Word 文档支持 (ZIP解压+XML提取) - 历史记录支持点击查看/恢复评测结果 【画布】 - ReactFlow 节点禁止内置拖拽避免冲突 - 连接线拖拽弹窗优化 (预览线不消失, 弹窗跟踪鼠标) 【页面修复】 - 首页轮播图改为 aspect-ratio: 16/9 解决尺寸问题 - 资产库新增悬停删除按钮 - scriptEvalClient 改用服务端 /api/ai/chat 端点 - TokenUsagePage 未登录跳过 API 调用 --- server-patches/patch-email-verification.js | 291 ++++ src/App.tsx | 32 +- src/api/aiGenerationClient.ts | 23 + src/api/keyServerClient.ts | 90 +- src/api/scriptEvalClient.ts | 15 +- src/components/AdminMonitor.tsx | 2 +- src/components/AppShell.tsx | 11 +- src/components/PageTransition.tsx | 1 - .../RechargeModal/RechargeModal.tsx | 76 + src/features/assets/AssetsPage.tsx | 70 +- src/features/canvas/CanvasPage.tsx | 14 +- src/features/ecommerce/EcommercePage.tsx | 1432 ++++------------- .../ecommerce/EcommerceVideoWorkspace.tsx | 121 +- .../ecommerce/ecommerceVideoKeepalive.ts | 5 +- .../ecommerce/ecommerceVideoService.ts | 22 +- src/features/ecommerce/ecommerceVideoTypes.ts | 1 + src/features/profile/ProfilePage.tsx | 122 +- .../script-tokens/ScriptTokensPage.tsx | 108 +- src/features/script-tokens/TokenUsagePage.tsx | 20 +- src/features/workbench/WorkbenchPage.tsx | 4 + src/stores/index.ts | 2 + src/styles/components/recharge-modal.css | 102 ++ src/styles/pages/assets.css | 34 + src/styles/pages/compliance.css | 108 +- src/styles/pages/ecommerce.css | 48 + src/styles/pages/home.css | 6 +- src/styles/shell/app-shell.css | 146 ++ src/styles/themes/dark-green.css | 34 +- src/types.ts | 3 +- src/utils/errorReporting.ts | 3 + 30 files changed, 1697 insertions(+), 1249 deletions(-) create mode 100644 server-patches/patch-email-verification.js 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 353238b..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,8 +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")); @@ -56,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 { @@ -103,7 +105,6 @@ const VIEW_KEYS = new Set([ "ecommerce", "scriptTokens", "tokenUsage", - "settings", "imageWorkbench", "resolutionUpscale", "watermarkRemoval", @@ -116,17 +117,23 @@ const VIEW_KEYS = new Set([ "communityCaseAdd", "report", "providerHealth", + "userAgreement", + "privacyPolicy", "not-found", ]); -const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "not-found"]); +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" @@ -321,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: }, @@ -838,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) { @@ -1112,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 ( (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 0516508..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,7 +10,6 @@ export interface ScriptEvalResult { suggestions: string[]; } -const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions"; const MODEL = "qwen3.7-max"; const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。 @@ -68,11 +69,9 @@ function extractJson(text: string): unknown { } export async function evaluateScript(script: string, signal?: AbortSignal): Promise { - const res = await fetch(DASHSCOPE_ENDPOINT, { + const res = await fetch(buildApiUrl("ai/chat"), { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: buildAuthHeaders(), body: JSON.stringify({ model: MODEL, messages: [ @@ -92,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 3457460..700938b 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; @@ -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/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 ( -
-