feat: 邮箱注册验证 + 9项功能修复与优化

【认证系统】
- 新增邮箱验证码注册/登录流程 (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 调用
This commit is contained in:
2026-06-03 20:19:07 +08:00
parent 56dabf1f7d
commit f5a75074a4
30 changed files with 1697 additions and 1249 deletions
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CopyOutlined,
DownloadOutlined,
@@ -23,6 +23,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,
@@ -45,6 +46,34 @@ 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";
}
@@ -85,14 +114,20 @@ export default function EcommerceVideoWorkspace({
const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false });
const setView = useAppStore((s) => s.setView);
const keepaliveRestoredRef = useRef(false);
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
const keepalivePollingStartedRef = useRef(false);
const generation = useGenerationTasks({ sourceView: "ecommerce" });
const sceneStoreIdMap = useRef<Map<number, string>>(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
@@ -102,13 +137,33 @@ export default function EcommerceVideoWorkspace({
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, planProgress, scenes, sourceImageUrls });
}, [stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
// ── Auto-advance: skip manual "next step" clicks ─────────
const autoAdvanceTriggeredRef = useRef(false);
useEffect(() => {
if (autoAdvanceTriggeredRef.current) return;
const delay = 600;
if (stage === "planned" && planResult && scenes.length > 0) {
autoAdvanceTriggeredRef.current = true;
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
return () => clearTimeout(timer);
}
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
autoAdvanceTriggeredRef.current = true;
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
return () => clearTimeout(timer);
}
if (stage === "idle" || stage === "cancelled") {
autoAdvanceTriggeredRef.current = false;
}
}, [stage, scenes, planResult]);
// ── Keep-alive: resume polling for running tasks ──────────
useEffect(() => {
@@ -286,6 +341,7 @@ export default function EcommerceVideoWorkspace({
: [];
const persist = (stageNow: EcommerceVideoStage) => {
saveEcommerceVideoState({
inputFingerprint,
stage: stageNow,
completedSteps: liveCompletedSteps,
planResult: null,
@@ -308,6 +364,9 @@ export default function EcommerceVideoWorkspace({
livePlanProgress = { ...livePlanProgress, imageUrls: urls };
persist("planning");
},
onUploadRejected: (messages) => {
if (messages.length) showNotice(`已跳过 ${messages.length} 张上传失败的图片`);
},
onPartialProgress: (progress) => {
livePlanProgress = progress;
setPlanProgress(progress);
@@ -322,7 +381,7 @@ export default function EcommerceVideoWorkspace({
setPlanProgress(null);
setScenes(builtScenes);
setStage("planned");
saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, 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" && controller.signal.aborted) return;
const message = err instanceof Error ? err.message : "策划失败";
@@ -362,7 +421,7 @@ 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 });
};
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
@@ -374,10 +433,22 @@ export default function EcommerceVideoWorkspace({
await renderSceneImage(
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
{
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,
);
@@ -389,7 +460,7 @@ 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 ──────────
@@ -404,7 +475,7 @@ 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 });
};
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
@@ -417,10 +488,22 @@ export default function EcommerceVideoWorkspace({
await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, 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,
);
@@ -436,7 +519,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"); };
@@ -9,6 +9,7 @@ import type {
const KEEPALIVE_KEY = "omniai:ecommerce-video-workspace";
interface EcommerceVideoKeepalive {
inputFingerprint: string;
stage: EcommerceVideoStage;
completedSteps: PlanStep[];
planResult: EcommerceVideoPlanResult | null;
@@ -19,6 +20,7 @@ interface EcommerceVideoKeepalive {
}
export function saveEcommerceVideoState(state: {
inputFingerprint: string;
stage: EcommerceVideoStage;
completedSteps: PlanStep[];
planResult: EcommerceVideoPlanResult | null;
@@ -38,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;
@@ -48,6 +50,7 @@ export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null {
clearEcommerceVideoState();
return null;
}
if (parsed.inputFingerprint !== inputFingerprint) return null;
return parsed;
} catch {
return null;
@@ -11,6 +11,7 @@ import {
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
import type {
EcommerceVideoPlanProgress,
EcommerceVideoPlanResult,
@@ -22,6 +23,7 @@ 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. */
@@ -47,19 +49,29 @@ export async function runVideoPlan(
if (!progress.imageUrls?.length) {
onStepStart("upload");
const imageUrls: string[] = [];
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
const rejected: string[] = [];
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 mimeType = normalizeEcommerceImageMime(rawBlob.type);
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const result = await aiGenerationClient.uploadAssetBinary(blob, { mimeType, scope: "ecommerce-product" });
const dataUrl = await new Promise<string>((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 {
// skip images that fail to upload
} 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");
@@ -49,6 +49,7 @@ export interface EcommerceVideoPlanResult {
export interface EcommerceVideoPlanProgress {
imageUrls?: string[];
imageDescription?: string;
uploadWarnings?: string[];
summary?: ProductSummary;
selling?: SellingPointResult;
creatives?: CreativeOption[];