diff --git a/server-patches/patch-email-verification.js b/server-patches/patch-email-verification.js deleted file mode 100644 index bfc1967..0000000 --- a/server-patches/patch-email-verification.js +++ /dev/null @@ -1,291 +0,0 @@ -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 062e150..7a9dee5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,7 @@ import { ToolOutlined, WalletOutlined, } from "@ant-design/icons"; -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from "react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import ErrorBoundary from "./components/ErrorBoundary"; import { reportError } from "./utils/errorReporting"; import { initNotificationPermission } from "./utils/generationNotifier"; @@ -284,6 +284,12 @@ function App() { const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead); const clearAppState = useAppStore((s) => s.clearAppState); + const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false); + const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub"; + useEffect(() => { + if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true); + }, [isEcommerceActive]); // eslint-disable-line react-hooks/exhaustive-deps + // Dismiss boot splash after first render useEffect(() => { const splash = document.getElementById("app-boot-splash"); @@ -1075,20 +1081,7 @@ function App() { return ; case "ecommerce": case "ecommerceHub": - return ( - setPendingEcommerceTemplate(null)} - /> - ); + return null; case "digitalHuman": return ( {activePage} + + {/* KeepAlive: EcommercePage stays mounted once visited */} + {ecommerceEverMounted && ( +
+ setPendingEcommerceTemplate(null)} + /> +
+ )} diff --git a/src/components/CookieConsentBanner.tsx b/src/components/CookieConsentBanner.tsx new file mode 100644 index 0000000..ebdebb5 --- /dev/null +++ b/src/components/CookieConsentBanner.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; + +const COOKIE_CONSENT_KEY = "omniai:cookie-consent:v1"; + +export default function CookieConsentBanner() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + setVisible(localStorage.getItem(COOKIE_CONSENT_KEY) !== "accepted"); + }, []); + + const accept = () => { + localStorage.setItem(COOKIE_CONSENT_KEY, "accepted"); + setVisible(false); + }; + + if (!visible) return null; + + return ( +
+
+ Cookie 与本地存储提示 +

我们使用 Cookie 和本地存储保存登录状态、偏好设置、创作草稿和断点续传数据,用于保障服务正常运行。

+
+
+ 查看隐私政策 + +
+
+ ); +} diff --git a/src/components/PageTransition.tsx b/src/components/PageTransition.tsx index 6f66283..a020281 100644 --- a/src/components/PageTransition.tsx +++ b/src/components/PageTransition.tsx @@ -80,6 +80,8 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp const dirClass = exitDirection === "forward" ? " is-forward" : exitDirection === "backward" ? " is-backward" : ""; + if (!displayedChildren) return null; + return (
{displayedChildren} diff --git a/src/features/compliance/CompliancePage.tsx b/src/features/compliance/CompliancePage.tsx new file mode 100644 index 0000000..624754b --- /dev/null +++ b/src/features/compliance/CompliancePage.tsx @@ -0,0 +1,98 @@ +import { FileTextOutlined, SafetyOutlined } from "@ant-design/icons"; + +type ComplianceKind = "agreement" | "privacy"; + +interface CompliancePageProps { + kind: ComplianceKind; +} + +const companyName = "OmniAI"; +const contactPhone = "15155073618"; +const address = "江苏省南京市江北新区扬子江数字视听产业园9栋A楼501"; + +const agreementSections = [ + { + title: "服务范围", + body: "平台提供 AI 图片、视频、脚本、数字人及相关创作辅助服务。具体功能、模型能力、消耗规则以页面展示和平台公告为准。", + }, + { + title: "账号与使用", + body: "用户应保证注册信息真实有效,妥善保管账号与登录凭证,不得出租、转让账号或以自动化方式恶意占用平台资源。", + }, + { + title: "内容合规", + body: "用户不得上传、生成、发布违法违规、侵权、涉政敏感、暴恐、色情、赌博、诈骗或侵犯他人合法权益的内容。平台有权对违规内容采取删除、限制功能、封禁账号等措施。", + }, + { + title: "积分与付费", + body: "积分仅限平台内消费,不支持提现、转让或折现。充值、套餐、赠送积分的有效期、消耗顺序和退费规则以充值页面展示为准。", + }, + { + title: "责任限制", + body: "AI 生成结果可能存在偏差,用户应自行审核输出内容并承担使用后果。因不可抗力、第三方服务异常、网络故障造成的服务中断,平台将在合理范围内修复。", + }, +]; + +const privacySections = [ + { + title: "收集的信息", + body: "我们会收集账号信息、登录状态、联系方式、创作输入、生成结果、用量记录、设备与网络日志,用于提供服务、安全审计和问题排查。", + }, + { + title: "Cookie 与本地存储", + body: "我们使用 Cookie、localStorage 和 sessionStorage 保存登录状态、偏好设置、Cookie 同意状态、创作草稿和断点续传数据。", + }, + { + title: "信息使用", + body: "信息用于身份验证、生成任务处理、资产管理、积分计费、客服支持、风控合规、服务优化和法律法规要求的备案审计。", + }, + { + title: "第三方处理", + body: "为完成 AI 生成、对象存储、短信邮件、支付或错误监控,我们可能向必要的第三方服务提供最小范围数据,并要求其按约定保护数据安全。", + }, + { + title: "用户权利", + body: "你可以通过平台账号功能或联系方式申请访问、更正、删除个人信息,或撤回非必要授权。法律法规另有要求的记录可能需按规定保留。", + }, +]; + +export default function CompliancePage({ kind }: CompliancePageProps) { + const isPrivacy = kind === "privacy"; + const sections = isPrivacy ? privacySections : agreementSections; + const title = isPrivacy ? "隐私政策" : "用户协议"; + const Icon = isPrivacy ? SafetyOutlined : FileTextOutlined; + + return ( +
+
+
+ +
+ 合规文件 +

{title}

+

{companyName} 平台服务合规说明。更新日期:2026 年 6 月 3 日。

+
+
+ +
+ {sections.map((section, index) => ( +
+ {String(index + 1).padStart(2, "0")} +
+

{section.title}

+

{section.body}

+
+
+ ))} +
+ +
+ 联系我们 + 地址:{address} + 电话:{contactPhone} + 备案号:苏ICP备2026021747号-1 +
+
+
+ ); +} diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 9b574c3..f7963a5 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -2064,47 +2064,168 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { - {status === "done" ? ( -
- -
- ) : ( -
- {status === "generating" ? : status === "failed" ? : } - {status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"} - {status === "generating" ? : null} - - {status === "generating" - ? `AI 正在为 ${platform} / ${market} 整理${selectedCloneOutput.label}。` - : status === "failed" - ? "请检查网络后点击下方重试" - : "上传商品原图并填写信息后,AI 将在这里展示生成结果。"} - - {status === "failed" && lastFailedActionRef.current ? ( - + {cloneOutput === "video" ? ( + <> +
+ {/* Source Node — 原图素材 */} +
+
+ {productImages[0]?.src ? ( + 商品原图 + ) : ( +
+ +
+ )} +
+ 附件原图 +
+ + {/* Connector — 分支连接线 */} +
+ + {/* Status Overlay — 生成状态覆盖层 */} + {status !== "done" ? ( +
+ {status === "generating" ? ( + <> + + 正在生成 + + AI 正在为 {platform} / {market} 整理{selectedCloneOutput.label}。 + + ) : status === "failed" ? ( + <> + + 生成失败 + 请检查网络后点击下方重试 + {lastFailedActionRef.current ? ( + + ) : null} + + ) : ( + 上传商品原图并填写信息后,AI 将在这里展示生成结果。 + )} +
) : null} -
+ + ) : ( + <> + {status === "done" ? ( +
+ +
+ ) : ( +
+ {status === "generating" ? : status === "failed" ? : } + {status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"} + {status === "generating" ? : null} + + {status === "generating" + ? `AI 正在为 ${platform} / ${market} 整理${selectedCloneOutput.label}。` + : status === "failed" + ? "请检查网络后点击下方重试" + : "上传商品原图并填写信息后,AI 将在这里展示生成结果。"} + + {status === "failed" && lastFailedActionRef.current ? ( + + ) : null} +
+ )} + )}
diff --git a/src/features/ecommerce/EcommerceVideoWorkspace.tsx b/src/features/ecommerce/EcommerceVideoWorkspace.tsx index c318d85..79c8a92 100644 --- a/src/features/ecommerce/EcommerceVideoWorkspace.tsx +++ b/src/features/ecommerce/EcommerceVideoWorkspace.tsx @@ -1,4 +1,4 @@ -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CopyOutlined, DownloadOutlined, @@ -619,123 +619,126 @@ export default function EcommerceVideoWorkspace({
{!sourceImage ? (
- 上传商品图并点击"一键策划"开始 + 上传商品图并点击“一键策划”开始
) : ( -
- {/* Source image node */} -
-
- 商品图 +
+ {/* Source Node — 附件原图 */} +
+
+ 商品原图 +
+ 附件原图 +
+ + {/* Branch Connector — 分支连接线 */} +
- - {/* Connector: source → plan text nodes */} - {visiblePlanSteps.length > 0 ? ( - - ) : null} - - {/* Plan text nodes — side by side */} - {visiblePlanSteps.length > 0 ? ( -
- {visiblePlanSteps.map((step, idx) => ( - -
- - {currentStep === step ? : "✓"} - - {PLAN_STEP_LABELS[step]} +
+ + {/* Branches — 每个场景一条分支 */} +
+ {scenes.length > 0 ? scenes.map((scene, idx) => { + const planDone = completedSteps.length >= ALL_STEPS.length; + const imgReady = !!scene.imageUrl; + const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl; + const vidReady = scene.status === "completed" && scene.resultUrl; + const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending"); + const vidFailed = scene.status === "failed"; + + return ( +
+
+
+ 分镜文本{scene.sceneId} + + {planDone ? "已完成" : stage === "planning" ? "策划中..." : "等待策划"} + +
- {idx < visiblePlanSteps.length - 1 ? ( - - ) : null} - - ))} -
- ) : null} - - {/* Connector: plan → images */} - {hasImaging ? ( - - ) : null} - - {/* Storyboard image nodes — side by side per scene */} - {hasImaging ? ( -
- {scenes.map((scene, idx) => { - const imgReady = !!scene.imageUrl; - const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl; - const cls = imgReady ? "is-completed" : imgRunning ? "is-active" : ""; - return ( - -
-
- {imgReady ? {`分镜${scene.sceneId}`} - : imgRunning ?
- :
待生成
} + + + +
+ {imgReady ? ( + {`分镜${scene.sceneId}`} + ) : ( +
+ {imgRunning ? : 待生成}
- {imgRunning ? {scene.progress || 0}% : null} - 分镜{scene.sceneId} -
- {idx < scenes.length - 1 ? ( - - ) : null} - - ); - })} -
- ) : null} - - {/* Connector: images → videos */} - {hasRendering ? ( - - ) : null} - - {/* Video nodes — side by side per scene */} - {hasRendering ? ( -
- {scenes.map((scene, idx) => { - const vidReady = scene.status === "completed" && scene.resultUrl; - const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending"); - const vidFailed = scene.status === "failed"; - const cls = vidReady ? "is-completed" : vidRunning ? "is-active" : vidFailed ? "is-failed" : ""; - return ( - -
-
- {vidReady ?
+ + + +
+ {vidReady ? ( +
- {idx < scenes.length - 1 ? ( - + )} + {vidRunning ? {scene.progress || 0}% : null} + 分镜视频{scene.sceneId} + {vidFailed ? ( + ) : null} -
- ); - })} -
- ) : null} +
+
+ ); + }) : ( + [1, 2, 3].map((n) => ( +
+
+
+ 分镜文本{n} + {stage === "planning" ? "策划中..." : "等待策划"} +
+
+ +
+
+ {stage === "planning" ? : 待生成} +
+ 分镜图{n} +
+ +
+
+ {stage === "planning" ? : 待生成} +
+ 分镜视频{n} +
+
+ )) + )} +
)} diff --git a/src/features/ecommerce/ecommerceImageValidation.ts b/src/features/ecommerce/ecommerceImageValidation.ts new file mode 100644 index 0000000..a063e22 --- /dev/null +++ b/src/features/ecommerce/ecommerceImageValidation.ts @@ -0,0 +1,37 @@ +export const ECOMMERCE_SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); +export const ECOMMERCE_MAX_IMAGE_BYTES = 10 * 1024 * 1024; + +export interface EcommerceImageValidationResult { + accepted: File[]; + rejected: Array<{ name: string; reason: string }>; +} + +export function validateEcommerceImageFiles(files: File[]): EcommerceImageValidationResult { + const accepted: File[] = []; + const rejected: EcommerceImageValidationResult["rejected"] = []; + + files.forEach((file) => { + if (!ECOMMERCE_SUPPORTED_IMAGE_TYPES.has(file.type)) { + rejected.push({ name: file.name, reason: "不支持的图片格式" }); + return; + } + if (file.size > ECOMMERCE_MAX_IMAGE_BYTES) { + rejected.push({ name: file.name, reason: "图片超过 10MB" }); + return; + } + accepted.push(file); + }); + + return { accepted, rejected }; +} + +export function summarizeRejectedImages(rejected: EcommerceImageValidationResult["rejected"]): string { + if (!rejected.length) return ""; + const first = rejected[0]; + const suffix = rejected.length > 1 ? ` 等 ${rejected.length} 个文件` : ""; + return `${first.name}${suffix} 已跳过:${first.reason}`; +} + +export function normalizeEcommerceImageMime(type: string): string { + return ECOMMERCE_SUPPORTED_IMAGE_TYPES.has(type) ? type : "image/png"; +} diff --git a/src/features/ecommerce/panels/EcommerceClonePanel.tsx b/src/features/ecommerce/panels/EcommerceClonePanel.tsx new file mode 100644 index 0000000..57f83fe --- /dev/null +++ b/src/features/ecommerce/panels/EcommerceClonePanel.tsx @@ -0,0 +1,740 @@ +import { + CloudUploadOutlined, + CloseOutlined, + FileImageOutlined, + LoadingOutlined, + QuestionCircleOutlined, + ReloadOutlined, + SettingOutlined, +} from "@ant-design/icons"; +import type { ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react"; +import { useRef, useState } from "react"; + +type CloneOutputKey = string; +type CloneSetCountKey = string; +type CloneModelPanelTab = "scene" | "model"; +type CloneReferenceMode = "upload" | "link"; +type CloneReplicateLevelKey = string; +type CloneVideoQualityKey = string; + +interface CloneImageItem { + id: string; + src: string; + name: string; +} + +interface CloneBasicSelectItem { + key: string; + label: string; + value: string; + options: string[]; + onChange: (value: string) => void; +} + +interface CloneModelSelectItem { + key: string; + label: string; + value: string; + options: string[]; + onChange: (value: string) => void; +} + +interface CloneSetCountOption { + key: CloneSetCountKey; + title: string; + desc: string; +} + +interface CloneOutputOption { + key: CloneOutputKey; + label: string; +} + +interface CloneReplicateLevelOption { + key: CloneReplicateLevelKey; + title: string; + desc: string; +} + +interface CloneVideoQualityOption { + key: CloneVideoQualityKey; + label: string; + desc: string; +} + +interface CloneDetailModule { + id: string; + title: string; + desc: string; +} + +interface EcommerceClonePanelProps { + productInputRef: RefObject; + cloneReferenceInputRef: RefObject; + productImages: CloneImageItem[]; + isProductUploadDragging: boolean; + cloneOutput: CloneOutputKey; + cloneOutputOptions: CloneOutputOption[]; + cloneBasicSelects: CloneBasicSelectItem[]; + openCloneBasicSelect: string | null; + cloneReferenceMode: CloneReferenceMode; + cloneReferenceImages: CloneImageItem[]; + maxCloneReferenceImages: number; + cloneReplicateLevel: CloneReplicateLevelKey; + cloneReplicateLevelOptions: CloneReplicateLevelOption[]; + cloneSetCounts: Record; + cloneSetCountOptions: CloneSetCountOption[]; + cloneSetTotal: number; + minCloneSetTotal: number; + maxCloneSetTotal: number; + selectedCloneDetailModules: string[]; + cloneDetailModules: CloneDetailModule[]; + cloneModelPanelTab: CloneModelPanelTab; + tryOnScenes: string[]; + selectedCloneModelScenes: string[]; + cloneModelCustomScene: string; + cloneModelSelects: CloneModelSelectItem[]; + openCloneModelSelect: string | null; + cloneModelSelectDropUp: boolean; + cloneModelAppearance: string; + cloneVideoQuality: CloneVideoQualityKey; + cloneVideoQualityOptions: CloneVideoQualityOption[]; + cloneVideoDuration: number; + cloneVideoDurationMin: number; + cloneVideoDurationMax: number; + cloneVideoDurationStyle: { [key: string]: number | string }; + cloneVideoSmart: boolean; + canGenerate: boolean; + status: string; + lastFailedActionRef: MutableRefObject<(() => void) | null>; + setIsProductUploadDragging: (value: boolean) => void; + handleProductDrop: (event: DragEvent) => void; + removeProductImage: (id: string) => void; + handleProductUpload: (event: ChangeEvent) => void; + handleCloneOutputChange: (value: CloneOutputKey) => void; + setOpenCloneBasicSelect: (value: string | null) => void; + setCloneReferenceMode: (value: CloneReferenceMode) => void; + handleCloneReferenceUpload: (event: ChangeEvent) => void; + setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void; + startCloneSetCountHold: (key: CloneSetCountKey, delta: number, disabled: boolean) => void; + clearCloneSetCountHold: () => void; + toggleCloneDetailModule: (id: string) => void; + setCloneModelPanelTab: (value: CloneModelPanelTab) => void; + toggleCloneModelScene: (scene: string) => void; + setCloneModelCustomScene: (value: string) => void; + setOpenCloneModelSelect: (value: string | null) => void; + setCloneModelSelectDropUp: (value: boolean) => void; + setCloneModelAppearance: (value: string) => void; + setCloneVideoQuality: (value: CloneVideoQualityKey) => void; + setCloneVideoDuration: (value: number) => void; + clampCloneVideoDuration: (value: number) => number; + setCloneVideoSmart: (updater: (current: boolean) => boolean) => void; + handleGenerate: () => void; + formatRatioDisplayValue: (value: string) => string; + setVideoOutfitFiles?: (video: File | null, ref: File | null) => void; +} + +export default function EcommerceClonePanel({ + productInputRef, + cloneReferenceInputRef, + productImages, + isProductUploadDragging, + cloneOutput, + cloneOutputOptions, + cloneBasicSelects, + openCloneBasicSelect, + cloneReferenceMode, + cloneReferenceImages, + maxCloneReferenceImages, + cloneReplicateLevel, + cloneReplicateLevelOptions, + cloneSetCounts, + cloneSetCountOptions, + cloneSetTotal, + minCloneSetTotal, + maxCloneSetTotal, + selectedCloneDetailModules, + cloneDetailModules, + cloneModelPanelTab, + tryOnScenes, + selectedCloneModelScenes, + cloneModelCustomScene, + cloneModelSelects, + openCloneModelSelect, + cloneModelSelectDropUp, + cloneModelAppearance, + cloneVideoQuality, + cloneVideoQualityOptions, + cloneVideoDuration, + cloneVideoDurationMin, + cloneVideoDurationMax, + cloneVideoDurationStyle, + cloneVideoSmart, + canGenerate, + status, + lastFailedActionRef, + setIsProductUploadDragging, + handleProductDrop, + removeProductImage, + handleProductUpload, + handleCloneOutputChange, + setOpenCloneBasicSelect, + setCloneReferenceMode, + handleCloneReferenceUpload, + setCloneReplicateLevel, + startCloneSetCountHold, + clearCloneSetCountHold, + toggleCloneDetailModule, + setCloneModelPanelTab, + toggleCloneModelScene, + setCloneModelCustomScene, + setOpenCloneModelSelect, + setCloneModelSelectDropUp, + setCloneModelAppearance, + setCloneVideoQuality, + setCloneVideoDuration, + clampCloneVideoDuration, + setCloneVideoSmart, + handleGenerate, + formatRatioDisplayValue, + setVideoOutfitFiles, +}: EcommerceClonePanelProps) { + const videoOutfitVideoRef = useRef(null); + const videoOutfitRefRef = useRef(null); + const [videoOutfitVideoUrl, setVideoOutfitVideoUrl] = useState(null); + const [videoOutfitRefUrl, setVideoOutfitRefUrl] = useState(null); + + const handleVideoOutfitVideoChange = () => { + const file = videoOutfitVideoRef.current?.files?.[0] || null; + if (file) setVideoOutfitVideoUrl(URL.createObjectURL(file)); + setVideoOutfitFiles?.(file, videoOutfitRefRef.current?.files?.[0] || null); + }; + + const handleVideoOutfitRefChange = () => { + const file = videoOutfitRefRef.current?.files?.[0] || null; + if (file) setVideoOutfitRefUrl(URL.createObjectURL(file)); + setVideoOutfitFiles?.(videoOutfitVideoRef.current?.files?.[0] || null, file); + }; + + return ( + <> +
+
+ 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 ( +
+