From 56dabf1f7d89d490f3d387b97a3092802c067ba7 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Wed, 3 Jun 2026 12:16:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E7=94=B5=E5=95=86=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E7=94=9F=E6=88=90=E9=93=BE=E8=B7=AF=E7=A8=B3=E5=AE=9A=E6=80=A7?= =?UTF-8?q?=20=E2=80=94=20AI=E8=B6=85=E6=97=B6/=E9=87=8D=E8=AF=95/?= =?UTF-8?q?=E6=96=AD=E7=82=B9=E7=BB=AD=E4=BC=A0=20+=20404=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=20+=20DashScope=20Key=E7=A7=BB=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adVideoPlanClient: 模型级联降级(qwen-max→plus→turbo), 5xx/网络错误可重试, 超时延长至180s, 错误信息包含上游响应体 - 服务端ai/chat: 超时60s→120s, AbortError返回504(非500), PM2已热重载 - EcommerceVideoWorkspace: 策划失败后支持从断点继续(保留已完成步骤的中间产物), 分镜图/视频生成仅重做失败场景 - scriptEvalClient: 移除客户端DASHSCOPE_API_KEY引用(Nginx代理注入) - NotFoundPage: 未知路由显示404页面(替代兜底跳首页) Co-Authored-By: Claude Opus 4.7 --- src/App.tsx | 13 +- src/api/adVideoPlanClient.ts | 110 +++++++++------ src/api/scriptEvalClient.ts | 6 - src/components/NotFoundPage.tsx | 24 ++++ .../ecommerce/EcommerceVideoWorkspace.tsx | 131 +++++++++++++---- .../ecommerce/ecommerceVideoKeepalive.ts | 3 + .../ecommerce/ecommerceVideoService.ts | 133 ++++++++++++------ src/features/ecommerce/ecommerceVideoTypes.ts | 13 ++ src/styles/index.css | 1 + src/styles/pages/not-found.css | 56 ++++++++ src/types.ts | 3 +- 11 files changed, 373 insertions(+), 120 deletions(-) create mode 100644 src/components/NotFoundPage.tsx create mode 100644 src/styles/pages/not-found.css diff --git a/src/App.tsx b/src/App.tsx index f301622..353238b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import { import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway"; import { translateTaskError } from "./utils/translateTaskError"; import AppShell from "./components/AppShell"; +const NotFoundPage = lazy(() => import("./components/NotFoundPage")); import { cloneWorkflow, createBlankWorkflow } from "./data/workflows"; const AgentPage = lazy(() => import("./features/agent/AgentPage")); const AssetsPage = lazy(() => import("./features/assets/AssetsPage")); @@ -115,9 +116,10 @@ const VIEW_KEYS = new Set([ "communityCaseAdd", "report", "providerHealth", + "not-found", ]); -const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more"]); +const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "not-found"]); function normalizeViewKey(rawView: string): WebViewKey { const normalized = @@ -130,7 +132,7 @@ function normalizeViewKey(rawView: string): WebViewKey { : rawView === "community-case-add" ? "communityCaseAdd" : rawView; - return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "home"; + return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found"; } function readViewFromHash(): WebViewKey { @@ -146,7 +148,8 @@ function isWorkspaceView(view: WebViewKey): boolean { view !== "ecommerceHub" && view !== "ecommerce" && view !== "scriptTokens" && - view !== "login" + view !== "login" && + view !== "not-found" ); } @@ -1178,7 +1181,6 @@ function App() { /> ); case "home": - default: return ( handleSetView("workbench")} @@ -1190,6 +1192,9 @@ function App() { onOpenImageTool={handleOpenImageWorkbenchTool} /> ); + case "not-found": + default: + return handleSetView("home")} />; } })(); diff --git a/src/api/adVideoPlanClient.ts b/src/api/adVideoPlanClient.ts index 2bae189..f767d95 100644 --- a/src/api/adVideoPlanClient.ts +++ b/src/api/adVideoPlanClient.ts @@ -1,8 +1,7 @@ import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; -const TEXT_MODEL = "qwen-max"; -const VISION_MODEL = "qwen3.7-plus"; -const VISION_FALLBACK_MODEL = "qwen-vl-plus"; +const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"]; +const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"]; export interface AdVideoUserConfig { platform: string; @@ -110,27 +109,41 @@ interface ChatMessage { const MAX_RETRIES = 3; const RETRY_BASE_MS = 2000; -const CHAT_TIMEOUT_MS = 120_000; // 2 minutes per AI call +const CHAT_TIMEOUT_MS = 180_000; // 3 minutes per AI call (server times out at 120s + network slack) +// 5xx, 429, network failures, timeouts, and AbortError-from-timeout are all retryable function isTransientError(err: unknown): boolean { if (!(err instanceof Error)) return false; const msg = err.message.toLowerCase(); - return /\b429\b/.test(msg) || msg.includes("signal timed out") || msg.includes("aborted") || msg.includes("timeout"); + if (/\b(429|500|502|503|504|520|521|522|524)\b/.test(msg)) return true; + if (msg.includes("signal timed out") || msg.includes("timeout")) return true; + if (msg.includes("failed to fetch") || msg.includes("networkerror") || msg.includes("network error")) return true; + if (msg.includes("ai 调用失败") || msg.includes("图片理解调用失败")) return true; // generic upstream failures + return false; } async function retryOnTransient(fn: () => Promise, signal?: AbortSignal): Promise { + let lastErr: unknown; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { return await fn(); } catch (err) { + lastErr = err; if (signal?.aborted) throw err; + // External AbortError caused by our timeoutSignal — retryable + if (err instanceof Error && err.name === "AbortError" && !signal?.aborted) { + if (attempt === MAX_RETRIES) throw err; + const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000; + await new Promise((r) => setTimeout(r, delay)); + continue; + } if (attempt === MAX_RETRIES) throw err; if (!isTransientError(err)) throw err; const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000; await new Promise((r) => setTimeout(r, delay)); } } - throw new Error("unreachable"); + throw lastErr instanceof Error ? lastErr : new Error("AI 调用失败:已重试多次"); } async function chat( @@ -138,33 +151,45 @@ async function chat( userContent: string, options?: { model?: string; signal?: AbortSignal }, ): Promise { - return retryOnTransient(async () => { - const messages: ChatMessage[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: userContent }, - ]; - const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); - const combinedSignal = options?.signal - ? AbortSignal.any([options.signal, timeoutSignal]) - : timeoutSignal; - const res = await fetch(buildApiUrl("ai/chat"), { - method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify({ - model: options?.model ?? TEXT_MODEL, - messages, - stream: false, - temperature: 0.4, - }), - signal: combinedSignal, - }); - if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`); - const payload = await res.json(); - const content: string = - payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; - if (!content) throw new Error("模型未返回有效内容"); - return content; - }, options?.signal); + const candidateModels = options?.model ? [options.model] : TEXT_MODELS; + let lastError: Error | null = null; + + for (const model of candidateModels) { + try { + return await retryOnTransient(async () => { + const messages: ChatMessage[] = [ + { role: "system", content: systemPrompt }, + { role: "user", content: userContent }, + ]; + const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); + const combinedSignal = options?.signal + ? AbortSignal.any([options.signal, timeoutSignal]) + : timeoutSignal; + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ model, messages, stream: false, temperature: 0.4 }), + signal: combinedSignal, + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`); + } + const payload = await res.json(); + const content: string = + payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + if (!content) throw new Error("模型未返回有效内容"); + return content; + }, options?.signal); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (options?.signal?.aborted) throw lastError; + // If user pinned a specific model, don't fall back to others + if (options?.model) throw lastError; + // Try next model in fallback chain + } + } + throw lastError ?? new Error("所有候选模型均不可用"); } async function visionChat( @@ -182,7 +207,8 @@ async function visionChat( { role: "user", content }, ]; - for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) { + let lastError: Error | null = null; + for (const model of VISION_MODELS) { const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) @@ -197,8 +223,8 @@ async function visionChat( }); if (!res.ok) { const errBody = await res.text().catch(() => ""); - if (model === VISION_MODEL && errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK"); - throw new Error(`图片理解调用失败 (${res.status})`); + if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK"); + throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`); } const payload = await res.json(); const result: string = @@ -208,12 +234,16 @@ async function visionChat( }, signal); return out; } catch (err) { - if (err instanceof Error && err.message === "IMAGE_FORMAT_FALLBACK") continue; - if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue; - throw err; + lastError = err instanceof Error ? err : new Error(String(err)); + if (signal?.aborted) throw lastError; + // Continue trying next vision model on transient failures, image format errors, or upstream errors + if (lastError.message === "IMAGE_FORMAT_FALLBACK") continue; + if (lastError.message.includes("图片理解调用失败")) continue; + if (isTransientError(lastError)) continue; + throw lastError; } } - throw new Error("图片理解调用失败,所有模型均不可用"); + throw lastError ?? new Error("图片理解调用失败,所有模型均不可用"); } const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`; diff --git a/src/api/scriptEvalClient.ts b/src/api/scriptEvalClient.ts index c4f1dc5..0516508 100644 --- a/src/api/scriptEvalClient.ts +++ b/src/api/scriptEvalClient.ts @@ -8,7 +8,6 @@ export interface ScriptEvalResult { suggestions: string[]; } -const DASHSCOPE_API_KEY = import.meta.env.VITE_DASHSCOPE_API_KEY || ""; const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions"; const MODEL = "qwen3.7-max"; @@ -69,15 +68,10 @@ function extractJson(text: string): unknown { } export async function evaluateScript(script: string, signal?: AbortSignal): Promise { - if (!DASHSCOPE_API_KEY) { - throw new Error("DashScope API key 未配置,请在 .env.local 中设置 VITE_DASHSCOPE_API_KEY"); - } - const res = await fetch(DASHSCOPE_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${DASHSCOPE_API_KEY}`, }, body: JSON.stringify({ model: MODEL, 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/features/ecommerce/EcommerceVideoWorkspace.tsx b/src/features/ecommerce/EcommerceVideoWorkspace.tsx index e173c28..65da77d 100644 --- a/src/features/ecommerce/EcommerceVideoWorkspace.tsx +++ b/src/features/ecommerce/EcommerceVideoWorkspace.tsx @@ -15,6 +15,7 @@ import { PLAN_STEPS_DISPLAY, type EcommerceVideoStage, type EcommerceVideoSceneTask, + type EcommerceVideoPlanProgress, type EcommerceVideoPlanResult, type PlanStep, } from "./ecommerceVideoTypes"; @@ -48,6 +49,19 @@ 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, @@ -60,10 +74,12 @@ export default function EcommerceVideoWorkspace({ }: 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 abortControllerRef = useRef(null); @@ -83,6 +99,7 @@ export default function EcommerceVideoWorkspace({ 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 || []); }, []); @@ -90,8 +107,8 @@ export default function EcommerceVideoWorkspace({ // ── 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({ stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls }); + }, [stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]); // ── Keep-alive: resume polling for running tasks ────────── useEffect(() => { @@ -253,40 +270,85 @@ 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({ + 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"); + }, + 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({ 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 () => { @@ -302,9 +364,12 @@ export default function EcommerceVideoWorkspace({ setScenes(next); saveEcommerceVideoState({ 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 }, @@ -331,8 +396,7 @@ export default function EcommerceVideoWorkspace({ 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); @@ -342,10 +406,13 @@ export default function EcommerceVideoWorkspace({ setScenes(next); saveEcommerceVideoState({ 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 }, @@ -424,26 +491,32 @@ export default function EcommerceVideoWorkspace({
{error ? {error} : null} + {stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? ( + + ) : 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" ? ( 生成图片中 diff --git a/src/features/ecommerce/ecommerceVideoKeepalive.ts b/src/features/ecommerce/ecommerceVideoKeepalive.ts index 8ded519..ba27d0a 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"; @@ -11,6 +12,7 @@ interface EcommerceVideoKeepalive { stage: EcommerceVideoStage; completedSteps: PlanStep[]; planResult: EcommerceVideoPlanResult | null; + planProgress?: EcommerceVideoPlanProgress | null; scenes: EcommerceVideoSceneTask[]; sourceImageUrls: string[]; savedAt: number; @@ -20,6 +22,7 @@ export function saveEcommerceVideoState(state: { stage: EcommerceVideoStage; completedSteps: PlanStep[]; planResult: EcommerceVideoPlanResult | null; + planProgress?: EcommerceVideoPlanProgress | null; scenes: EcommerceVideoSceneTask[]; sourceImageUrls?: string[]; }): void { diff --git a/src/features/ecommerce/ecommerceVideoService.ts b/src/features/ecommerce/ecommerceVideoService.ts index bb607fb..81703b0 100644 --- a/src/features/ecommerce/ecommerceVideoService.ts +++ b/src/features/ecommerce/ecommerceVideoService.ts @@ -12,6 +12,7 @@ import { aiGenerationClient } from "../../api/aiGenerationClient"; import { waitForTask } from "../../api/taskSubscription"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; import type { + EcommerceVideoPlanProgress, EcommerceVideoPlanResult, EcommerceVideoSceneTask, PlanStep, @@ -21,66 +22,118 @@ export interface PlanCallbacks { onStepStart: (step: PlanStep) => void; onStepDone: (step: PlanStep) => void; onImagesUploaded?: (urls: 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 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 + } } + 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 { diff --git a/src/features/ecommerce/ecommerceVideoTypes.ts b/src/features/ecommerce/ecommerceVideoTypes.ts index 445c7e0..9d8fbd8 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,18 @@ 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; + summary?: ProductSummary; + selling?: SellingPointResult; + creatives?: CreativeOption[]; + storyboard?: Storyboard; + videoPrompts?: VideoPrompt[]; + compliance?: ComplianceCheck; +} + export interface EcommerceVideoDelivery { planResult: EcommerceVideoPlanResult | null; scenes: EcommerceVideoSceneTask[]; diff --git a/src/styles/index.css b/src/styles/index.css index 781f5ab..e46fa73 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -29,6 +29,7 @@ @import "./pages/compliance.css"; @import "./pages/provider-health.css"; @import "./pages/legacy-pages.css"; +@import "./pages/not-found.css"; @import "./components/recharge-modal.css"; @import "./components/dropzone.css"; @import "./components/skeleton.css"; diff --git a/src/styles/pages/not-found.css b/src/styles/pages/not-found.css new file mode 100644 index 0000000..e704183 --- /dev/null +++ b/src/styles/pages/not-found.css @@ -0,0 +1,56 @@ +.not-found-page { + display: flex; + align-items: center; + justify-content: center; + min-height: calc(100vh - 60px); + padding: 48px 24px; + background: var(--app-bg, #0b0b0f); +} + +.not-found-page__content { + text-align: center; + max-width: 420px; +} + +.not-found-page__code { + font-size: 96px; + font-weight: 800; + line-height: 1; + letter-spacing: -0.03em; + color: var(--accent-teal, #2dd4bf); + margin-bottom: 12px; +} + +.not-found-page h1 { + font-size: 22px; + font-weight: 600; + color: var(--text-primary, #f1f5f9); + margin: 0 0 8px; +} + +.not-found-page p { + font-size: 14px; + color: var(--text-secondary, #94a3b8); + margin: 0 0 28px; + line-height: 1.6; +} + +.not-found-page__button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 24px; + border: 1px solid var(--border-default, #334155); + border-radius: 8px; + background: var(--surface-elevated, #1e293b); + color: var(--text-primary, #f1f5f9); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.not-found-page__button:hover { + background: var(--surface-hover, #334155); + border-color: var(--accent-teal, #2dd4bf); +} diff --git a/src/types.ts b/src/types.ts index 5eb1580..4eeaf26 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,7 +26,8 @@ export type WebViewKey = | "communityReview" | "communityCaseAdd" | "report" - | "providerHealth"; + | "providerHealth" + | "not-found"; export type WebImageWorkbenchTool = "workbench" | "inpaint" | "camera"; -- 2.52.0 From f5a75074a4b6080e1fb39850ea33ef5e75c2b5bd Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Wed, 3 Jun 2026 20:19:07 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E9=82=AE=E7=AE=B1=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E9=AA=8C=E8=AF=81=20+=209=E9=A1=B9=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8E=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【认证系统】 - 新增邮箱验证码注册/登录流程 (sendEmailCode / verifyEmail / forgotPassword / resetPassword) - register-email 现在需要验证码 - 服务端新增 email_verification_codes 表 + patch-email-verification.js - App.tsx 登录后 emailVerified 检查提醒 - keyServerClient token 显式传递修复 401 错误 【电商模块】 - 自动推进: 策划完成后自动生成分镜图/视频 - 模特图选项 (性别/年龄/种族/体型/场景) 注入 AI 提示词 - 任务持久化指纹修复 (图片数量替代 blob URL) - 新增「视频换装」入口 (happyhorse-1.0-video-edit) 【剧本评分】 - 新增 .docx/.doc Word 文档支持 (ZIP解压+XML提取) - 历史记录支持点击查看/恢复评测结果 【画布】 - ReactFlow 节点禁止内置拖拽避免冲突 - 连接线拖拽弹窗优化 (预览线不消失, 弹窗跟踪鼠标) 【页面修复】 - 首页轮播图改为 aspect-ratio: 16/9 解决尺寸问题 - 资产库新增悬停删除按钮 - scriptEvalClient 改用服务端 /api/ai/chat 端点 - TokenUsagePage 未登录跳过 API 调用 --- server-patches/patch-email-verification.js | 291 ++++ src/App.tsx | 32 +- src/api/aiGenerationClient.ts | 23 + src/api/keyServerClient.ts | 90 +- src/api/scriptEvalClient.ts | 15 +- src/components/AdminMonitor.tsx | 2 +- src/components/AppShell.tsx | 11 +- src/components/PageTransition.tsx | 1 - .../RechargeModal/RechargeModal.tsx | 76 + src/features/assets/AssetsPage.tsx | 70 +- src/features/canvas/CanvasPage.tsx | 14 +- src/features/ecommerce/EcommercePage.tsx | 1432 ++++------------- .../ecommerce/EcommerceVideoWorkspace.tsx | 121 +- .../ecommerce/ecommerceVideoKeepalive.ts | 5 +- .../ecommerce/ecommerceVideoService.ts | 22 +- src/features/ecommerce/ecommerceVideoTypes.ts | 1 + src/features/profile/ProfilePage.tsx | 122 +- .../script-tokens/ScriptTokensPage.tsx | 108 +- src/features/script-tokens/TokenUsagePage.tsx | 20 +- src/features/workbench/WorkbenchPage.tsx | 4 + src/stores/index.ts | 2 + src/styles/components/recharge-modal.css | 102 ++ src/styles/pages/assets.css | 34 + src/styles/pages/compliance.css | 108 +- src/styles/pages/ecommerce.css | 48 + src/styles/pages/home.css | 6 +- src/styles/shell/app-shell.css | 146 ++ src/styles/themes/dark-green.css | 34 +- src/types.ts | 3 +- src/utils/errorReporting.ts | 3 + 30 files changed, 1697 insertions(+), 1249 deletions(-) create mode 100644 server-patches/patch-email-verification.js diff --git a/server-patches/patch-email-verification.js b/server-patches/patch-email-verification.js new file mode 100644 index 0000000..bfc1967 --- /dev/null +++ b/server-patches/patch-email-verification.js @@ -0,0 +1,291 @@ +const fs = require("fs"); + +// ── Patch 1: context.js ────────────────────────────────────── +const ctxPath = "/opt/omniai-server/src/routes/context.js"; +let ctx = fs.readFileSync(ctxPath, "utf8"); + +const smsMaxLine = "const SMS_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.SMS_CODE_MAX_ATTEMPTS) || 5);"; +const emailConsts = ` +const EMAIL_PURPOSES = new Set(["register", "login", "reset"]); +const EMAIL_CODE_TTL_MINUTES = Math.max(1, Number(process.env.EMAIL_CODE_TTL_MINUTES) || 10); +const EMAIL_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.EMAIL_CODE_COOLDOWN_SECONDS) || 60); +const EMAIL_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.EMAIL_CODE_MAX_ATTEMPTS) || 5);`; + +if (!ctx.includes("EMAIL_PURPOSES")) { + ctx = ctx.replace(smsMaxLine, smsMaxLine + emailConsts); + console.log("[ctx] added EMAIL_PURPOSES"); +} + +const afterConsume = ' await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);\n return true;\n}'; +const emailFuncs = ` +function hashEmailCode(email, code) { + const secret = process.env.EMAIL_CODE_SECRET || process.env.JWT_SECRET || "omniai-dev-email-secret"; + return crypto.createHash("sha256").update(email + ":" + code + ":" + secret).digest("hex"); +} + +async function sendEmailCode(email, code, purpose) { + const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase(); + + if (provider === "smtp") { + const nodemailer = require("nodemailer"); + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT) || 587, + secure: process.env.SMTP_SECURE === "1", + auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, + }); + + const purposeText = purpose === "register" ? "\u6ce8\u518c" : purpose === "login" ? "\u767b\u5f55" : "\u91cd\u7f6e\u5bc6\u7801"; + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: email, + subject: "[OmniAI] \u90ae\u7bb1\u9a8c\u8bc1\u7801", + text: "\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a" + code + "\n\u7528\u9014\uff1a" + purposeText + "\n\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f\n\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002", + html: '

OmniAI \u90ae\u7bb1\u9a8c\u8bc1

\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a

' + code + '

\u7528\u9014\uff1a' + purposeText + '

\u6709\u6548\u671f\uff1a' + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + ' \u5206\u949f


\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002

', + }); + return { provider: "smtp" }; + } + + console.log("[email:" + purpose + "] " + email + " verification code: " + code + " (mock provider)"); + return { provider: "mock", devCode: process.env.EMAIL_DEV_RETURN_CODE === "1" ? code : undefined }; +} + +async function consumeEmailCode(email, code, purpose) { + const { rows } = await pool.query( + "SELECT id, code_hash, attempts FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1", + [email, purpose] + ); + const row = rows[0]; + if (!row) return false; + if (Number(row.attempts || 0) >= EMAIL_CODE_MAX_ATTEMPTS) return false; + + const expectedHash = hashEmailCode(email, String(code || "").trim()); + if (row.code_hash !== expectedHash) { + await pool.query("UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1", [row.id]); + return false; + } + await pool.query("UPDATE email_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]); + return true; +}`; + +if (!ctx.includes("hashEmailCode")) { + ctx = ctx.replace(afterConsume, afterConsume + emailFuncs); + console.log("[ctx] added email functions"); +} + +// Update exports +if (!ctx.includes("EMAIL_PURPOSES,")) { + ctx = ctx.replace(" EMAIL_PATTERN,\n SMS_PURPOSES,", " EMAIL_PATTERN,\n EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n SMS_PURPOSES,"); +} +if (!ctx.includes("hashEmailCode,")) { + ctx = ctx.replace(" sendSmsCode,\n createLoginResultForUserId,", " sendSmsCode,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n createLoginResultForUserId,"); +} + +fs.writeFileSync(ctxPath, ctx, "utf8"); +console.log("[ctx] written"); + +// ── Patch 2: auth.js ───────────────────────────────────────── +const authPath = "/opt/omniai-server/src/routes/auth.js"; +let auth = fs.readFileSync(authPath, "utf8"); + +// 2a. Add imports inside context.js destructuring +if (!auth.includes("hashEmailCode,")) { + auth = auth.replace( + '} = require("./context");', + ' EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n} = require("./context");' + ); + console.log("[auth] added imports"); +} + +// 2b. Insert new routes before module.exports +const newRoutes = ` + // ============================================================ + // Email verification routes + // ============================================================ + + router.post("/auth/email/send-code", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const purpose = String(req.body?.purpose || "register"); + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" }); + + if (purpose === "register") { + const inviteOk = await ensureBetaInviteCode(req, res); + if (!inviteOk) return; + } + + try { + const { rows: recentCodes } = await pool.query( + "SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1", + [email, purpose, EMAIL_CODE_COOLDOWN_SECONDS] + ); + if (recentCodes.length > 0) { + return res.status(429).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" }); + } + + if (purpose === "register") { + const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", [email]); + if (existing.length > 0) return res.status(409).json({ error: "\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c" }); + } + + if (purpose === "login" || purpose === "reset") { + const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]); + if (existing.length === 0) return res.status(404).json({ error: "\u8be5\u90ae\u7bb1\u5c1a\u672a\u6ce8\u518c" }); + } + + const code = generateSmsCode(); + const codeHash = hashEmailCode(email, code); + await pool.query( + "INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval)", + [email, purpose, codeHash, EMAIL_CODE_TTL_MINUTES] + ); + + const sendResult = await sendEmailCode(email, code, purpose); + res.json({ + success: true, + provider: sendResult.provider, + ttlSeconds: EMAIL_CODE_TTL_MINUTES * 60, + cooldownSeconds: EMAIL_CODE_COOLDOWN_SECONDS, + ...(sendResult.devCode ? { devCode: sendResult.devCode } : {}), + }); + } catch (error) { + console.error("[auth/email/send-code] failed", error); + res.status(500).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u5931\u8d25" }); + } + }); + + router.post("/auth/email/verify", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const code = String(req.body?.code || "").trim(); + const purpose = String(req.body?.purpose || "register"); + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" }); + if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" }); + + try { + const verified = await consumeEmailCode(email, code, purpose); + if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" }); + if (purpose === "register" || purpose === "login") { + await pool.query("UPDATE users SET email_verified = 1 WHERE LOWER(email) = LOWER($1)", [email]); + } + res.json({ success: true }); + } catch (error) { + console.error("[auth/email/verify] failed", error); + res.status(500).json({ error: "\u9a8c\u8bc1\u5931\u8d25" }); + } + }); + + router.post("/auth/forgot-password", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + + try { + const { rows } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]); + if (rows.length === 0) { + return res.json({ success: true, message: "\u5982\u679c\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c\uff0c\u91cd\u7f6e\u94fe\u63a5\u5df2\u53d1\u9001" }); + } + + const { rows: recentCodes } = await pool.query( + "SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = 'reset' AND created_at > NOW() - ($2::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1", + [email, EMAIL_CODE_COOLDOWN_SECONDS] + ); + if (recentCodes.length > 0) { + return res.status(429).json({ error: "\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" }); + } + + const code = generateSmsCode(); + const codeHash = hashEmailCode(email, code); + await pool.query( + "INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, 'reset', $2, NOW() + ($3::text || ' minutes')::interval)", + [email, codeHash, EMAIL_CODE_TTL_MINUTES] + ); + await sendEmailCode(email, code, "reset"); + res.json({ success: true, message: "\u91cd\u7f6e\u9a8c\u8bc1\u7801\u5df2\u53d1\u9001\u5230\u60a8\u7684\u90ae\u7bb1" }); + } catch (error) { + console.error("[auth/forgot-password] failed", error); + res.status(500).json({ error: "\u53d1\u9001\u5931\u8d25" }); + } + }); + + router.post("/auth/reset-password", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const code = String(req.body?.code || "").trim(); + const newPassword = String(req.body?.newPassword || ""); + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" }); + const passwordError = validatePassword(newPassword); + if (passwordError) return res.status(400).json({ error: passwordError }); + + try { + const verified = await consumeEmailCode(email, code, "reset"); + if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" }); + const hash = await bcrypt.hash(newPassword, 10); + await pool.query("UPDATE users SET password_hash = $1 WHERE LOWER(email) = LOWER($2)", [hash, email]); + res.json({ success: true, message: "\u5bc6\u7801\u91cd\u7f6e\u6210\u529f\uff0c\u8bf7\u91cd\u65b0\u767b\u5f55" }); + } catch (error) { + console.error("[auth/reset-password] failed", error); + res.status(500).json({ error: "\u5bc6\u7801\u91cd\u7f6e\u5931\u8d25" }); + } + }); + +`; + +if (!auth.includes("/auth/email/send-code")) { + const endMarker = "\n}\n\nmodule.exports = {"; + auth = auth.replace(endMarker, "\n" + newRoutes + "}\n\nmodule.exports = {"); + console.log("[auth] added new routes"); +} + +// 2c. Update register-email to require verification code +// Replace: router.post("/auth/register-email" ... without code check +// With: router.post("/auth/register-email" ... with code verification + +const oldRegisterEmail = ` router.post("/auth/register-email", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const usernameInput = String(req.body?.username || "").trim(); + const password = String(req.body?.password || ""); + + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + const passwordError = validatePassword(password); + if (passwordError) return res.status(400).json({ error: passwordError }); + const registrationInvite = await ensureRegistrationInvite(req, res); + if (!registrationInvite) return; + + try { + const { rows: existingEmail }`; + +const newRegisterEmail = ` router.post("/auth/register-email", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const usernameInput = String(req.body?.username || "").trim(); + const password = String(req.body?.password || ""); + const code = String(req.body?.code || "").trim(); + + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" }); + const passwordError = validatePassword(password); + if (passwordError) return res.status(400).json({ error: passwordError }); + const registrationInvite = await ensureRegistrationInvite(req, res); + if (!registrationInvite) return; + + try { + const verified = await consumeEmailCode(email, code, "register"); + if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" }); + + const { rows: existingEmail }`; + +if (auth.includes(oldRegisterEmail)) { + auth = auth.replace(oldRegisterEmail, newRegisterEmail); + console.log("[auth] updated register-email with verification"); +} else { + console.log("[auth] WARNING: register-email pattern not found, skipping"); +} + +fs.writeFileSync(authPath, auth, "utf8"); +console.log("[auth] written"); +console.log("\nDone."); diff --git a/src/App.tsx b/src/App.tsx index 353238b..062e150 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { reportError } from "./utils/errorReporting"; import { initNotificationPermission } from "./utils/generationNotifier"; import PageTransition from "./components/PageTransition"; import ToastContainer from "./components/toast/ToastContainer"; +import { toast } from "./components/toast/toastStore"; import { aiGenerationClient } from "./api/aiGenerationClient"; import { keyServerClient } from "./api/keyServerClient"; import { notificationClient } from "./api/notificationClient"; @@ -32,8 +33,10 @@ import { } from "./api/serverConnection"; import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway"; import { translateTaskError } from "./utils/translateTaskError"; +import { recoverAndResumeTasks } from "./services/backgroundTaskRunner"; import AppShell from "./components/AppShell"; const NotFoundPage = lazy(() => import("./components/NotFoundPage")); +const CompliancePage = lazy(() => import("./features/compliance/CompliancePage")); import { cloneWorkflow, createBlankWorkflow } from "./data/workflows"; const AgentPage = lazy(() => import("./features/agent/AgentPage")); const AssetsPage = lazy(() => import("./features/assets/AssetsPage")); @@ -56,7 +59,6 @@ const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/Wat const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage")); const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage")); const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage")); -const SettingsPage = lazy(() => import("./features/settings/SettingsPage")); const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage")); import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage"; import { @@ -103,7 +105,6 @@ const VIEW_KEYS = new Set([ "ecommerce", "scriptTokens", "tokenUsage", - "settings", "imageWorkbench", "resolutionUpscale", "watermarkRemoval", @@ -116,17 +117,23 @@ const VIEW_KEYS = new Set([ "communityCaseAdd", "report", "providerHealth", + "userAgreement", + "privacyPolicy", "not-found", ]); -const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "not-found"]); +const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]); function normalizeViewKey(rawView: string): WebViewKey { const normalized = rawView === "profile" || rawView === "auth" ? "login" - : rawView === "ecommerceHub" - ? "ecommerce" + : rawView === "ecommerceHub" + ? "ecommerce" + : rawView === "terms" || rawView === "agreement" || rawView === "user-agreement" + ? "userAgreement" + : rawView === "privacy" || rawView === "privacy-policy" + ? "privacyPolicy" : rawView === "community-review" ? "communityReview" : rawView === "community-case-add" @@ -321,6 +328,11 @@ function App() { } }, []); // eslint-disable-line react-hooks/exhaustive-deps + // ── Recover background tasks on app start ────────── + useEffect(() => { + recoverAndResumeTasks(); + }, []); + const navItems = useMemo( () => [ { key: "home", label: "首页", hint: "项目入口", icon: }, @@ -838,6 +850,10 @@ function App() { setSession(nextSession); await hydrateAccountData(nextSession); + if (nextSession.user.email && !nextSession.user.emailVerified) { + toast.info("邮箱尚未验证,部分功能可能受限,请在登录页通过邮箱验证码完成验证"); + } + const action = pendingAction; closeLoginPrompt(); if (action) { @@ -1112,8 +1128,6 @@ function App() { onSelectView={handleSetView} /> ); - case "settings": - return ; case "imageWorkbench": return ( ; case "providerHealth": return ; + case "userAgreement": + return ; + case "privacyPolicy": + return ; case "communityReview": return ( (res, "Subtitle removal response failed"); }, + async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> { + const res = await fetch(buildApiUrl("ai/video/edit"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ ...input, model: input.model || "happyhorse-1.0-video-edit" }), + }); + if (!res.ok) { + await throwResponseError(res, "Video edit request failed"); + } + return readJsonResponse<{ taskId: string }>(res, "Video edit response failed"); + }, + async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> { const res = await fetch(buildApiUrl("ai/image/super-resolve"), { method: "POST", diff --git a/src/api/keyServerClient.ts b/src/api/keyServerClient.ts index 16b02e0..1acf077 100644 --- a/src/api/keyServerClient.ts +++ b/src/api/keyServerClient.ts @@ -30,9 +30,26 @@ interface EmailAuthInput { email: string; password: string; username?: string; + code?: string; betaCode?: string; } +interface EmailCodeInput { + email: string; + code: string; + purpose?: "register" | "login"; +} + +interface ForgotPasswordInput { + email: string; +} + +interface ResetPasswordInput { + email: string; + code: string; + newPassword: string; +} + interface PhoneAuthInput { phone: string; code: string; @@ -52,6 +69,19 @@ interface DeleteProjectOptions { cleanupUserData?: boolean; } +export interface RechargeOrderInput { + planId: string; + paymentMethod: "wechat" | "alipay" | "bank"; +} + +export interface RechargeOrderResult { + orderId: string; + status: string; + payUrl?: string | null; + qrCodeUrl?: string | null; + message?: string | null; +} + export interface WechatLoginTicket { configured: boolean; url?: string; @@ -624,6 +654,21 @@ function normalizeEnterpriseUsageSummary(payload: unknown): WebEnterpriseUsageSu }; } +function normalizeRechargeOrder(payload: unknown): RechargeOrderResult { + const raw = unwrapApiPayload(payload); + if (!isRecord(raw)) { + return { orderId: `local-${Date.now()}`, status: "pending", message: "订单已提交,请联系客服确认到账。" }; + } + + return { + orderId: toStringValue(raw.orderId ?? raw.order_id ?? raw.id, `local-${Date.now()}`), + status: toStringValue(raw.status, "pending"), + payUrl: toNullableString(raw.payUrl ?? raw.pay_url ?? raw.checkoutUrl ?? raw.checkout_url), + qrCodeUrl: toNullableString(raw.qrCodeUrl ?? raw.qr_code_url ?? raw.qrcodeUrl), + message: toNullableString(raw.message ?? raw.notice), + }; +} + function buildProjectUpsertPayload(workflow: WebCanvasWorkflow, session: WebUserSession): Record { const userId = String(session.user.id).replace(/[^a-zA-Z0-9_-]/g, ""); const projectId = workflow.id.trim(); @@ -714,6 +759,7 @@ export const keyServerClient = { email: input.email.trim(), username: input.username?.trim() || undefined, password: input.password, + code: input.code?.trim() || undefined, betaCode: input.betaCode?.trim() || undefined, }, }), @@ -731,6 +777,30 @@ export const keyServerClient = { body: { phone: phone.trim(), purpose, betaCode: betaCode?.trim() || undefined }, }); }, + async sendEmailCode(email: string, purpose: "login" | "register" | "reset", betaCode?: string): Promise<{ cooldownSeconds?: number; ttlSeconds?: number; devCode?: string }> { + return request<{ cooldownSeconds?: number; ttlSeconds?: number; devCode?: string }>("/auth/email/send-code", { + method: "POST", + body: { email: email.trim(), purpose, betaCode: betaCode?.trim() || undefined }, + }); + }, + async verifyEmail(input: EmailCodeInput): Promise<{ success: boolean }> { + return request<{ success: boolean }>("/auth/email/verify", { + method: "POST", + body: { email: input.email.trim(), code: input.code.trim(), purpose: input.purpose || "register" }, + }); + }, + async forgotPassword(input: ForgotPasswordInput): Promise<{ success: boolean; message?: string }> { + return request<{ success: boolean; message?: string }>("/auth/forgot-password", { + method: "POST", + body: { email: input.email.trim() }, + }); + }, + async resetPassword(input: ResetPasswordInput): Promise<{ success: boolean; message?: string }> { + return request<{ success: boolean; message?: string }>("/auth/reset-password", { + method: "POST", + body: { email: input.email.trim(), code: input.code.trim(), newPassword: input.newPassword }, + }); + }, async loginPhone(input: PhoneAuthInput): Promise { const session = normalizeLoginResult( await request("/auth/login-phone", { @@ -855,13 +925,23 @@ export const keyServerClient = { return normalizeProjectContent(response, projectId); }, async getUsageSummary(): Promise { - return normalizeUsageSummary(await request("/user/usage/summary")); + const stored = readStoredSession(); + return normalizeUsageSummary(await request("/user/usage/summary", { token: stored?.token })); }, async getEnterpriseUsageSummary(): Promise { - return normalizeEnterpriseUsageSummary(await request("/enterprise/usage/summary")); + const stored = readStoredSession(); + return normalizeEnterpriseUsageSummary(await request("/enterprise/usage/summary", { token: stored?.token })); }, async getPersonalUsageSummary(): Promise { - return normalizeEnterpriseUsageSummary(await request("/user/usage/credits")); + const stored = readStoredSession(); + return normalizeEnterpriseUsageSummary(await request("/user/usage/credits", { token: stored?.token })); + }, + async createRechargeOrder(input: RechargeOrderInput): Promise { + const response = await request("/payments/recharge-orders", { + method: "POST", + body: input, + }); + return normalizeRechargeOrder(response); }, async createProjectSpace(workflow: WebCanvasWorkflow): Promise { const stored = readStoredSession(); @@ -929,8 +1009,8 @@ export const keyServerClient = { }); }, - async getClientErrors(page = 1): Promise<{ items: unknown[]; total: number }> { - const data = await request<{ items: unknown[]; total: number }>(`/client-errors?page=${page}`); + async getClientErrors(page = 1): Promise<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }> { + const data = await request<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }>(`/client-errors?page=${page}`); return data; }, }; diff --git a/src/api/scriptEvalClient.ts b/src/api/scriptEvalClient.ts index 0516508..9a8f836 100644 --- a/src/api/scriptEvalClient.ts +++ b/src/api/scriptEvalClient.ts @@ -1,3 +1,5 @@ +import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; + export interface ScriptEvalResult { totalScore: number; grade: string; @@ -8,7 +10,6 @@ export interface ScriptEvalResult { suggestions: string[]; } -const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions"; const MODEL = "qwen3.7-max"; const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。 @@ -68,11 +69,9 @@ function extractJson(text: string): unknown { } export async function evaluateScript(script: string, signal?: AbortSignal): Promise { - const res = await fetch(DASHSCOPE_ENDPOINT, { + const res = await fetch(buildApiUrl("ai/chat"), { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: buildAuthHeaders(), body: JSON.stringify({ model: MODEL, messages: [ @@ -92,11 +91,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom } const payload = await res.json(); - const content: string = payload?.choices?.[0]?.message?.content - ?? payload?.result?.content - ?? payload?.content - ?? payload?.text - ?? (typeof payload === "string" ? payload : ""); + const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; if (!content) throw new Error("模型未返回有效内容"); diff --git a/src/components/AdminMonitor.tsx b/src/components/AdminMonitor.tsx index 4fc41f1..d5a7bbc 100644 --- a/src/components/AdminMonitor.tsx +++ b/src/components/AdminMonitor.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { keyServerClient } from "../api/keyServerClient"; -interface ClientErrorItem { +export interface ClientErrorItem { id: number; message: string; stack?: string; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 3457460..700938b 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -22,6 +22,7 @@ import NotificationCenter from "./NotificationCenter"; import { RechargeModal } from "./RechargeModal/RechargeModal"; import { AnimatedPanel } from "./AnimatedPanel"; import AdminMonitor from "./AdminMonitor"; +import CookieConsentBanner from "./CookieConsentBanner"; interface AppShellProps { activeView: WebViewKey; @@ -40,6 +41,7 @@ interface AppShellProps { } const BRAND_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png"; +const CLIENT_ERROR_MONITOR_ENABLED = import.meta.env.VITE_ENABLE_CLIENT_ERROR_MONITOR === "1"; function formatBalance(cents: number): string { const value = Math.max(0, cents) / 100; @@ -344,8 +346,8 @@ function AppShell({
15155073618
@@ -356,7 +358,7 @@ function AppShell({ onClick={() => setRechargeOpen(true)} > - {displayedBalanceLabel} + {displayedBalanceLabel}
- {session?.user.role === "admin" ? : null} + {CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? : null} setRechargeOpen(false)} currentBalance={displayedBalanceCents} /> + ); } diff --git a/src/components/PageTransition.tsx b/src/components/PageTransition.tsx index 3f6a491..6f66283 100644 --- a/src/components/PageTransition.tsx +++ b/src/components/PageTransition.tsx @@ -27,7 +27,6 @@ const NAV_ORDER: string[] = [ "avatarConsole", "characterMix", "agent", - "settings", "login", "profile", "report", diff --git a/src/components/RechargeModal/RechargeModal.tsx b/src/components/RechargeModal/RechargeModal.tsx index 2254028..95be2eb 100644 --- a/src/components/RechargeModal/RechargeModal.tsx +++ b/src/components/RechargeModal/RechargeModal.tsx @@ -1,7 +1,10 @@ import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons"; import { useMemo, useState, type ReactNode } from "react"; +import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient"; +import { toast } from "../toast/toastStore"; type RechargeAudience = "personal" | "enterprise"; +type PaymentMethod = "wechat" | "alipay" | "bank"; interface MembershipPlan { id: string; @@ -107,6 +110,12 @@ const rechargeRules = [ "退费规则:充值积分到账后不支持退换、折现,仅限平台内消费", ]; +const paymentMethods: Array<{ id: PaymentMethod; label: string; hint: string }> = [ + { id: "wechat", label: "微信支付", hint: "生成支付链接或二维码" }, + { id: "alipay", label: "支付宝", hint: "生成支付链接或二维码" }, + { id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" }, +]; + interface RechargeModalProps { open: boolean; onClose: () => void; @@ -116,14 +125,43 @@ interface RechargeModalProps { export function RechargeModal({ open, onClose, currentBalance }: RechargeModalProps) { const [activeAudience, setActiveAudience] = useState("personal"); const [selectedPlanIds, setSelectedPlanIds] = useState>(defaultSelectedPlanIds); + const [paymentMethod, setPaymentMethod] = useState("wechat"); + const [submitting, setSubmitting] = useState(false); + const [order, setOrder] = useState(null); const visiblePlans = useMemo(() => membershipPlans.filter((plan) => plan.audience === activeAudience), [activeAudience]); const selectedPlanId = selectedPlanIds[activeAudience]; + const selectedPlan = membershipPlans.find((plan) => plan.id === selectedPlanId) ?? visiblePlans[0]; const handlePlanSelect = (plan: MembershipPlan) => { setSelectedPlanIds((current) => ({ ...current, [plan.audience]: plan.id, })); + setOrder(null); + }; + + const handleCreateOrder = async () => { + if (!selectedPlan || submitting) return; + + setSubmitting(true); + try { + const nextOrder = await keyServerClient.createRechargeOrder({ planId: selectedPlan.id, paymentMethod }); + setOrder(nextOrder); + if (nextOrder.payUrl) { + window.open(nextOrder.payUrl, "_blank", "noopener,noreferrer"); + } + toast.success("充值订单已创建"); + } catch (error) { + const message = error instanceof Error ? error.message : "订单创建失败,请联系客服处理。"; + toast.error(message); + setOrder({ + orderId: `support-${Date.now()}`, + status: "manual-review", + message: "支付接口暂不可用,请通过页面联系方式联系客服完成充值。", + }); + } finally { + setSubmitting(false); + } }; if (!open) return null; @@ -224,6 +262,44 @@ export function RechargeModal({ open, onClose, currentBalance }: RechargeModalPr ))} + +
+
+ 支付确认 +

{selectedPlan.name} · {selectedPlan.period}

+

{selectedPlan.price},{selectedPlan.grant}

+
+
+ {paymentMethods.map((method) => ( + + ))} +
+ + {order ? ( +
+ 订单号:{order.orderId} + 状态:{order.status} + {order.qrCodeUrl ? 支付二维码 : null} + {order.payUrl ? 打开支付链接 : null} +

{order.message || "支付完成后积分将自动入账,如长时间未到账请联系客服。"}

+
+ ) : null} +
); diff --git a/src/features/assets/AssetsPage.tsx b/src/features/assets/AssetsPage.tsx index 5c94c07..e3cf85c 100644 --- a/src/features/assets/AssetsPage.tsx +++ b/src/features/assets/AssetsPage.tsx @@ -100,14 +100,14 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) { setContextMenu({ x: e.clientX, y: e.clientY, asset }); }, []); - const handleDeleteAsset = useCallback(async () => { - if (!contextMenu) return; - const { asset } = contextMenu; + const handleDeleteAsset = useCallback(async (asset?: LibraryAssetItem) => { + const target = asset || contextMenu?.asset; + if (!target) return; setContextMenu(null); try { - await assetClient.delete(asset.id); - setServerAssets((prev) => prev.filter((a) => a.id !== asset.id)); - setServerNotice(`已删除 ${asset.name}`); + await assetClient.delete(target.id); + setServerAssets((prev) => prev.filter((a) => a.id !== target.id)); + setServerNotice(`已删除 ${target.name}`); } catch (err) { setServerNotice(err instanceof Error ? err.message : "删除失败"); } @@ -287,32 +287,42 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) { {visibleAssets.length ? (
{visibleAssets.map((asset) => ( - + + +
))} ) : isLoading ? ( diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index eaa6543..68c758c 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -3717,6 +3717,9 @@ function CanvasPage({ event.stopPropagation()} onContextMenu={(event) => event.preventDefault()} + onMouseMove={(event) => { + if (pendingLinkPort) { + setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY)); + } + }} >
新建节点并连接
- - ); - }; - - const renderAdVideoPlan = () => { - if (adVideoStep !== "planned" && adVideoStep !== "rendering") return null; - return ( -
- {adVideoSummary ? ( -
- {adVideoSummary.product_name} · {adVideoSummary.category} -

{adVideoSummary.appearance}

-
- {adVideoSummary.selling_points.map((sp, i) => ( - {sp} - ))} -
-
- ) : null} - {adVideoCreatives[0] ? ( -
- 广告创意:{adVideoCreatives[0].creative_type} -

{adVideoCreatives[0].hook}

-
- ) : null} - {adVideoStoryboard ? ( -
- 分镜:{adVideoStoryboard.video_title} -
- {adVideoStoryboard.scenes.map((scene) => { - const sceneVideo = adVideoScenes.find((s) => s.sceneId === scene.scene_id); - return ( -
-
- 镜头 {scene.scene_id} · {scene.duration} - {sceneVideo ? ( - - {sceneVideo.status === "completed" - ? "完成" - : sceneVideo.status === "failed" - ? "失败" - : sceneVideo.status === "idle" - ? "等待" - : `${sceneVideo.progress}%`} - - ) : null} -
-

{scene.visual_description}

- {sceneVideo?.resultUrl ? ( -
- ); - })} -
-
- ) : null} - {renderAdVideoCompliance()} -
- ); - }; - const handleGenerate = () => { if (!canGenerate) return; + + if ((appUsage?.balanceCents ?? 0) <= 0) { + toast.error("积分不足,请充值后继续"); + return; + } + + if (cloneOutput === "set" && cloneSetTotal > 5) { + if (!window.confirm(`将生成 ${cloneSetTotal} 张图片,可能消耗较多积分,是否继续?`)) return; + } + imageAbortRef.current = { current: false }; - if (cloneOutput === "set") { + lastFailedActionRef.current = null; + if (cloneOutput === "video-outfit") { + void handleVideoOutfitGenerate(); + } else if (cloneOutput === "set") { void generateSetImages( productImages, cloneSetCounts, requirement, platform, ratio, language, market, @@ -1733,32 +1604,39 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { platform, ratio, language, market, (s) => setStatus(s as ProductCloneStatus), setResults, ); + lastFailedActionRef.current = () => handleGenerate(); } }; const handleGenerateModel = () => { imageAbortRef.current = { current: false }; + lastFailedActionRef.current = null; setTryOnStatus("modeling"); void generateEcommerceImage( "model", garmentImages, requirement, platform, ratio, language, market, + { gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene }, (s) => { if (s === "done") setTryOnStatus("ready"); else setTryOnStatus(s as TryOnStatus); }, () => { setTryOnStatus("ready"); }, ); + lastFailedActionRef.current = () => handleGenerateModel(); }; const handleTryOnGenerate = () => { if (!canGenerateTryOn) return; imageAbortRef.current = { current: false }; + lastFailedActionRef.current = null; void generateEcommerceImage( "model", garmentImages, requirement, platform, ratio, language, market, + { gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene }, (s) => setTryOnStatus(s as TryOnStatus), (res) => setTryOnResultImages(res.map((r) => r.src).filter(Boolean)), ); + lastFailedActionRef.current = () => handleTryOnGenerate(); }; const toggleScene = (scene: string) => { @@ -1776,12 +1654,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const handleSetGenerate = () => { if (!canGenerateSet) return; imageAbortRef.current = { current: false }; + lastFailedActionRef.current = null; void generateSetImages( setImages, cloneSetCounts, productSetRequirement, productSetPlatform, productSetRatio, productSetLanguage, productSetMarket, (s) => setProductSetStatus(s as ProductSetStatus), (urls) => setProductSetResultImages(urls), ); + lastFailedActionRef.current = () => handleSetGenerate(); }; const openProductSetPreview = (card: { src: string; label: string }) => { @@ -1797,6 +1677,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const handleDetailGenerate = () => { if (!canGenerateDetail) return; imageAbortRef.current = { current: false }; + lastFailedActionRef.current = null; void generateEcommerceImage( "detail", detailProductImages, detailRequirement, detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket, @@ -1932,858 +1813,166 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ]; const setPanel = ( - <> -
-
-

- 上传商品原图 - -

- - - {setImages.length ? ( -
- {setImages.map((item) => ( -
- {item.name} - - -
- ))} -
- ) : null} -
- -
-

- 生成设置 - -

-
- 生成内容 -
- {productSetOutputOptions.map((option) => ( - - ))} -
-
-
- 基础设置 -
- - - - -
-
-
-
- + ); const clonePanel = ( - <> -
-
- AI - 电商生成 -
- -
-

- - 上传商品原图 -

-
productInputRef.current?.click()} - onKeyDown={(event) => { - if (event.target !== event.currentTarget) return; - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - productInputRef.current?.click(); - } - }} - onDragEnter={(event) => { - event.preventDefault(); - setIsProductUploadDragging(true); - }} - onDragOver={(event) => event.preventDefault()} - onDragLeave={() => setIsProductUploadDragging(false)} - onDrop={handleProductDrop} - > -
- - - - 拖拽或点击上传 - - - 上传图片 - - 同一产品,最多 7 张 -
- {productImages.length ? ( -
- {productImages.map((item) => ( -
- {item.name} - - -
- ))} -
- ) : null} -
- -
- -
-

- - 生成设置 -

-
- 生成内容 -
- {cloneOutputOptions.map((option) => ( - - ))} -
-
-
- 基础设置 -
- {cloneBasicSelects.map((item) => { - const hasMultipleOptions = item.options.length > 1; - const isOpen = hasMultipleOptions && openCloneBasicSelect === item.key; - return ( -
-