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/src/App.tsx b/src/App.tsx index f301622..2e1d5fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,12 +14,13 @@ 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"; 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"; @@ -27,12 +28,16 @@ import { SERVER_SESSION_REPLACED_EVENT, SERVER_SESSION_EXPIRED_EVENT, checkServerHealth, + clearAllUserStorage, getErrorMessage, type ServerSessionReplacedDetail, } from "./api/serverConnection"; import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway"; import { translateTaskError } from "./utils/translateTaskError"; +import { recoverAndResumeTasks } from "./services/backgroundTaskRunner"; import AppShell from "./components/AppShell"; +const NotFoundPage = lazy(() => import("./components/NotFoundPage")); +const CompliancePage = lazy(() => import("./features/compliance/CompliancePage")); import { cloneWorkflow, createBlankWorkflow } from "./data/workflows"; const AgentPage = lazy(() => import("./features/agent/AgentPage")); const AssetsPage = lazy(() => import("./features/assets/AssetsPage")); @@ -55,7 +60,6 @@ const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/Wat const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage")); const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage")); const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage")); -const SettingsPage = lazy(() => import("./features/settings/SettingsPage")); const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage")); import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage"; import { @@ -102,7 +106,6 @@ const VIEW_KEYS = new Set([ "ecommerce", "scriptTokens", "tokenUsage", - "settings", "imageWorkbench", "resolutionUpscale", "watermarkRemoval", @@ -115,26 +118,35 @@ const VIEW_KEYS = new Set([ "communityCaseAdd", "report", "providerHealth", + "userAgreement", + "privacyPolicy", + "not-found", ]); -const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more"]); +const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]); function normalizeViewKey(rawView: string): WebViewKey { const normalized = rawView === "profile" || rawView === "auth" ? "login" - : rawView === "ecommerceHub" - ? "ecommerce" + : rawView === "ecommerceHub" + ? "ecommerce" + : rawView === "terms" || rawView === "agreement" || rawView === "user-agreement" + ? "userAgreement" + : rawView === "privacy" || rawView === "privacy-policy" + ? "privacyPolicy" : rawView === "community-review" ? "communityReview" : rawView === "community-case-add" ? "communityCaseAdd" : rawView; - return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "home"; + return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found"; } function readViewFromHash(): WebViewKey { - return normalizeViewKey(window.location.hash.replace(/^#\/?/, "")); + const raw = window.location.hash.replace(/^#\/?/, ""); + if (!raw) return "home"; + return normalizeViewKey(raw); } function isWorkspaceView(view: WebViewKey): boolean { @@ -146,7 +158,8 @@ function isWorkspaceView(view: WebViewKey): boolean { view !== "ecommerceHub" && view !== "ecommerce" && view !== "scriptTokens" && - view !== "login" + view !== "login" && + view !== "not-found" ); } @@ -274,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"); @@ -318,6 +337,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: }, @@ -354,7 +378,7 @@ function App() { }, [setView, setWorkspaceExpanded]); const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => { - keyServerClient.clearSession(); + clearAllUserStorage(); clearSessionState(); setProjects([]); setProjectsLoaded(true); @@ -835,6 +859,10 @@ function App() { setSession(nextSession); await hydrateAccountData(nextSession); + if (nextSession.user.email && !nextSession.user.emailVerified) { + toast.info("邮箱尚未验证,部分功能可能受限,请在登录页通过邮箱验证码完成验证"); + } + const action = pendingAction; closeLoginPrompt(); if (action) { @@ -1056,20 +1084,7 @@ function App() { return ; case "ecommerce": case "ecommerceHub": - return ( - setPendingEcommerceTemplate(null)} - /> - ); + return null; case "digitalHuman": return ( ); - case "settings": - return ; case "imageWorkbench": return ( ; case "providerHealth": return ; + case "userAgreement": + return ; + case "privacyPolicy": + return ; case "communityReview": return ( ); case "home": - default: return ( handleSetView("workbench")} @@ -1190,6 +1206,9 @@ function App() { onOpenImageTool={handleOpenImageWorkbenchTool} /> ); + case "not-found": + default: + return handleSetView("home")} />; } })(); @@ -1208,7 +1227,7 @@ function App() { onMarkNotificationRead={handleMarkNotificationRead} onMarkAllNotificationsRead={handleMarkAllNotificationsRead} > - +
@@ -1221,6 +1240,26 @@ function App() { + {/* KeepAlive: EcommercePage stays mounted once visited, hidden via display:none */} + {ecommerceEverMounted && ( +
+ + setPendingEcommerceTemplate(null)} + /> + +
+ )} + {loginPromptOpen && pendingAction ? (
@@ -356,7 +372,7 @@ function AppShell({ onClick={() => setRechargeOpen(true)} > - {displayedBalanceLabel} + {displayedBalanceLabel}
{session?.user.role === "admin" ? : null} setRechargeOpen(false)} currentBalance={displayedBalanceCents} /> +
); } 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/NotFoundPage.tsx b/src/components/NotFoundPage.tsx new file mode 100644 index 0000000..2a09a9c --- /dev/null +++ b/src/components/NotFoundPage.tsx @@ -0,0 +1,24 @@ +import { HomeOutlined } from "@ant-design/icons"; +import { useCallback } from "react"; + +interface NotFoundPageProps { + onGoHome: () => void; +} + +function NotFoundPage({ onGoHome }: NotFoundPageProps) { + return ( +
+
+
404
+

页面未找到

+

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

+ +
+
+ ); +} + +export default NotFoundPage; diff --git a/src/components/PageTransition.tsx b/src/components/PageTransition.tsx index 3f6a491..a020281 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", @@ -81,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/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/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/assets/AssetsPage.tsx b/src/features/assets/AssetsPage.tsx index 5c94c07..e3cf85c 100644 --- a/src/features/assets/AssetsPage.tsx +++ b/src/features/assets/AssetsPage.tsx @@ -100,14 +100,14 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) { setContextMenu({ x: e.clientX, y: e.clientY, asset }); }, []); - const handleDeleteAsset = useCallback(async () => { - if (!contextMenu) return; - const { asset } = contextMenu; + const handleDeleteAsset = useCallback(async (asset?: LibraryAssetItem) => { + const target = asset || contextMenu?.asset; + if (!target) return; setContextMenu(null); try { - await assetClient.delete(asset.id); - setServerAssets((prev) => prev.filter((a) => a.id !== asset.id)); - setServerNotice(`已删除 ${asset.name}`); + await assetClient.delete(target.id); + setServerAssets((prev) => prev.filter((a) => a.id !== target.id)); + setServerNotice(`已删除 ${target.name}`); } catch (err) { setServerNotice(err instanceof Error ? err.message : "删除失败"); } @@ -287,32 +287,42 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) { {visibleAssets.length ? (
{visibleAssets.map((asset) => ( - + + +
))} ) : isLoading ? ( diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index 64c8510..9306ec3 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -2645,7 +2645,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); @@ -2815,6 +2831,8 @@ function CanvasPage({ originTop: event.clientY, sourcePort: connectorDrag.port, }); + setPendingLinkPort(null); + setPendingLinkPreviewPoint(null); } } else { clearPendingConnector(); @@ -2839,7 +2857,7 @@ function CanvasPage({ }, [selectedNode]); const handleCanvasMouseMove = (event: MouseEvent) => { - if (!pendingLinkPort) return; + if (!pendingLinkPort || connectionDropMenu) return; setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY)); }; @@ -3717,6 +3735,9 @@ function CanvasPage({ @@ -5557,8 +5576,6 @@ function CanvasPage({ const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop); pendingAutoConnectRef.current = connectionDropMenu.sourcePort; addImageNode("", "图片节点", pos); - setPendingLinkPort(null); - setPendingLinkPreviewPoint(null); setConnectionDropMenu(null); }} > @@ -5574,8 +5591,6 @@ function CanvasPage({ const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop); pendingAutoConnectRef.current = connectionDropMenu.sourcePort; addVideoNode(pos); - setPendingLinkPort(null); - setPendingLinkPreviewPoint(null); setConnectionDropMenu(null); }} > diff --git a/src/features/community/CommunityPage.tsx b/src/features/community/CommunityPage.tsx index 4770c90..bcc738c 100644 --- a/src/features/community/CommunityPage.tsx +++ b/src/features/community/CommunityPage.tsx @@ -16,10 +16,10 @@ import WorkspacePageShell from "../../components/WorkspacePageShell"; import OptimizedImage from "../../components/OptimizedImage"; import { EmptyState } from "../../components/EmptyState"; import { cloneWorkflow, createBlankWorkflow } from "../../data/workflows"; +import { ossAssets } from "../../data/ossAssets"; import type { WebCanvasWorkflow, WebProjectSummary } from "../../types"; import { getCommunityCaseCover, getWorkflowFromCase, shouldShowInCanvasCommunity } from "./communityCaseUtils"; import { ossThumb } from "../../utils/ossImageOptimize"; -const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban"; interface CommunityPageProps { projects: WebProjectSummary[]; @@ -31,23 +31,12 @@ interface CommunityPageProps { onRequireLogin?: (action: string) => boolean | void; } -const communityCardImages = [ - `${OSS_MUBAN}/dianshang1.png`, - `${OSS_MUBAN}/dianshang2.png`, - `${OSS_MUBAN}/dianshang3.png`, - `${OSS_MUBAN}/wechat-7.png`, - `${OSS_MUBAN}/wechat-8.png`, - `${OSS_MUBAN}/wechat-9.png`, -]; +const communityCardImages = ossAssets.community.cardImages; const SLIDE_INTERVAL = 3000; const CAROUSEL_VISIBLE_COUNT = 3; const MANUAL_PAUSE_DURATION = 2000; -const COMMUNITY_CAROUSEL_VIDEOS = [ - "https://stringtest.oss-cn-hangzhou.aliyuncs.com/test3.mp4", - "https://stringtest.oss-cn-hangzhou.aliyuncs.com/test4.mp4", - "https://stringtest.oss-cn-hangzhou.aliyuncs.com/test6.mp4", -]; +const COMMUNITY_CAROUSEL_VIDEOS = ossAssets.community.carouselVideos; function buildWorkflowFromServerCase(item: ServerCommunityCase, fallback: WebCanvasWorkflow): WebCanvasWorkflow { const workflow = getWorkflowFromCase(item); 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/digital-human/DigitalHumanPage.tsx b/src/features/digital-human/DigitalHumanPage.tsx index 8db7a91..20092dd 100644 --- a/src/features/digital-human/DigitalHumanPage.tsx +++ b/src/features/digital-human/DigitalHumanPage.tsx @@ -114,12 +114,12 @@ function DigitalHumanPage({ keepaliveRestoredRef.current = true; const saved = loadToolTaskState("digital-human"); if (!saved || saved.resultUrl) return; - setIsProcessing(true); + setIsCreating(true); cancelRef.current = false; pollRunRef.current += 1; setActiveTaskId(saved.taskId); void waitForTaskResult(saved.taskId).catch(() => {}); - setStatus("正在恢复数字人任务..."); + setNotice("正在恢复数字人任务..."); }, []); useEffect(() => { @@ -567,7 +567,17 @@ function DigitalHumanPage({ )} {resultVideoUrl && ( -
+ + )} + {resultVideoUrl && ( +
-
)}
diff --git a/src/features/ecommerce/EcommerceVideoWorkspace.tsx b/src/features/ecommerce/EcommerceVideoWorkspace.tsx index e173c28..e476d1a 100644 --- a/src/features/ecommerce/EcommerceVideoWorkspace.tsx +++ b/src/features/ecommerce/EcommerceVideoWorkspace.tsx @@ -1,20 +1,22 @@ -import { Fragment, useCallback, useEffect, useRef, useState } from "react"; +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, type EcommerceVideoStage, type EcommerceVideoSceneTask, + type EcommerceVideoPlanProgress, type EcommerceVideoPlanResult, type PlanStep, } from "./ecommerceVideoTypes"; @@ -22,6 +24,7 @@ import type { AdVideoUserConfig } from "../../api/adVideoPlanClient"; import { ServerRequestError } from "../../api/serverConnection"; import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions"; import { useAppStore } from "../../stores"; +import { useGenerationTasks } from "../../hooks/useGenerationTasks"; import { saveEcommerceVideoState, loadEcommerceVideoState, @@ -37,6 +40,8 @@ interface EcommerceVideoWorkspaceProps { durationSeconds: number; resolution: string; onRequestLogin?: () => void; + onOpenHistory?: () => void; + triggerPlan?: number; } const ALL_STEPS: PlanStep[] = [ @@ -44,10 +49,51 @@ const ALL_STEPS: PlanStep[] = [ "creative", "storyboard", "prompts", "compliance", ]; +function hashString(value: string): string { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +} + +function buildInputFingerprint(input: { + productImageDataUrls: string[]; + requirement: string; + platform: string; + aspectRatio: string; + durationSeconds: number; + resolution: string; +}): string { + const imageCount = input.productImageDataUrls.length; + return hashString([ + String(imageCount), + input.requirement.trim(), + input.platform, + input.aspectRatio, + input.durationSeconds, + input.resolution, + ].join("::")); +} + function mapResolutionToQuality(res: string): "720P" | "1080P" { return res.includes("720") ? "720P" : "1080P"; } +function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress): boolean { + switch (step) { + case "upload": return Boolean(p.imageUrls?.length); + case "analyze": return p.imageDescription !== undefined; + case "summary": return Boolean(p.summary); + case "selling": return Boolean(p.selling); + case "creative": return Boolean(p.creatives?.length); + case "storyboard": return Boolean(p.storyboard); + case "prompts": return Boolean(p.videoPrompts); + case "compliance": return Boolean(p.compliance); + } +} + export default function EcommerceVideoWorkspace({ isAuthenticated, productImageDataUrls, @@ -57,41 +103,92 @@ export default function EcommerceVideoWorkspace({ durationSeconds, resolution, onRequestLogin, + onOpenHistory, + triggerPlan, }: EcommerceVideoWorkspaceProps) { const [stage, setStage] = useState("idle"); const [planResult, setPlanResult] = useState(null); + const [planProgress, setPlanProgress] = useState(null); const [scenes, setScenes] = useState([]); const [completedSteps, setCompletedSteps] = useState([]); const [sourceImageUrls, setSourceImageUrls] = useState([]); const [currentStep, setCurrentStep] = useState(null); + 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); - const keepaliveRestoredRef = useRef(false); + const keepaliveRestoredFingerprintRef = useRef(null); const keepalivePollingStartedRef = useRef(false); + const generation = useGenerationTasks({ sourceView: "ecommerce" }); + const sceneStoreIdMap = useRef>(new Map()); + const inputFingerprint = useMemo( + () => buildInputFingerprint({ productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution }), + [productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution], + ); // ── Keep-alive: restore saved state on mount ───────────── useEffect(() => { - if (keepaliveRestoredRef.current) return; - keepaliveRestoredRef.current = true; - const saved = loadEcommerceVideoState(); + if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return; + keepaliveRestoredFingerprintRef.current = inputFingerprint; + const saved = loadEcommerceVideoState(inputFingerprint); if (!saved) return; if (saved.stage === "idle" || saved.stage === "cancelled") return; // Restore completed / in-progress states — results persist across page switches setStage(saved.stage); setCompletedSteps(saved.completedSteps || []); setPlanResult(saved.planResult); + setPlanProgress((saved as { planProgress?: EcommerceVideoPlanProgress | null }).planProgress || null); setScenes(saved.scenes || []); setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []); - }, []); + }, [inputFingerprint]); // ── Keep-alive: save state on changes ─────────────────── useEffect(() => { if (stage === "idle" || stage === "cancelled") return; - saveEcommerceVideoState({ stage, completedSteps, planResult, scenes, sourceImageUrls }); - }, [stage, completedSteps, planResult, scenes, sourceImageUrls]); + saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls }); + }, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]); + + // ── Auto-advance: automatically run the full pipeline ───────── + useEffect(() => { + const delay = 600; + if (stage === "planned" && planResult && scenes.length > 0) { + const timer = setTimeout(() => { void handleGenerateImages(); }, delay); + return () => clearTimeout(timer); + } + if (stage === "imaged" && scenes.every((s) => s.imageUrl)) { + const timer = setTimeout(() => { void handleRenderVideos(); }, delay); + return () => clearTimeout(timer); + } + }, [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(() => { @@ -253,40 +350,89 @@ export default function EcommerceVideoWorkspace({ // ── Phase 1: Planning ────────────────────────────────────── - const handlePlan = async () => { - if (!isAuthenticated) { onRequestLogin?.(); return; } - if (!productImageDataUrls.length && !requirement.trim()) { - setError("请先上传产品图片或填写商品说明"); return; - } + const runPlanFlow = async (resume: EcommerceVideoPlanProgress | null) => { abortControllerRef.current?.abort(); const controller = new AbortController(); abortControllerRef.current = controller; - setStage("planning"); setError(null); - setCompletedSteps([]); setCurrentStep(null); - setPlanResult(null); setScenes([]); setSourceImageUrls([]); + setStage("planning"); setError(null); setFailedStep(null); + if (!resume) { + setCompletedSteps([]); setPlanResult(null); setScenes([]); setSourceImageUrls([]); setPlanProgress(null); + } + setCurrentStep(null); + // Mutable snapshot — async handlers must persist to localStorage directly since the component may unmount + let livePlanProgress: EcommerceVideoPlanProgress = resume ? { ...resume } : {}; + let liveCompletedSteps: PlanStep[] = resume + ? ALL_STEPS.filter((s) => stepCompletedFromProgress(s, resume)) + : []; + const persist = (stageNow: EcommerceVideoStage) => { + saveEcommerceVideoState({ + inputFingerprint, + stage: stageNow, + completedSteps: liveCompletedSteps, + planResult: null, + planProgress: livePlanProgress, + scenes: [], + sourceImageUrls: livePlanProgress.imageUrls || [], + }); + }; try { const result = await runVideoPlan( productImageDataUrls, requirement, buildConfig(), { onStepStart: (step) => setCurrentStep(step), - onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]), - onImagesUploaded: (urls) => { setSourceImageUrls(urls); saveEcommerceVideoState({ stage: "planning", completedSteps: ["upload"], planResult: null, scenes: [], sourceImageUrls: urls }); }, + onStepDone: (step) => { + liveCompletedSteps = [...liveCompletedSteps, step]; + setCompletedSteps((prev) => [...prev, step]); + }, + onImagesUploaded: (urls) => { + setSourceImageUrls(urls); + livePlanProgress = { ...livePlanProgress, imageUrls: urls }; + persist("planning"); + }, + onUploadRejected: (messages) => { + if (messages.length) showNotice(`已跳过 ${messages.length} 张上传失败的图片`); + }, + onPartialProgress: (progress) => { + livePlanProgress = progress; + setPlanProgress(progress); + persist("planning"); + }, + resumeFrom: resume || undefined, signal: controller.signal, }, ); const builtScenes = buildSceneTasks(result); setPlanResult(result); + setPlanProgress(null); setScenes(builtScenes); setStage("planned"); - // Persist immediately — component may be unmounted by the time React re-renders - saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, scenes: builtScenes, sourceImageUrls: result.imageUrls }); + saveEcommerceVideoState({ inputFingerprint, stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, scenes: builtScenes, sourceImageUrls: result.imageUrls }); } catch (err) { - if ((err as Error).name === "AbortError") return; - setError(err instanceof Error ? err.message : "策划失败"); + if ((err as Error).name === "AbortError" && controller.signal.aborted) return; + const message = err instanceof Error ? err.message : "策划失败"; + setError(message); + // Mark the step that was in-progress as failed so user can resume + setFailedStep((prev) => prev || currentStep); setStage("idle"); + // Persist partial progress so the user can resume after a page switch + persist("idle"); } finally { setCurrentStep(null); } }; + const handlePlan = async () => { + if (!isAuthenticated) { onRequestLogin?.(); return; } + if (!productImageDataUrls.length && !requirement.trim()) { + setError("请先上传产品图片或填写商品说明"); return; + } + await runPlanFlow(null); + }; + + const handleResumePlan = async () => { + if (!isAuthenticated) { onRequestLogin?.(); return; } + if (!planProgress) { void handlePlan(); return; } + await runPlanFlow(planProgress); + }; + // ── Phase 2: Image generation per scene ────────────────────── const handleGenerateImages = async () => { @@ -300,19 +446,34 @@ export default function EcommerceVideoWorkspace({ const persistScenes = (next: EcommerceVideoSceneTask[]) => { currentScenes = next; setScenes(next); - saveEcommerceVideoState({ stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls }); + saveEcommerceVideoState({ inputFingerprint, stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls }); }; - for (const scene of currentScenes) { + // Only redo scenes missing imageUrl — preserves successfully generated images on partial retry + const scenesToProcess = currentScenes.filter((s) => !s.imageUrl); + if (!scenesToProcess.length) { setStage("imaged"); return; } + for (const scene of scenesToProcess) { if (renderAbortRef.current.current) break; - persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s)); + 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)), + onSceneImageSubmitted: (id, taskId) => { + persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)); + const storeId = generation.submitTask({ title: `分镜${id}图片`, type: "image", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "imaging" } }); + sceneStoreIdMap.current.set(id, storeId); + }, onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)), - onSceneImageCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)), - onSceneImageFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)), + onSceneImageCompleted: (id, url) => { + persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)); + const sid = sceneStoreIdMap.current.get(id); + if (sid) generation.markCompleted(sid, url); + }, + onSceneImageFailed: (id, err2) => { + persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)); + const sid = sceneStoreIdMap.current.get(id); + if (sid) generation.markFailed(sid, err2); + }, }, renderAbortRef.current, ); @@ -324,15 +485,14 @@ export default function EcommerceVideoWorkspace({ const allHaveImages = currentScenes.every((s) => s.imageUrl); const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const; setStage(finalStage); - saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls }); + saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls }); }; // ── Phase 3: Video rendering from generated images ────────── const handleRenderVideos = async () => { if (!scenes.length) return; - const firstImage = scenes[0]?.imageUrl; - if (!firstImage) { setError("请先生成分镜图片"); return; } + if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; } setStage("rendering"); setError(null); renderAbortRef.current = { current: false }; const quality = mapResolutionToQuality(resolution); @@ -340,20 +500,35 @@ export default function EcommerceVideoWorkspace({ const persistScenes = (next: EcommerceVideoSceneTask[]) => { currentScenes = next; setScenes(next); - saveEcommerceVideoState({ stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls }); + saveEcommerceVideoState({ inputFingerprint, stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls }); }; - for (const scene of currentScenes) { + // Only render scenes that haven't completed yet — preserves successful videos on partial retry + const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed"); + if (!scenesToProcess.length) { setStage(currentScenes.every((s) => s.status === "completed") ? "completed" : "partial_failed"); return; } + for (const scene of scenesToProcess) { if (renderAbortRef.current.current) break; if (!scene.imageUrl) continue; - persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s)); + 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)), + onSceneSubmitted: (id, taskId) => { + persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)); + const storeId = generation.submitTask({ title: `分镜${id}视频`, type: "video", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "rendering" } }); + sceneStoreIdMap.current.set(id, storeId); + }, onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)), - onSceneCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)), - onSceneFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)), + onSceneCompleted: (id, url) => { + persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)); + const sid = sceneStoreIdMap.current.get(id); + if (sid) generation.markCompleted(sid, url); + }, + onSceneFailed: (id, err2) => { + persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)); + const sid = sceneStoreIdMap.current.get(id); + if (sid) generation.markFailed(sid, err2); + }, }, renderAbortRef.current, ); @@ -369,7 +544,7 @@ export default function EcommerceVideoWorkspace({ const finalStage = allDone ? (hasFailed ? "partial_failed" as const : "completed" as const) : "rendering" as const; setScenes(currentScenes); setStage(finalStage); - saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls }); + saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls }); }; const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); }; @@ -379,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)), @@ -423,27 +598,32 @@ export default function EcommerceVideoWorkspace({
+ {onOpenHistory ? ( + + ) : null} {error ? {error} : null} - {stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? ( - ) : null} - {stage === "planned" ? ( + {stage === "planned" || stage === "imaged" ? ( ) : null} - {stage === "imaged" ? ( + {stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? ( ) : null} {stage === "planning" ? ( - 策划中 + {currentStep ? PLAN_STEP_LABELS[currentStep] : "策划中"} ) : null} {stage === "imaging" ? ( 生成图片中 @@ -463,123 +643,120 @@ 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 ?
- :
待生成
} + + + +
setPreviewMedia({ url: scene.imageUrl!, type: "image" }) : undefined} style={imgReady ? { cursor: "pointer" } : undefined}> + {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 ?
+ + + +
setPreviewMedia({ url: scene.resultUrl!, type: "video" }) : undefined} style={vidReady ? { cursor: "pointer" } : undefined}> + {vidReady ? ( +
- {idx < scenes.length - 1 ? ( - + )} + {vidRunning ? {scene.progress || 0}% : null} + 分镜视频{scene.sceneId} + {vidFailed ? ( + ) : null} -
- ); - })} -
- ) : null} +
+
+ ); + }) : ( +
+
+
+ 分镜策划 + {stage === "planning" ? "策划中..." : "点击一键策划开始"} +
+
+ +
+
+ {stage === "planning" ? : 待生成} +
+ 分镜图 +
+ +
+
+ {stage === "planning" ? : 待生成} +
+ 分镜视频 +
+
+ )} +
)} @@ -594,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 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/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/ecommerceVideoKeepalive.ts b/src/features/ecommerce/ecommerceVideoKeepalive.ts index 8ded519..3697ddc 100644 --- a/src/features/ecommerce/ecommerceVideoKeepalive.ts +++ b/src/features/ecommerce/ecommerceVideoKeepalive.ts @@ -1,6 +1,7 @@ import type { EcommerceVideoStage, EcommerceVideoSceneTask, + EcommerceVideoPlanProgress, EcommerceVideoPlanResult, PlanStep, } from "./ecommerceVideoTypes"; @@ -8,18 +9,22 @@ import type { const KEEPALIVE_KEY = "omniai:ecommerce-video-workspace"; interface EcommerceVideoKeepalive { + inputFingerprint: string; stage: EcommerceVideoStage; completedSteps: PlanStep[]; planResult: EcommerceVideoPlanResult | null; + planProgress?: EcommerceVideoPlanProgress | null; scenes: EcommerceVideoSceneTask[]; sourceImageUrls: string[]; savedAt: number; } export function saveEcommerceVideoState(state: { + inputFingerprint: string; stage: EcommerceVideoStage; completedSteps: PlanStep[]; planResult: EcommerceVideoPlanResult | null; + planProgress?: EcommerceVideoPlanProgress | null; scenes: EcommerceVideoSceneTask[]; sourceImageUrls?: string[]; }): void { @@ -35,7 +40,7 @@ export function saveEcommerceVideoState(state: { } } -export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null { +export function loadEcommerceVideoState(inputFingerprint: string): EcommerceVideoKeepalive | null { try { const raw = window.localStorage.getItem(KEEPALIVE_KEY); if (!raw) return null; @@ -45,6 +50,7 @@ export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null { clearEcommerceVideoState(); return null; } + if (parsed.inputFingerprint !== inputFingerprint) return null; return parsed; } catch { return null; diff --git a/src/features/ecommerce/ecommerceVideoService.ts b/src/features/ecommerce/ecommerceVideoService.ts index bb607fb..7f85cfa 100644 --- a/src/features/ecommerce/ecommerceVideoService.ts +++ b/src/features/ecommerce/ecommerceVideoService.ts @@ -11,7 +11,9 @@ import { import { aiGenerationClient } from "../../api/aiGenerationClient"; import { waitForTask } from "../../api/taskSubscription"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; +import { normalizeEcommerceImageMime } from "./ecommerceImageValidation"; import type { + EcommerceVideoPlanProgress, EcommerceVideoPlanResult, EcommerceVideoSceneTask, PlanStep, @@ -21,72 +23,136 @@ export interface PlanCallbacks { onStepStart: (step: PlanStep) => void; onStepDone: (step: PlanStep) => void; onImagesUploaded?: (urls: string[]) => void; + onUploadRejected?: (messages: string[]) => void; + onPartialProgress?: (progress: EcommerceVideoPlanProgress) => void; signal?: AbortSignal; + /** Partial state from a previous run; steps with existing data are skipped. */ + resumeFrom?: EcommerceVideoPlanProgress; } +/** + * Run the full ad video planning pipeline. + * Supports resumption: if `resumeFrom` contains data for a step, that step is skipped. + * After each step, `onPartialProgress` fires so callers can persist intermediate state. + */ export async function runVideoPlan( imageDataUrls: string[], manualText: string, config: AdVideoUserConfig, callbacks: PlanCallbacks, ): Promise { - const { onStepStart, onStepDone, signal } = callbacks; + const { onStepStart, onStepDone, signal, resumeFrom = {} } = callbacks; + const progress: EcommerceVideoPlanProgress = { ...resumeFrom }; + const emit = () => callbacks.onPartialProgress?.({ ...progress }); - onStepStart("upload"); - const imageUrls: string[] = []; - const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); - for (const srcUrl of imageDataUrls) { - try { - const resp = await fetch(srcUrl); - const rawBlob = await resp.blob(); - const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png"; - const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType }); - const result = await aiGenerationClient.uploadAssetBinary(blob, { mimeType, scope: "ecommerce-product" }); - imageUrls.push(result.url); - } catch { - // skip images that fail to upload + // ── Step: upload ────────────────────────────────────── + if (!progress.imageUrls?.length) { + onStepStart("upload"); + const imageUrls: string[] = []; + const rejected: string[] = []; + for (const srcUrl of imageDataUrls) { + try { + const resp = await fetch(srcUrl); + const rawBlob = await resp.blob(); + const mimeType = normalizeEcommerceImageMime(rawBlob.type); + const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType }); + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("文件读取失败")); + reader.readAsDataURL(blob); + }); + const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" }); + imageUrls.push(result.url); + } catch (err) { + rejected.push(err instanceof Error ? err.message : "图片上传失败"); + } } + if (rejected.length) { + progress.uploadWarnings = rejected; + callbacks.onUploadRejected?.(rejected); + } + if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试"); + progress.imageUrls = imageUrls; + onStepDone("upload"); + callbacks.onImagesUploaded?.(imageUrls); + emit(); } - if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试"); - onStepDone("upload"); - callbacks.onImagesUploaded?.(imageUrls); - onStepStart("analyze"); - const imageDesc = await analyzeProductImages(imageUrls, signal); - onStepDone("analyze"); + // ── Step: analyze ───────────────────────────────────── + if (progress.imageDescription === undefined) { + onStepStart("analyze"); + progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal); + onStepDone("analyze"); + emit(); + } - onStepStart("summary"); - const summary = await buildProductSummary(imageDesc, manualText, signal); - onStepDone("summary"); + // ── Step: summary ───────────────────────────────────── + if (!progress.summary) { + onStepStart("summary"); + progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal); + onStepDone("summary"); + emit(); + } - onStepStart("selling"); - const selling = await extractSellingPoints(summary, signal); - onStepDone("selling"); + // ── Step: selling ───────────────────────────────────── + if (!progress.selling) { + onStepStart("selling"); + progress.selling = await extractSellingPoints(progress.summary, signal); + onStepDone("selling"); + emit(); + } - onStepStart("creative"); - const creatives = await generateCreativeOptions(selling, config, signal); - if (!creatives.length) throw new Error("未能生成有效的广告创意"); - onStepDone("creative"); + // ── Step: creative ──────────────────────────────────── + if (!progress.creatives?.length) { + onStepStart("creative"); + progress.creatives = await generateCreativeOptions(progress.selling, config, signal); + if (!progress.creatives.length) throw new Error("未能生成有效的广告创意"); + onStepDone("creative"); + emit(); + } - onStepStart("storyboard"); - const storyboard = await generateStoryboard(creatives[0], summary, config, signal); - onStepDone("storyboard"); + // ── Step: storyboard ────────────────────────────────── + if (!progress.storyboard) { + onStepStart("storyboard"); + progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal); + onStepDone("storyboard"); + emit(); + } - onStepStart("prompts"); - const videoPrompts = await generateVideoPrompts(storyboard, summary, signal); - onStepDone("prompts"); + // ── Step: prompts ───────────────────────────────────── + if (!progress.videoPrompts) { + onStepStart("prompts"); + progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal); + onStepDone("prompts"); + emit(); + } - onStepStart("compliance"); - const compliance = await checkCompliance(summary, selling, storyboard, signal); - onStepDone("compliance"); + // ── Step: compliance ────────────────────────────────── + if (!progress.compliance) { + onStepStart("compliance"); + progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal); + onStepDone("compliance"); + emit(); + } - return { imageUrls, summary, selling, creatives, storyboard, videoPrompts, compliance }; + return { + imageUrls: progress.imageUrls!, + imageDescription: progress.imageDescription, + summary: progress.summary!, + selling: progress.selling!, + creatives: progress.creatives!, + storyboard: progress.storyboard!, + videoPrompts: progress.videoPrompts!, + compliance: progress.compliance!, + }; } export interface RenderSceneImageInput { sceneId: number; prompt: string; aspectRatio: string; + productImageUrls: string[]; } export interface RenderImageCallbacks { @@ -106,6 +172,7 @@ export async function renderSceneImage( prompt: input.prompt, ratio: input.aspectRatio, quality: "2K", + referenceUrls: input.productImageUrls, }); callbacks.onSceneImageSubmitted(input.sceneId, taskId); @@ -127,6 +194,7 @@ export interface RenderSceneInput { prompt: string; durationSeconds: number; imageUrl: string; + productImageUrls: string[]; aspectRatio: string; resolution: string; model?: string; @@ -144,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({ @@ -157,7 +226,7 @@ export async function renderScene( quality: input.resolution, resolution: input.resolution, frameMode: "start-end", - referenceUrls: [input.imageUrl], + referenceUrls: allReferenceUrls, hasReferenceVideo: false, }); @@ -189,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/ecommerceVideoTypes.ts b/src/features/ecommerce/ecommerceVideoTypes.ts index 445c7e0..2ac8ec3 100644 --- a/src/features/ecommerce/ecommerceVideoTypes.ts +++ b/src/features/ecommerce/ecommerceVideoTypes.ts @@ -36,6 +36,7 @@ export interface EcommerceVideoSceneTask { export interface EcommerceVideoPlanResult { imageUrls: string[]; + imageDescription?: string; summary: ProductSummary; selling: SellingPointResult; creatives: CreativeOption[]; @@ -44,6 +45,19 @@ export interface EcommerceVideoPlanResult { compliance: ComplianceCheck; } +/** Partial plan state — used as resume input when an earlier run failed mid-flow. */ +export interface EcommerceVideoPlanProgress { + imageUrls?: string[]; + imageDescription?: string; + uploadWarnings?: string[]; + summary?: ProductSummary; + selling?: SellingPointResult; + creatives?: CreativeOption[]; + storyboard?: Storyboard; + videoPrompts?: VideoPrompt[]; + compliance?: ComplianceCheck; +} + export interface EcommerceVideoDelivery { planResult: EcommerceVideoPlanResult | null; scenes: EcommerceVideoSceneTask[]; diff --git a/src/features/ecommerce/panels/EcommerceClonePanel.tsx b/src/features/ecommerce/panels/EcommerceClonePanel.tsx new file mode 100644 index 0000000..a732d87 --- /dev/null +++ b/src/features/ecommerce/panels/EcommerceClonePanel.tsx @@ -0,0 +1,752 @@ +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 ( +
+