diff --git a/.env.example b/.env.example index 44008b1..4ad78fa 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,5 @@ -# Dev proxy target — the backend API server -VITE_DEV_PROXY=http://47.110.225.76:3600 - -# Key server URL for auth/profile endpoints -VITE_KEY_SERVER_URL= - -# Main API base URL (used when not served from omniai.net.cn) -VITE_API_BASE_URL= \ No newline at end of file +# Frontend environment variables are intentionally unsupported. +# +# API traffic must go through same-origin /api. +# Public runtime settings must come from application APIs. +# Provider keys and OSS credentials must stay on the server. diff --git a/.gitignore b/.gitignore index 39ebdc3..ddc64a0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ node_modules/ Thumbs.db .vscode/ .idea/ +.claude/ +tmp/ *.swp *.swo -coverage/ \ No newline at end of file +coverage/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b4f0e1b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Project Rules + +## Asset, Key, And Runtime Data Governance + +These rules are mandatory for all frontend, backend, deployment, and agent-generated changes. + +1. Image and media assets must be stored in OSS. + - Do not commit product images, demo images, generated images, videos, or other large media assets into `src/assets` or other source folders. + - Code may reference media only by OSS URL or by data returned from an API. + - Local assets are limited to tiny build-critical files such as icons or placeholders, and require explicit justification. + +2. Frontend code must not contain API keys or secrets. + - Do not hard-code provider keys, access keys, tokens, private endpoints, passwords, or bearer tokens in TypeScript, CSS, HTML, Vite config, Nginx snippets, or checked-in docs. + - Browser-delivered code must treat every visible value as public. + +3. Provider keys are owned by the server key pool. + - AI provider credentials are stored and managed server-side. + - The frontend requests work through application APIs; the server leases provider keys from the concurrency/key pool and calls providers on behalf of the client. + - Do not add direct browser-to-provider calls that require provider credentials. + +4. Application data must come through APIs. + - Do not hard-code product data, pricing, model availability, provider routing, account state, usage state, or operational configuration in the frontend. + - Use typed API clients and server-provided payloads for runtime data. + - Static constants are allowed only for presentation defaults that are not business-authoritative. + +5. Do not use fixed environment configuration in application code. + - Do not bake production hostnames, provider endpoints, keys, or environment-specific behavior into source code. + - Environment-specific values belong in server deployment configuration, secret management, or runtime configuration endpoints. + - Frontend code must not add fixed `VITE_*` or equivalent environment variables for API hosts, provider hosts, business data, or secrets. + - If the browser needs runtime configuration, it must request that data from an application API. + +6. Deployment configuration must follow the same rules. + - Nginx and process manager configs must not embed provider API keys or long-lived credentials. + - Reverse proxies should route application traffic to the backend, not expose third-party credentials. + - Secrets must be rotated immediately if found in source, Git remotes, shell history, Nginx config, process manager config, or logs. + +7. Reviews must reject violations. + - Any new local media file, hard-coded key, direct provider credential path, or fixed production config is a blocking issue. + - Prefer deleting local assets and replacing them with OSS URLs returned by APIs or server-managed config. diff --git a/package.json b/package.json index efb8eb3..9cdec5e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "vite build", "preview": "vite preview --host 127.0.0.1", "type-check": "tsc -p tsconfig.json --noEmit", + "governance:check": "node scripts/check-governance.mjs", "style:check": "node scripts/check-style-governance.mjs", "smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs" }, diff --git a/scripts/check-governance.mjs b/scripts/check-governance.mjs new file mode 100644 index 0000000..efd2246 --- /dev/null +++ b/scripts/check-governance.mjs @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const repoRoot = process.cwd(); +const mediaExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".mov", ".webm", ".avif"]); +const textExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".html", ".css", ".md", ".env", ".example"]); + +const scanRoots = ["src", "vite.config.ts", "index.html", "package.json", ".env.example"]; +const allowedFiles = new Set([ + normalizePath("src/data/ossAssets.ts"), + normalizePath("src/utils/ossImageOptimize.ts"), +]); + +const forbiddenPatterns = [ + { label: "frontend env config", pattern: /\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/ }, + { label: "direct provider proxy", pattern: /\/dashscope-api\b|dashscope\.aliyuncs\.com/i }, + { label: "third-party demo media host", pattern: /picsum\.photos|xiuxiu-pro(?:-new)?\.meitudata\.com|meitudata\.com/i }, + { label: "hard-coded provider secret marker", pattern: /Bearer\s+sk-|DASHSCOPE_API_KEY|ACCESS_KEY_SECRET|SECRET_ACCESS_KEY/i }, + { label: "local media import", pattern: /from\s+["'][^"']*\/assets\/[^"']*\.(?:png|jpe?g|webp|gif|mp4|mov|webm|avif|svg)["']/i }, +]; + +const failures = []; + +function normalizePath(value) { + return value.replace(/\\/g, "/"); +} + +function walk(targetPath, visitor) { + if (!fs.existsSync(targetPath)) return; + const stat = fs.statSync(targetPath); + if (stat.isDirectory()) { + for (const entry of fs.readdirSync(targetPath)) { + if (entry === "node_modules" || entry === "dist" || entry === ".git") continue; + walk(path.join(targetPath, entry), visitor); + } + return; + } + visitor(targetPath, stat); +} + +function report(file, message) { + failures.push(`${normalizePath(path.relative(repoRoot, file))}: ${message}`); +} + +walk(path.join(repoRoot, "src", "assets"), (file) => { + if (mediaExtensions.has(path.extname(file).toLowerCase())) { + report(file, "media files must live in OSS, not src/assets"); + } +}); + +for (const root of scanRoots) { + walk(path.join(repoRoot, root), (file) => { + const relative = normalizePath(path.relative(repoRoot, file)); + const ext = path.extname(file).toLowerCase(); + if (!textExtensions.has(ext) && !relative.endsWith(".env.example")) return; + if (relative.startsWith("src/assets/")) return; + + const content = fs.readFileSync(file, "utf8"); + const isAllowed = allowedFiles.has(relative); + for (const rule of forbiddenPatterns) { + if (isAllowed && (rule.label === "third-party demo media host" || rule.label === "hard-coded provider secret marker")) { + continue; + } + if (rule.pattern.test(content)) { + report(file, `forbidden ${rule.label}`); + } + } + }); +} + +if (failures.length) { + console.error("Governance check failed:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log("Governance check passed."); diff --git a/scripts/check-style-governance.mjs b/scripts/check-style-governance.mjs new file mode 100644 index 0000000..3f5d301 --- /dev/null +++ b/scripts/check-style-governance.mjs @@ -0,0 +1 @@ +import "./check-governance.mjs"; 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..2e1d5fd 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"; @@ -28,6 +28,7 @@ import { SERVER_SESSION_REPLACED_EVENT, SERVER_SESSION_EXPIRED_EVENT, checkServerHealth, + clearAllUserStorage, getErrorMessage, type ServerSessionReplacedDetail, } from "./api/serverConnection"; @@ -143,7 +144,9 @@ function normalizeViewKey(rawView: string): WebViewKey { } function readViewFromHash(): WebViewKey { - return normalizeViewKey(window.location.hash.replace(/^#\/?/, "")); + const raw = window.location.hash.replace(/^#\/?/, ""); + if (!raw) return "home"; + return normalizeViewKey(raw); } function isWorkspaceView(view: WebViewKey): boolean { @@ -284,6 +287,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"); @@ -369,7 +378,7 @@ function App() { }, [setView, setWorkspaceExpanded]); const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => { - keyServerClient.clearSession(); + clearAllUserStorage(); clearSessionState(); setProjects([]); setProjectsLoaded(true); @@ -1075,20 +1084,7 @@ function App() { return ; case "ecommerce": case "ecommerceHub": - return ( - setPendingEcommerceTemplate(null)} - /> - ); + return null; case "digitalHuman": return ( - +
@@ -1244,6 +1240,26 @@ function App() { + {/* KeepAlive: EcommercePage stays mounted once visited, hidden via display:none */} + {ecommerceEverMounted && ( +
+ + setPendingEcommerceTemplate(null)} + /> + +
+ )} + {loginPromptOpen && pendingAction ? (
- {CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? : null} + {session?.user.role === "admin" ? : null} setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
diff --git a/src/components/CookieConsentBanner.tsx b/src/components/CookieConsentBanner.tsx index f8cf683..ebdebb5 100644 --- a/src/components/CookieConsentBanner.tsx +++ b/src/components/CookieConsentBanner.tsx @@ -1,4 +1,31 @@ -function CookieConsentBanner() { - return null; // TODO: implement cookie consent UI +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 和本地存储保存登录状态、偏好设置、创作草稿和断点续传数据,用于保障服务正常运行。

+
+
+ 查看隐私政策 + +
+
+ ); } -export default CookieConsentBanner; 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/data/ossAssets.ts b/src/data/ossAssets.ts new file mode 100644 index 0000000..221e4db --- /dev/null +++ b/src/data/ossAssets.ts @@ -0,0 +1,124 @@ +const OSS_PUBLIC_BASE_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com"; + +function oss(path: string): string { + return `${OSS_PUBLIC_BASE_URL}/${path.replace(/^\/+/, "")}`; +} + +function muban(path: string): string { + return oss(`muban/${path.replace(/^\/+/, "")}`); +} + +function toolbox(path: string): string { + return oss(`static/toolbox/${path.replace(/^\/+/, "")}`); +} + +export const ossAssets = { + brand: { + logo: oss("logo.png"), + }, + auth: { + showcaseVideo: oss("test5.mp4"), + }, + home: { + backgroundVideo: muban("hero-bg.mp4"), + heroSlides: [muban("hero-1.png"), muban("hero-2.png"), muban("hero-3.png")], + features: { + ecommerce: muban("feature-ecommerce.jpg"), + script: muban("feature-script.jpg"), + token: muban("feature-token.jpg"), + }, + }, + toolbox: { + imageBefore: toolbox("%E7%89%9B%E4%BB%94.webp"), + imageAfter: toolbox("%E8%A5%BF%E8%A3%85.webp"), + watermarkBefore: toolbox("%E5%8E%BB%E6%B0%B4%E5%8D%B0%E5%89%8D.webp"), + watermarkAfter: toolbox("%E5%8E%BB%E6%B0%B4%E5%8D%B0%E5%90%8E.webp"), + }, + community: { + cardImages: [ + muban("dianshang1.png"), + muban("dianshang2.png"), + muban("dianshang3.png"), + muban("wechat-7.png"), + muban("wechat-8.png"), + muban("wechat-9.png"), + ], + carouselVideos: [oss("test3.mp4"), oss("test4.mp4"), oss("test6.mp4")], + }, + workflows: { + caseImages: [ + muban("community/workflow-rain-night.jpg"), + muban("community/workflow-character-look.jpg"), + muban("community/workflow-skyline.jpg"), + muban("community/workflow-lab.jpg"), + ], + }, + ecommerce: { + generated: muban("ecommerce-carousel-generated.png"), + slides: { + slide4: muban("slide-4.png"), + slide5: muban("slide-5.png"), + }, + heroSlides: [ + muban("ecommerce-hero-carousel/slide-1.webp"), + muban("ecommerce-hero-carousel/slide-2.webp"), + muban("ecommerce-hero-carousel/slide-3.webp"), + muban("ecommerce-hero-carousel/slide-4.webp"), + muban("ecommerce-hero-carousel/slide-5.webp"), + ], + templateSlides: [ + muban("more-template-carousel/slide-1.jpg"), + muban("more-template-carousel/slide-2.jpg"), + muban("more-template-carousel/slide-3.jpg"), + muban("more-template-carousel/slide-4.png"), + muban("more-template-carousel/slide-5.gif"), + ], + templateCases: [ + muban("ecommerce/templates/case-1.png"), + muban("ecommerce/templates/case-2.png"), + muban("ecommerce/templates/case-3.png"), + muban("ecommerce/templates/case-4.png"), + muban("ecommerce/templates/case-5.png"), + muban("ecommerce/templates/case-6.png"), + ], + productSet: { + main: muban("ecommerce/product-set/main.webp"), + scene: muban("ecommerce/product-set/scene.webp"), + model: muban("ecommerce/product-set/model.webp"), + detail: muban("ecommerce/product-set/detail.webp"), + selling: muban("ecommerce/product-set/selling.webp"), + hosting: muban("ecommerce/product-set/hosting.webp"), + }, + tryOn: { + dressA: muban("ecommerce/try-on/dress-a.webp"), + dressB: muban("ecommerce/try-on/dress-b.webp"), + modelWoman: muban("ecommerce/try-on/model-woman.webp"), + modelMan: muban("ecommerce/try-on/model-man.webp"), + modelAsian: muban("ecommerce/try-on/model-asian.webp"), + tryA: muban("ecommerce/try-on/result-a.webp"), + tryB: muban("ecommerce/try-on/result-b.webp"), + jacket: muban("ecommerce/try-on/jacket.webp"), + jacketResultA: muban("ecommerce/try-on/jacket-result-a.webp"), + jacketResultB: muban("ecommerce/try-on/jacket-result-b.webp"), + hat: muban("ecommerce/try-on/hat.webp"), + hatResultA: muban("ecommerce/try-on/hat-result-a.webp"), + hatResultB: muban("ecommerce/try-on/hat-result-b.webp"), + }, + detail: { + productA: muban("ecommerce/detail/product-a.webp"), + productB: muban("ecommerce/detail/product-b.webp"), + productC: muban("ecommerce/detail/product-c.webp"), + longPage: muban("ecommerce/detail/long-page.webp"), + gridA: muban("ecommerce/detail/grid-a.webp"), + gridB: muban("ecommerce/detail/grid-b.webp"), + gridC: muban("ecommerce/detail/grid-c.webp"), + gridD: muban("ecommerce/detail/grid-d.webp"), + gridE: muban("ecommerce/detail/grid-e.webp"), + gridF: muban("ecommerce/detail/grid-f.webp"), + }, + }, +} as const; + +export type ProductSetOssAssets = typeof ossAssets.ecommerce.productSet; +export type TryOnOssAssets = typeof ossAssets.ecommerce.tryOn; +export type DetailOssAssets = typeof ossAssets.ecommerce.detail; diff --git a/src/data/workflows.ts b/src/data/workflows.ts index c6c4dcc..3387ab9 100644 --- a/src/data/workflows.ts +++ b/src/data/workflows.ts @@ -1,4 +1,7 @@ import type { WebCanvasWorkflow, WebCommunityCase } from "../types"; +import { ossAssets } from "./ossAssets"; + +const [rainNightImage, characterLookImage, skylineImage, labImage] = ossAssets.workflows.caseImages; function createNodes( title: string, @@ -69,7 +72,7 @@ export const communityCases: WebCommunityCase[] = [ author: "Dave", tag: "视频案例", summary: "从街口推到人物面部,强调雨夜反光与情绪收束。", - imageUrl: "https://picsum.photos/id/1011/900/540", + imageUrl: rainNightImage, workflow: { id: "workflow-rain-night", version: 1, @@ -83,7 +86,7 @@ export const communityCases: WebCommunityCase[] = [ duration: "6s", resolution: "720p", }, - nodes: createNodes("雨夜街巷,镜头从水面倒影推进到人物特写", "https://picsum.photos/id/1011/960/540"), + nodes: createNodes("雨夜街巷,镜头从水面倒影推进到人物特写", rainNightImage), edges: createEdges(), }, }, @@ -93,7 +96,7 @@ export const communityCases: WebCommunityCase[] = [ author: "SuperXe", tag: "角色案例", summary: "把单张角色图扩展成可连续出片的角色工作流。", - imageUrl: "https://picsum.photos/id/1027/900/540", + imageUrl: characterLookImage, workflow: { id: "workflow-character-look", version: 1, @@ -107,7 +110,7 @@ export const communityCases: WebCommunityCase[] = [ duration: "5s", resolution: "720p", }, - nodes: createNodes("角色定妆,强调服装、姿态与近景表情", "https://picsum.photos/id/1027/960/540"), + nodes: createNodes("角色定妆,强调服装、姿态与近景表情", characterLookImage), edges: createEdges(), }, }, @@ -117,7 +120,7 @@ export const communityCases: WebCommunityCase[] = [ author: "OmniAI", tag: "风景案例", summary: "用广角风景做镜头进入,适合转场和开场片头。", - imageUrl: "https://picsum.photos/id/1050/900/540", + imageUrl: skylineImage, workflow: { id: "workflow-skyline", version: 1, @@ -131,7 +134,7 @@ export const communityCases: WebCommunityCase[] = [ duration: "8s", resolution: "1080p", }, - nodes: createNodes("风景开场,镜头缓慢推进到天际线", "https://picsum.photos/id/1050/960/540"), + nodes: createNodes("风景开场,镜头缓慢推进到天际线", skylineImage), edges: createEdges(), }, }, @@ -141,7 +144,7 @@ export const communityCases: WebCommunityCase[] = [ author: "Studio", tag: "实验案例", summary: "更适合拆解推拉摇移和节奏控制的实验模板。", - imageUrl: "https://picsum.photos/id/1056/900/540", + imageUrl: labImage, workflow: { id: "workflow-lab", version: 1, @@ -155,7 +158,7 @@ export const communityCases: WebCommunityCase[] = [ duration: "6s", resolution: "720p", }, - nodes: createNodes("镜头实验,分镜更清晰,便于二次调整", "https://picsum.photos/id/1056/960/540"), + nodes: createNodes("镜头实验,分镜更清晰,便于二次调整", labImage), edges: createEdges(), }, }, diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index 2afe062..4bf3cb8 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -2646,7 +2646,23 @@ function CanvasPage({ } : null; })() - : null; + : connectionDropMenu + ? (() => { + const source = getNodePortPoint(connectionDropMenu.sourcePort); + const target = getCanvasWorldPointFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop); + return source + ? { + id: "pending-link-preview", + sourceX: source.x, + sourceY: source.y, + targetX: target.x, + targetY: target.y, + sourceSide: connectionDropMenu.sourcePort.side, + targetSide: null, + } + : null; + })() + : null; const openCanvasAddNodeMenu = useCallback((clientX: number, clientY: number) => { const menuPosition = positionFloatingMenu(clientX, clientY, 260, 390, 0); @@ -2816,6 +2832,8 @@ function CanvasPage({ originTop: event.clientY, sourcePort: connectorDrag.port, }); + setPendingLinkPort(null); + setPendingLinkPreviewPoint(null); } } else { clearPendingConnector(); @@ -2840,7 +2858,7 @@ function CanvasPage({ }, [selectedNode]); const handleCanvasMouseMove = (event: MouseEvent) => { - if (!pendingLinkPort) return; + if (!pendingLinkPort || connectionDropMenu) return; setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY)); }; @@ -5534,11 +5552,6 @@ function CanvasPage({ role="menu" onClick={(event) => event.stopPropagation()} onContextMenu={(event) => event.preventDefault()} - onMouseMove={(event) => { - if (pendingLinkPort) { - setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY)); - } - }} >
新建节点并连接
) : null} + + setVideoHistoryVisible(false)} + /> ); } diff --git a/src/features/ecommerce/EcommerceVideoWorkspace.tsx b/src/features/ecommerce/EcommerceVideoWorkspace.tsx index c318d85..e476d1a 100644 --- a/src/features/ecommerce/EcommerceVideoWorkspace.tsx +++ b/src/features/ecommerce/EcommerceVideoWorkspace.tsx @@ -1,15 +1,16 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + CloseOutlined, CopyOutlined, DownloadOutlined, FolderAddOutlined, + HistoryOutlined, LoadingOutlined, - PlayCircleOutlined, ReloadOutlined, SendOutlined, StopOutlined, } from "@ant-design/icons"; -import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks } from "./ecommerceVideoService"; +import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService"; import { PLAN_STEP_LABELS, PLAN_STEPS_DISPLAY, @@ -39,6 +40,8 @@ interface EcommerceVideoWorkspaceProps { durationSeconds: number; resolution: string; onRequestLogin?: () => void; + onOpenHistory?: () => void; + triggerPlan?: number; } const ALL_STEPS: PlanStep[] = [ @@ -100,6 +103,8 @@ export default function EcommerceVideoWorkspace({ durationSeconds, resolution, onRequestLogin, + onOpenHistory, + triggerPlan, }: EcommerceVideoWorkspaceProps) { const [stage, setStage] = useState("idle"); const [planResult, setPlanResult] = useState(null); @@ -111,6 +116,7 @@ export default function EcommerceVideoWorkspace({ const [failedStep, setFailedStep] = useState(null); const [error, setError] = useState(null); const [actionNotice, setActionNotice] = useState(null); + const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null); const abortControllerRef = useRef(null); const renderAbortRef = useRef({ current: false }); const setView = useAppStore((s) => s.setView); @@ -145,26 +151,45 @@ export default function EcommerceVideoWorkspace({ saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls }); }, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]); - // ── Auto-advance: skip manual "next step" clicks ───────── - const autoAdvanceTriggeredRef = useRef(false); + // ── Auto-advance: automatically run the full pipeline ───────── useEffect(() => { - if (autoAdvanceTriggeredRef.current) return; const delay = 600; if (stage === "planned" && planResult && scenes.length > 0) { - autoAdvanceTriggeredRef.current = true; const timer = setTimeout(() => { void handleGenerateImages(); }, delay); return () => clearTimeout(timer); } if (stage === "imaged" && scenes.every((s) => s.imageUrl)) { - autoAdvanceTriggeredRef.current = true; const timer = setTimeout(() => { void handleRenderVideos(); }, delay); return () => clearTimeout(timer); } - if (stage === "idle" || stage === "cancelled") { - autoAdvanceTriggeredRef.current = false; - } }, [stage, scenes, planResult]); + // ── External trigger: start plan from parent ──────────────── + const triggerPlanPrevRef = useRef(triggerPlan); + useEffect(() => { + if (triggerPlan != null && triggerPlan !== triggerPlanPrevRef.current) { + triggerPlanPrevRef.current = triggerPlan; + void handlePlan(); + } + }, [triggerPlan]); + + // ── Auto-save: persist completed results to server ────────── + const historySavedRef = useRef(false); + useEffect(() => { + if (stage !== "completed") { historySavedRef.current = false; return; } + if (historySavedRef.current) return; + if (!planResult || !scenes.length) return; + historySavedRef.current = true; + const title = planResult.storyboard?.video_title || planResult.summary?.product_name || "电商广告视频"; + saveVideoHistory({ + title, + config: { platform, aspectRatio, durationSeconds, resolution }, + plan: planResult as unknown as Record, + scenes: scenes.map((s) => ({ sceneId: s.sceneId, prompt: s.prompt, imageUrl: s.imageUrl, videoUrl: s.resultUrl })), + sourceImageUrls, + }).catch(() => {}); + }, [stage, planResult, scenes, sourceImageUrls, platform, aspectRatio, durationSeconds, resolution]); + // ── Keep-alive: resume polling for running tasks ────────── useEffect(() => { if (keepalivePollingStartedRef.current) return; @@ -431,7 +456,7 @@ export default function EcommerceVideoWorkspace({ persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s)); try { await renderSceneImage( - { sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio }, + { sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio, productImageUrls: sourceImageUrls }, { onSceneImageSubmitted: (id, taskId) => { persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)); @@ -486,7 +511,7 @@ export default function EcommerceVideoWorkspace({ persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s)); try { await renderScene( - { sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality }, + { sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, productImageUrls: sourceImageUrls, aspectRatio, resolution: quality }, { onSceneSubmitted: (id, taskId) => { persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)); @@ -529,7 +554,7 @@ export default function EcommerceVideoWorkspace({ setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s)); try { await renderScene( - { sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, aspectRatio, resolution: mapResolutionToQuality(resolution) }, + { sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, productImageUrls: sourceImageUrls, aspectRatio, resolution: mapResolutionToQuality(resolution) }, { onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)), onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)), @@ -573,6 +598,11 @@ export default function EcommerceVideoWorkspace({
+ {onOpenHistory ? ( + + ) : null} {error ? {error} : null} {stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? ( ) : null} - {stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? ( - - ) : null} {stage === "planned" || stage === "imaged" ? ( - ) : null} - {vidFailed && scene.error ? ( - {scene.error.slice(0, 20)} - ) : null} -
- ) : null} + + + ); + }) : ( +
+
+
+ 分镜策划 + {stage === "planning" ? "策划中..." : "点击一键策划开始"} +
+
+ +
+
+ {stage === "planning" ? : 待生成} +
+ 分镜图 +
+ +
+
+ {stage === "planning" ? : 待生成} +
+ 分镜视频 +
+
+ )} + )} @@ -750,6 +771,19 @@ export default function EcommerceVideoWorkspace({ ) : null} {actionNotice ?
{actionNotice}
: null} + + {previewMedia ? ( +
setPreviewMedia(null)}> + + {previewMedia.type === "image" ? ( + 预览 e.stopPropagation()} /> + ) : ( +
+ ) : null} ); } diff --git a/src/features/ecommerce/ecommerceImageValidation.ts b/src/features/ecommerce/ecommerceImageValidation.ts index 044f193..a063e22 100644 --- a/src/features/ecommerce/ecommerceImageValidation.ts +++ b/src/features/ecommerce/ecommerceImageValidation.ts @@ -1,16 +1,37 @@ -export function normalizeEcommerceImageMime(file: File): string { - return file.type || "image/png"; +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 summarizeRejectedImages(files: File[]): string { - if (files.length === 0) return ""; - return `${files.length} 个文件不符合要求`; +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 validateEcommerceImageFiles(files: File[]): { - valid: File[]; - rejected: File[]; -} { - // TODO: implement actual image validation - return { valid: files, 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/ecommerceTemplates.ts b/src/features/ecommerce/ecommerceTemplates.ts index 45ee5e9..6f96ea9 100644 --- a/src/features/ecommerce/ecommerceTemplates.ts +++ b/src/features/ecommerce/ecommerceTemplates.ts @@ -1,18 +1,28 @@ -import ecommerceCarouselGenerated from "../../assets/ecommerce-carousel-generated.png"; -import moreTemplateSlide1 from "../../assets/more-template-carousel/slide-1.jpg"; -import moreTemplateSlide2 from "../../assets/more-template-carousel/slide-2.jpg"; -import moreTemplateSlide3 from "../../assets/more-template-carousel/slide-3.jpg"; -import moreTemplateSlide4 from "../../assets/more-template-carousel/slide-4.png"; -import moreTemplateSlide5 from "../../assets/more-template-carousel/slide-5.gif"; -import ecommerceHeroSlide1 from "../../assets/ecommerce-hero-carousel/slide-1.webp"; -import ecommerceHeroSlide2 from "../../assets/ecommerce-hero-carousel/slide-2.webp"; -import ecommerceHeroSlide3 from "../../assets/ecommerce-hero-carousel/slide-3.webp"; -import ecommerceHeroSlide4 from "../../assets/ecommerce-hero-carousel/slide-4.webp"; -import ecommerceHeroSlide5 from "../../assets/ecommerce-hero-carousel/slide-5.webp"; -import ecommerceCarouselImage1 from "../../../tu/微信图片_20260514125332_8_2.png"; -import ecommerceCarouselImage2 from "../../../tu/微信图片_20260514125332_9_2.png"; -import ecommerceCarouselImage3 from "../../../tu/微信图片_20260514125332_7_2.png"; -import ecommerceCarouselImage4 from "../../../tu/微信图片_20260514125332_12_2.png"; +import { ossAssets } from "../../data/ossAssets"; + +const [ + moreTemplateSlide1, + moreTemplateSlide2, + moreTemplateSlide3, + moreTemplateSlide4, + moreTemplateSlide5, +] = ossAssets.ecommerce.templateSlides; +const [ + ecommerceHeroSlide1, + ecommerceHeroSlide2, + ecommerceHeroSlide3, + ecommerceHeroSlide4, + ecommerceHeroSlide5, +] = ossAssets.ecommerce.heroSlides; +const [ + ecommerceCarouselImage1, + ecommerceCarouselImage2, + ecommerceCarouselImage3, + ecommerceCarouselImage4, + ecommerceCarouselImage5, + ecommerceCarouselImage6, +] = ossAssets.ecommerce.templateCases; +const ecommerceCarouselGenerated = ossAssets.ecommerce.generated; export interface TemplateCase { title: string; @@ -124,6 +134,6 @@ export const templateCases: TemplateCase[] = [ title: "促销卖点组合图", category: "详情图", summary: "把成分、规格、卖点拆成清晰的详情页模块。", - imageUrl: "https://picsum.photos/id/1080/900/620", + imageUrl: ecommerceCarouselImage6, }, ]; diff --git a/src/features/ecommerce/ecommerceVideoService.ts b/src/features/ecommerce/ecommerceVideoService.ts index 3ea489d..7f85cfa 100644 --- a/src/features/ecommerce/ecommerceVideoService.ts +++ b/src/features/ecommerce/ecommerceVideoService.ts @@ -152,6 +152,7 @@ export interface RenderSceneImageInput { sceneId: number; prompt: string; aspectRatio: string; + productImageUrls: string[]; } export interface RenderImageCallbacks { @@ -171,6 +172,7 @@ export async function renderSceneImage( prompt: input.prompt, ratio: input.aspectRatio, quality: "2K", + referenceUrls: input.productImageUrls, }); callbacks.onSceneImageSubmitted(input.sceneId, taskId); @@ -192,6 +194,7 @@ export interface RenderSceneInput { prompt: string; durationSeconds: number; imageUrl: string; + productImageUrls: string[]; aspectRatio: string; resolution: string; model?: string; @@ -209,9 +212,10 @@ export async function renderScene( callbacks: RenderCallbacks, abortRef: { current: boolean }, ): Promise { + const allReferenceUrls = [...input.productImageUrls, input.imageUrl]; const model = resolveVideoRequestModel({ model: input.model || "happyhorse-1.0", - referenceUrls: [input.imageUrl], + referenceUrls: allReferenceUrls, }); const { taskId } = await aiGenerationClient.createVideoTask({ @@ -222,7 +226,7 @@ export async function renderScene( quality: input.resolution, resolution: input.resolution, frameMode: "start-end", - referenceUrls: [input.imageUrl], + referenceUrls: allReferenceUrls, hasReferenceVideo: false, }); @@ -254,3 +258,73 @@ export function buildSceneTasks( }; }); } + +// ── Video History API ────────────────────────────────── + +export interface VideoHistoryScene { + sceneId: number; + prompt: string; + imageUrl?: string | null; + videoUrl?: string | null; +} + +export interface VideoHistoryItem { + id: number; + title: string; + config: Record; + scenes: VideoHistoryScene[]; + sourceImageUrls: string[]; + createdAt: string; +} + +export interface VideoHistoryListResponse { + items: VideoHistoryItem[]; + total: number; + limit: number; + offset: number; +} + +import { getStoredToken } from "../../api/serverConnection"; + +const API_BASE = "/api/ai/ecommerce/video-history"; + +function getAuthHeaders(): Record { + const token = getStoredToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +export async function saveVideoHistory(payload: { + title: string; + config: Record; + plan: Record; + scenes: VideoHistoryScene[]; + sourceImageUrls: string[]; +}): Promise<{ id: number; createdAt: string }> { + const res = await fetch(API_BASE, { + method: "POST", + headers: { "Content-Type": "application/json", ...getAuthHeaders() }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error("保存历史记录失败"); + return res.json(); +} + +export async function fetchVideoHistory( + limit = 20, + offset = 0, +): Promise { + const res = await fetch( + `${API_BASE}?limit=${limit}&offset=${offset}`, + { headers: getAuthHeaders() }, + ); + if (!res.ok) throw new Error("获取历史记录失败"); + return res.json(); +} + +export async function deleteVideoHistory(id: number): Promise { + const res = await fetch(`${API_BASE}/${id}`, { + method: "DELETE", + headers: getAuthHeaders(), + }); + if (!res.ok) throw new Error("删除失败"); +} diff --git a/src/features/ecommerce/panels/EcommerceClonePanel.tsx b/src/features/ecommerce/panels/EcommerceClonePanel.tsx index 89bdb4c..a732d87 100644 --- a/src/features/ecommerce/panels/EcommerceClonePanel.tsx +++ b/src/features/ecommerce/panels/EcommerceClonePanel.tsx @@ -1,4 +1,752 @@ -function EcommerceClonePanel(_props: Record) { - return
商品克隆模块 - 开发中
; +import { + CloudUploadOutlined, + CloseOutlined, + FileImageOutlined, + LoadingOutlined, + QuestionCircleOutlined, + ReloadOutlined, + SettingOutlined, +} from "@ant-design/icons"; +import type { CSSProperties, ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react"; +import { useRef, useState } from "react"; + +type ProductSetOutputKey = "set" | "detail" | "model" | "video"; +type CloneOutputKey = ProductSetOutputKey | "hot" | "video-outfit"; +type CloneSetCountKey = "selling" | "white" | "scene"; +type CloneModelPanelTab = "scene" | "model"; +type CloneReferenceMode = "upload" | "link"; +type CloneReplicateLevelKey = "style" | "high"; +type CloneVideoQualityKey = "standard" | "high" | "ultra"; +type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio"; +type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body"; + +interface CloneImageItem { + id: string; + src: string; + name: string; +} + +interface CloneBasicSelectItem { + key: CloneBasicSelectKey; + label: string; + value: string; + options: string[]; + onChange: (value: string) => void; +} + +interface CloneModelSelectItem { + key: CloneModelSelectKey; + 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: CloneBasicSelectKey | 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: CloneModelSelectKey | null; + cloneModelSelectDropUp: boolean; + cloneModelAppearance: string; + cloneVideoQuality: CloneVideoQualityKey; + cloneVideoQualityOptions: CloneVideoQualityOption[]; + cloneVideoDuration: number; + cloneVideoDurationMin: number; + cloneVideoDurationMax: number; + cloneVideoDurationStyle: CSSProperties; + 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: CloneBasicSelectKey | null) => void; + setCloneReferenceMode: (value: CloneReferenceMode) => void; + handleCloneReferenceUpload: (event: ChangeEvent) => void; + setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void; + startCloneSetCountHold: (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => void; + clearCloneSetCountHold: () => void; + toggleCloneDetailModule: (id: string) => void; + setCloneModelPanelTab: (value: CloneModelPanelTab) => void; + toggleCloneModelScene: (scene: string) => void; + setCloneModelCustomScene: (value: string) => void; + setOpenCloneModelSelect: (value: CloneModelSelectKey | 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; + onStartVideoPlan?: () => 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, + onStartVideoPlan, +}: 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 ( +
+