Fix/ecommerce video 400 bug #7

Merged
stringadmin merged 10 commits from fix/ecommerce-video-400-bug into master 2026-06-03 02:47:07 +00:00
35 changed files with 1263 additions and 441 deletions
Showing only changes of commit 468d1d27dd - Show all commits
+8 -61
View File
@@ -42,8 +42,6 @@ const CommunityReviewPage = lazy(() => import("./features/community-review/Commu
const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage")); const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage")); const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage")); const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
const EcommerceTemplatesPage = lazy(() => import("./features/ecommerce/EcommerceTemplatesPage"));
import type { TemplateCase } from "./features/ecommerce/ecommerceTemplates";
const HomePage = lazy(() => import("./features/home/HomePage")); const HomePage = lazy(() => import("./features/home/HomePage"));
const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage")); const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
const MorePage = lazy(() => import("./features/more/MorePage")); const MorePage = lazy(() => import("./features/more/MorePage"));
@@ -54,7 +52,6 @@ const ResolutionUpscalePage = lazy(() => import("./features/resolution-upscale/R
const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/WatermarkRemovalPage")); const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/WatermarkRemovalPage"));
const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage")); const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage"));
const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage")); const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage"));
const SizeTemplatePage = lazy(() => import("./features/size-template/SizeTemplatePage"));
const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage")); const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage")); const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage")); const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage"));
@@ -101,7 +98,6 @@ const VIEW_KEYS = new Set<WebViewKey>([
"assets", "assets",
"ecommerceHub", "ecommerceHub",
"ecommerce", "ecommerce",
"ecommerceTemplates",
"scriptTokens", "scriptTokens",
"tokenUsage", "tokenUsage",
"settings", "settings",
@@ -113,14 +109,13 @@ const VIEW_KEYS = new Set<WebViewKey>([
"avatarConsole", "avatarConsole",
"characterMix", "characterMix",
"more", "more",
"sizeTemplate",
"communityReview", "communityReview",
"communityCaseAdd", "communityCaseAdd",
"report", "report",
"providerHealth", "providerHealth",
]); ]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "ecommerceTemplates", "sizeTemplate"]); const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more"]);
function normalizeViewKey(rawView: string): WebViewKey { function normalizeViewKey(rawView: string): WebViewKey {
const normalized = const normalized =
@@ -312,12 +307,6 @@ function App() {
hint: "AI创作与海报生成", hint: "AI创作与海报生成",
icon: <ShoppingOutlined />, icon: <ShoppingOutlined />,
}, },
{
key: "sizeTemplate",
label: "示例模板",
hint: "平台比例与导出尺寸",
icon: <LayoutOutlined />,
},
{ key: "canvas", label: "画布", hint: "进入自由画布编排", icon: <BranchesOutlined /> }, { key: "canvas", label: "画布", hint: "进入自由画布编排", icon: <BranchesOutlined /> },
{ key: "community", label: "社区", hint: "案例分享与导入", icon: <GlobalOutlined /> }, { key: "community", label: "社区", hint: "案例分享与导入", icon: <GlobalOutlined /> },
{ key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: <BarChartOutlined /> }, { key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: <BarChartOutlined /> },
@@ -380,11 +369,6 @@ function App() {
}; };
}, [showSessionReplacedModal]); }, [showSessionReplacedModal]);
const handleOpenEcommerceTemplate = useCallback((template: TemplateCase) => {
setPendingEcommerceTemplate(template);
handleSetView("ecommerce");
}, [setPendingEcommerceTemplate, handleSetView]);
const hydrateAccountData = useCallback(async (nextSession: WebUserSession | null) => { const hydrateAccountData = useCallback(async (nextSession: WebUserSession | null) => {
setProjectsLoaded(false); setProjectsLoaded(false);
if (!nextSession) { if (!nextSession) {
@@ -681,11 +665,14 @@ function App() {
} }
canvasAutoOpenedRecentRef.current = true; canvasAutoOpenedRecentRef.current = true;
void handleOpenProject(projects[0]); handleOpenProject(projects[0]).catch(() => {
// Reset flag on failure so auto-open can retry on next dependency change
canvasAutoOpenedRecentRef.current = false;
});
}, [ }, [
activeView, activeView,
canvasWorkflow?.nodes.length, canvasWorkflow.nodes.length,
canvasWorkflow?.source, canvasWorkflow.source,
currentCanvasProjectId, currentCanvasProjectId,
handleOpenProject, handleOpenProject,
projects, projects,
@@ -987,25 +974,6 @@ function App() {
}, [activeView, session]); // eslint-disable-line react-hooks/exhaustive-deps }, [activeView, session]); // eslint-disable-line react-hooks/exhaustive-deps
const activePage = (() => { const activePage = (() => {
if (!session && !PUBLIC_VIEWS.has(activeView)) {
return (
<ProfilePage
session={session}
usage={usage}
projects={projects}
tasks={tasks}
pendingActionLabel={pendingAction?.label ?? null}
onLogin={handleLogin}
onRegister={handleRegister}
onAuthComplete={completeAuth}
onSessionChange={setSession}
onLogout={handleLogout}
onOpenWorkbench={() => handleSetView("workbench")}
onOpenCommunity={() => handleSetView("community")}
onDeleteProject={handleDeleteProject}
/>
);
}
switch (activeView) { switch (activeView) {
case "login": case "login":
return ( return (
@@ -1049,7 +1017,7 @@ function App() {
case "canvas": case "canvas":
return ( return (
<CanvasPage <CanvasPage
workflow={canvasWorkflow!} workflow={canvasWorkflow}
projectId={currentCanvasProjectId} projectId={currentCanvasProjectId}
projects={projects} projects={projects}
projectsLoaded={projectsLoaded} projectsLoaded={projectsLoaded}
@@ -1081,18 +1049,6 @@ function App() {
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)} onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
/> />
); );
case "ecommerceTemplates":
return (
<EcommerceTemplatesPage
projects={projects}
onOpenMore={() => handleSetView("more")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onSelectTemplate={handleOpenEcommerceTemplate}
onStartCreate={handleStartTemplateCanvasCreate}
onOpenProject={handleOpenProject}
onDeleteProject={handleDeleteProject}
/>
);
case "digitalHuman": case "digitalHuman":
return ( return (
<DigitalHumanPage <DigitalHumanPage
@@ -1118,15 +1074,6 @@ function App() {
); );
case "more": case "more":
return <MorePage onSelectView={handleSetView} onOpenImageTool={handleOpenImageWorkbenchTool} />; return <MorePage onSelectView={handleSetView} onOpenImageTool={handleOpenImageWorkbenchTool} />;
case "sizeTemplate":
return (
<SizeTemplatePage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onSelectView={handleSetView}
/>
);
case "scriptTokens": case "scriptTokens":
return <ScriptTokensPage />; return <ScriptTokensPage />;
case "tokenUsage": case "tokenUsage":
+17 -16
View File
@@ -1,5 +1,3 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
export interface ScriptEvalResult { export interface ScriptEvalResult {
totalScore: number; totalScore: number;
grade: string; grade: string;
@@ -10,6 +8,10 @@ export interface ScriptEvalResult {
suggestions: string[]; 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";
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。 const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
【剧本类型识别】 【剧本类型识别】
@@ -67,31 +69,35 @@ function extractJson(text: string): unknown {
} }
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> { export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
console.log("[API] 发送评测请求,剧本长度:", script.slice(0, 8000).length, "字符"); if (!DASHSCOPE_API_KEY) {
const res = await fetch(buildApiUrl("ai/chat"), { throw new Error("DashScope API key 未配置,请在 .env.local 中设置 VITE_DASHSCOPE_API_KEY");
}
const res = await fetch(DASHSCOPE_ENDPOINT, {
method: "POST", method: "POST",
headers: buildAuthHeaders(), headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${DASHSCOPE_API_KEY}`,
},
body: JSON.stringify({ body: JSON.stringify({
model: "qwen3.7-max", model: MODEL,
messages: [ messages: [
{ role: "system", content: EVAL_SYSTEM_PROMPT }, { role: "system", content: EVAL_SYSTEM_PROMPT },
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` }, { role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
], ],
stream: false, stream: false,
temperature: 0.3, temperature: 0.3,
max_tokens: 4096,
}), }),
signal, signal,
}); });
console.log("[API] 响应状态:", res.status, res.statusText);
if (!res.ok) { if (!res.ok) {
throw new Error(`评测请求失败 (${res.status})`); const errText = await res.text().catch(() => "");
throw new Error(`评测请求失败 (${res.status}): ${errText.slice(0, 200)}`);
} }
const payload = await res.json(); const payload = await res.json();
console.log("[API] 原始响应体:", payload);
const content: string = payload?.choices?.[0]?.message?.content const content: string = payload?.choices?.[0]?.message?.content
?? payload?.result?.content ?? payload?.result?.content
?? payload?.content ?? payload?.content
@@ -100,11 +106,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
if (!content) throw new Error("模型未返回有效内容"); if (!content) throw new Error("模型未返回有效内容");
console.log("[API] 模型返回内容 (前500字符):", content.slice(0, 500));
const parsed = extractJson(content) as Record<string, unknown>; const parsed = extractJson(content) as Record<string, unknown>;
console.log("[API] 解析后的JSON:", parsed);
const dimensionScores: Record<string, number> = {}; const dimensionScores: Record<string, number> = {};
const rawScores = parsed.dimensionScores as Record<string, number> | undefined; const rawScores = parsed.dimensionScores as Record<string, number> | undefined;
if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常"); if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常");
@@ -115,7 +117,6 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
} }
const { totalScore, grade } = computeTotalAndGrade(dimensionScores); const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
console.log("[API] 计算后总分:", totalScore, "等级:", grade);
return { return {
totalScore, totalScore,
+5
View File
@@ -234,6 +234,10 @@ function notifySessionExpired(status: number, response: Response, payload: unkno
if (/\/auth\//i.test(response.url)) return; if (/\/auth\//i.test(response.url)) return;
// SESSION_REPLACED has its own dedicated handling/modal. // SESSION_REPLACED has its own dedicated handling/modal.
if (getPayloadCode(payload) === "SESSION_REPLACED") return; if (getPayloadCode(payload) === "SESSION_REPLACED") return;
// If the user never had a session, a 401 is expected — not a session expiry.
if (!readStoredSession()) return;
// Deliberate early-exit for unauthenticated users — not a real auth failure.
if (getPayloadCode(payload) === "NOT_LOGGED_IN") return;
const now = Date.now(); const now = Date.now();
if (now - lastSessionExpiredEventAt < 1500) return; if (now - lastSessionExpiredEventAt < 1500) return;
@@ -250,6 +254,7 @@ function notifySessionReplaced(status: number, payload: unknown, fallbackMessage
const message = getPayloadMessage(payload) || fallbackMessage || "您已在别处登录"; const message = getPayloadMessage(payload) || fallbackMessage || "您已在别处登录";
const isSessionReplaced = code === "SESSION_REPLACED" || message.includes("您已在别处登录"); const isSessionReplaced = code === "SESSION_REPLACED" || message.includes("您已在别处登录");
if (!isSessionReplaced || typeof window === "undefined") return; if (!isSessionReplaced || typeof window === "undefined") return;
if (!readStoredSession()) return;
const now = Date.now(); const now = Date.now();
if (now - lastSessionReplacedEventAt < 1500) return; if (now - lastSessionReplacedEventAt < 1500) return;
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

+41
View File
@@ -3,8 +3,12 @@ import {
ArrowUpOutlined, ArrowUpOutlined,
CheckCircleOutlined, CheckCircleOutlined,
FlagOutlined, FlagOutlined,
InfoCircleOutlined,
LoginOutlined, LoginOutlined,
LogoutOutlined, LogoutOutlined,
PhoneOutlined,
SafetyOutlined,
EnvironmentOutlined,
PlusCircleOutlined, PlusCircleOutlined,
UserOutlined, UserOutlined,
WalletOutlined, WalletOutlined,
@@ -61,6 +65,8 @@ function AppShell({
const submenuHideTimerRef = useRef<number | null>(null); const submenuHideTimerRef = useRef<number | null>(null);
const [profileOpen, setProfileOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false);
const [rechargeOpen, setRechargeOpen] = useState(false); const [rechargeOpen, setRechargeOpen] = useState(false);
const [infoOpen, setInfoOpen] = useState(false);
const infoRef = useRef<HTMLDivElement>(null);
const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null); const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null);
const prevActiveViewRef = useRef<WebViewKey>(activeView); const prevActiveViewRef = useRef<WebViewKey>(activeView);
const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null); const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null);
@@ -140,6 +146,17 @@ function AppShell({
return () => document.removeEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [profileOpen]); }, [profileOpen]);
useEffect(() => {
if (!infoOpen) return;
const handleInfoOutside = (event: PointerEvent) => {
if (!infoRef.current?.contains(event.target as Node)) {
setInfoOpen(false);
}
};
document.addEventListener("pointerdown", handleInfoOutside);
return () => document.removeEventListener("pointerdown", handleInfoOutside);
}, [infoOpen]);
useEffect(() => { useEffect(() => {
if (!session) { if (!session) {
setProfileOpen(false); setProfileOpen(false);
@@ -307,6 +324,30 @@ function AppShell({
onMarkAllRead={onMarkAllNotificationsRead} onMarkAllRead={onMarkAllNotificationsRead}
/> />
)} )}
<div className="info-popover-anchor" ref={infoRef}>
<button
className="info-button"
type="button"
aria-label="网站信息"
onClick={() => setInfoOpen((c) => !c)}
>
<InfoCircleOutlined />
</button>
<AnimatedPanel open={infoOpen} className="info-popover panel-surface">
<dl>
<dt></dt>
<dd>ICP备2026021747号-1</dd>
<dt></dt>
<dd>9A楼501</dd>
<dt></dt>
<dd>15155073618</dd>
</dl>
<div className="info-popover__links">
<a href="#" onClick={(e) => { e.preventDefault(); setInfoOpen(false); }}></a>
<a href="#" onClick={(e) => { e.preventDefault(); setInfoOpen(false); }}></a>
</div>
</AnimatedPanel>
</div>
<button <button
className="member-button" className="member-button"
type="button" type="button"
+2
View File
@@ -47,6 +47,8 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp
useEffect(() => { useEffect(() => {
if (viewKey === prevKeyRef.current) { if (viewKey === prevKeyRef.current) {
setDisplayedChildren(children); setDisplayedChildren(children);
// Cancel any active exit animation — children updated but viewKey stable.
setPhase("idle");
return; return;
} }
+46 -30
View File
@@ -48,6 +48,7 @@ import {
} from "./canvasCommunityPublish"; } from "./canvasCommunityPublish";
import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence"; import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence";
import { normalizeCanvasWorkflowSchema } from "./canvasWorkflowSchema"; import { normalizeCanvasWorkflowSchema } from "./canvasWorkflowSchema";
import { createBlankWorkflow } from "../../data/workflows";
import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory"; import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory";
import { useCanvasKeyboard } from "./useCanvasKeyboard"; import { useCanvasKeyboard } from "./useCanvasKeyboard";
import { useCanvasNodeDrag } from "./useCanvasNodeDrag"; import { useCanvasNodeDrag } from "./useCanvasNodeDrag";
@@ -310,7 +311,7 @@ function getCameraMotionPrompt(value: string): string {
} }
function CanvasPage({ function CanvasPage({
workflow, workflow: rawWorkflow,
projectId, projectId,
projects = [], projects = [],
projectsLoaded = true, projectsLoaded = true,
@@ -323,6 +324,7 @@ function CanvasPage({
onSaveWorkflow, onSaveWorkflow,
onCreateTask, onCreateTask,
}: CanvasPageProps) { }: CanvasPageProps) {
const workflow = rawWorkflow || createBlankWorkflow();
const [contextMenu, setContextMenu] = useState<CanvasFloatingMenuPosition | null>(null); const [contextMenu, setContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
const [nodeMenu, setNodeMenu] = useState<CanvasFloatingMenuPosition | null>(null); const [nodeMenu, setNodeMenu] = useState<CanvasFloatingMenuPosition | null>(null);
const [textNodeMenu, setTextNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null); const [textNodeMenu, setTextNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
@@ -404,6 +406,7 @@ function CanvasPage({
textGenerationState, imageGenerationState, videoGenerationState, textGenerationState, imageGenerationState, videoGenerationState,
generationToast, setGenerationToast, generationToast, setGenerationToast,
imageGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef, imageGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus, setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
restoreKeepaliveTasks, resetGenerationState, restoreKeepaliveTasks, resetGenerationState,
} = useCanvasGeneration({ setImageNodes, setVideoNodes }); } = useCanvasGeneration({ setImageNodes, setVideoNodes });
@@ -524,6 +527,7 @@ function CanvasPage({
const canvasAssets = serverAssets.filter((asset) => asset.imageUrl); const canvasAssets = serverAssets.filter((asset) => asset.imageUrl);
const shouldShowEmptyProjectState = const shouldShowEmptyProjectState =
projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0; projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0;
const isWaitingForProjects = isAuthenticated && !projectsLoaded;
const [projectSaveState, setProjectSaveState] = useState<CanvasProjectSaveState>({ const [projectSaveState, setProjectSaveState] = useState<CanvasProjectSaveState>({
status: "idle", status: "idle",
message: "", message: "",
@@ -571,10 +575,13 @@ function CanvasPage({
imageNodeIdRef.current = nextImageNodes.length + 1; imageNodeIdRef.current = nextImageNodes.length + 1;
videoNodeIdRef.current = nextVideoNodes.length + 1; videoNodeIdRef.current = nextVideoNodes.length + 1;
// Reset keepalive flag so tasks can be restored for this project
canvasGenKeepaliveRestoredRef.current = false;
if (projectId && isAuthenticated) { if (projectId && isAuthenticated) {
restoreKeepaliveTasks(projectId, nextImageNodes, nextVideoNodes); restoreKeepaliveTasks(projectId, nextImageNodes, nextVideoNodes);
} }
}, [workflow.id, workflow.nodes, projectId, isAuthenticated]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflow.id, workflow.nodes, projectId]);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) { if (!isAuthenticated) {
@@ -3524,16 +3531,16 @@ function CanvasPage({
return ( return (
<WorkspacePageShell title="画布" fullWidth className="canvas-page page-motion"> <WorkspacePageShell title="画布" fullWidth className="canvas-page page-motion">
<div className={`studio-tool-layout studio-tool-layout--no-top studio-tool-layout--no-left studio-tool-layout--no-right studio-tool-layout--canvas${shouldShowEmptyProjectState ? " studio-tool-layout--canvas-empty" : ""}`}> <div className={`studio-tool-layout studio-tool-layout--no-top studio-tool-layout--no-left studio-tool-layout--no-right studio-tool-layout--canvas${(shouldShowEmptyProjectState || isWaitingForProjects) ? " studio-tool-layout--canvas-empty" : ""}`}>
<section <section
className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${shouldShowEmptyProjectState ? " is-empty-projects" : ""}`} className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${(shouldShowEmptyProjectState || isWaitingForProjects) ? " is-empty-projects" : ""}`}
ref={canvasRef} ref={canvasRef}
onAuxClick={shouldShowEmptyProjectState ? undefined : handleCanvasAuxClick} onAuxClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasAuxClick}
onContextMenu={shouldShowEmptyProjectState ? (event) => event.preventDefault() : handleCanvasContextMenu} onContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? (event) => event.preventDefault() : handleCanvasContextMenu}
onMouseDownCapture={shouldShowEmptyProjectState ? undefined : handleCanvasMouseDown} onMouseDownCapture={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseDown}
onDoubleClick={shouldShowEmptyProjectState ? undefined : handleCanvasDoubleClick} onDoubleClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDoubleClick}
onMouseMove={shouldShowEmptyProjectState ? undefined : handleCanvasMouseMove} onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove}
onWheel={shouldShowEmptyProjectState ? undefined : handleCanvasWheel} onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel}
style={{ style={{
"--canvas-bg-size": `${24 * canvasViewport.zoom}px`, "--canvas-bg-size": `${24 * canvasViewport.zoom}px`,
"--canvas-bg-x": `${canvasViewport.x}px`, "--canvas-bg-x": `${canvasViewport.x}px`,
@@ -3555,7 +3562,7 @@ function CanvasPage({
className="studio-canvas-hidden-input" className="studio-canvas-hidden-input"
onChange={(event) => handleImageFileSelected(event, pendingImagePosition)} onChange={(event) => handleImageFileSelected(event, pendingImagePosition)}
/> />
{!shouldShowEmptyProjectState ? ( {(!shouldShowEmptyProjectState || isWaitingForProjects) ? (
<div className="studio-canvas-project-bar" onMouseDown={(event) => event.stopPropagation()}> <div className="studio-canvas-project-bar" onMouseDown={(event) => event.stopPropagation()}>
<div className="studio-canvas-project-bar__identity"> <div className="studio-canvas-project-bar__identity">
{projectNameEditing ? ( {projectNameEditing ? (
@@ -3650,7 +3657,7 @@ function CanvasPage({
</button> </button>
</div> </div>
) : null} ) : null}
{!shouldShowEmptyProjectState && recentProjectsOpen ? ( {(!shouldShowEmptyProjectState || isWaitingForProjects) && recentProjectsOpen ? (
<aside <aside
id="studio-canvas-recent-drawer" id="studio-canvas-recent-drawer"
className="studio-canvas-recent-drawer" className="studio-canvas-recent-drawer"
@@ -3718,8 +3725,8 @@ function CanvasPage({
zoomOnPinch={false} zoomOnPinch={false}
zoomOnScroll={false} zoomOnScroll={false}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
onPaneClick={shouldShowEmptyProjectState ? undefined : handlePaneClick} onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick}
onPaneContextMenu={shouldShowEmptyProjectState ? undefined : handlePaneContextMenu} onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
> >
<Background gap={24} color="transparent" className="studio-canvas__background" /> <Background gap={24} color="transparent" className="studio-canvas__background" />
</ReactFlow> </ReactFlow>
@@ -3731,7 +3738,7 @@ function CanvasPage({
<button type="button" title="放大" onClick={zoomCanvasIn}>+</button> <button type="button" title="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" onClick={fitCanvasView}></button> <button type="button" title="适应视图" onClick={fitCanvasView}></button>
</div> </div>
{shouldShowEmptyProjectState ? ( {(shouldShowEmptyProjectState || isWaitingForProjects) ? (
<div <div
className="studio-canvas-empty-projects" className="studio-canvas-empty-projects"
role="status" role="status"
@@ -3742,21 +3749,30 @@ function CanvasPage({
event.stopPropagation(); event.stopPropagation();
}} }}
> >
<strong></strong> {isWaitingForProjects ? (
<button <>
type="button" <div className="studio-canvas-loading-spinner" />
className="studio-canvas-empty-projects__button" <strong></strong>
onClick={(event) => { </>
event.stopPropagation(); ) : (
if (onStartCreate) { <>
onStartCreate(); <strong></strong>
return; <button
} type="button"
onOpenLogin(); className="studio-canvas-empty-projects__button"
}} onClick={(event) => {
> event.stopPropagation();
if (onStartCreate) {
</button> onStartCreate();
return;
}
onOpenLogin();
}}
>
</button>
</>
)}
</div> </div>
) : null} ) : null}
{selectionRect ? ( {selectionRect ? (
@@ -22,6 +22,7 @@ import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { uploadAssetWithProgress } from "../../api/uploadWithProgress"; import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions"; import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
import { CheckCircleOutlined, InfoCircleOutlined } from "@ant-design/icons"; import { CheckCircleOutlined, InfoCircleOutlined } from "@ant-design/icons";
@@ -85,12 +86,32 @@ function CharacterMixPage({
}; };
}, [checkImage, characterPreview]); }, [checkImage, characterPreview]);
const keepaliveRestoredRef = useRef(false);
// Keep-alive: restore saved task on mount
useEffect(() => {
if (keepaliveRestoredRef.current) return;
keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("charactermix");
if (!saved || saved.resultUrl) return;
setIsCreating(true);
abortRef.current = false;
void pollTaskUntilDone(saved.taskId).then((result) => {
setResultUrl(result);
setNotice(result ? "角色迁移完成" : "已取消");
setIsCreating(false);
setProgress(0);
if (result) {
saveToolTaskState("charactermix", { taskId: saved.taskId, resultUrl: result, status: "完成", progress: 100 });
} else {
clearToolTaskState("charactermix");
}
});
}, []);
useEffect(() => { useEffect(() => {
return () => { return () => {
abortRef.current = true; abortRef.current = true;
if (taskIdRef.current) {
aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {});
}
}; };
}, []); }, []);
@@ -100,6 +121,7 @@ function CharacterMixPage({
aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {}); aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {});
taskIdRef.current = null; taskIdRef.current = null;
} }
clearToolTaskState("charactermix");
}, []); }, []);
const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => { const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => {
@@ -153,10 +175,16 @@ function CharacterMixPage({
muted: !watermark, muted: !watermark,
}); });
taskIdRef.current = taskId; taskIdRef.current = taskId;
saveToolTaskState("charactermix", { taskId, status: "running", progress: 0 });
const result = await pollTaskUntilDone(taskId); const result = await pollTaskUntilDone(taskId);
setResultUrl(result); setResultUrl(result);
setNotice(result ? "角色迁移完成" : "已取消"); setNotice(result ? "角色迁移完成" : "已取消");
if (result) {
saveToolTaskState("charactermix", { taskId, resultUrl: result, status: "完成", progress: 100 });
} else {
clearToolTaskState("charactermix");
}
} catch (error) { } catch (error) {
setNotice(error instanceof Error ? error.message : "任务创建失败,请稍后重试。"); setNotice(error instanceof Error ? error.message : "任务创建失败,请稍后重试。");
} finally { } finally {
-34
View File
@@ -232,40 +232,6 @@ function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject
}; };
}, []); }, []);
useEffect(() => {
let cancelled = false;
const timeoutId = window.setTimeout(() => {
communityClient
.listApprovedCases({
limit: 30,
sort: "latest",
})
.then((items) => {
if (!cancelled) {
const canvasItems = items.filter(shouldShowInCanvasCommunity);
setServerCases(canvasItems);
setServerNotice(
canvasItems.length
? "已连接服务器画布社区"
: items.length
? "服务器暂无匹配画布案例"
: "社区暂无模板",
);
}
})
.catch((error) => {
if (!cancelled) {
setServerNotice(error instanceof Error ? error.message : "社区服务暂时不可用");
}
});
}, 280);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, []);
const handleToggleFavorite = async (item: ServerCommunityCase, cardId: string) => { const handleToggleFavorite = async (item: ServerCommunityCase, cardId: string) => {
const nextActive = !(item.isFavorited || favoriteIds.includes(cardId)); const nextActive = !(item.isFavorited || favoriteIds.includes(cardId));
setFavoriteIds((current) => setFavoriteIds((current) =>
@@ -21,6 +21,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { uploadAssetWithProgress } from "../../api/uploadWithProgress"; import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { getServerBaseUrl } from "../../api/serverConnection"; import { getServerBaseUrl } from "../../api/serverConnection";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway"; import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import StudioToolLayout from "../../components/StudioToolLayout"; import StudioToolLayout from "../../components/StudioToolLayout";
@@ -93,6 +94,7 @@ function DigitalHumanPage({
const cancelRef = useRef(false); const cancelRef = useRef(false);
const activeTaskIdRef = useRef(activeTaskId); const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId; activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -106,13 +108,24 @@ function DigitalHumanPage({
}; };
}, [audioPreview]); }, [audioPreview]);
// Keep-alive: restore saved task on mount
useEffect(() => {
if (keepaliveRestoredRef.current) return;
keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("digital-human");
if (!saved || saved.resultUrl) return;
setIsProcessing(true);
cancelRef.current = false;
pollRunRef.current += 1;
setActiveTaskId(saved.taskId);
void waitForTaskResult(saved.taskId).catch(() => {});
setStatus("正在恢复数字人任务...");
}, []);
useEffect(() => { useEffect(() => {
return () => { return () => {
pollRunRef.current += 1; pollRunRef.current += 1;
cancelRef.current = true; cancelRef.current = true;
if (activeTaskIdRef.current) {
aiGenerationClient.cancelTask(activeTaskIdRef.current).catch(() => {});
}
}; };
}, []); }, []);
@@ -186,6 +199,7 @@ function DigitalHumanPage({
const runId = ++pollRunRef.current; const runId = ++pollRunRef.current;
setActiveTaskId(taskId); setActiveTaskId(taskId);
setTaskProgress(0); setTaskProgress(0);
saveToolTaskState("digital-human", { taskId, status: "running", progress: 0 });
pushDebugEntry("开始订阅", `开始接收任务 ${taskId} 的生成结果。`); pushDebugEntry("开始订阅", `开始接收任务 ${taskId} 的生成结果。`);
const resultUrl = await waitForTask(taskId, { const resultUrl = await waitForTask(taskId, {
@@ -204,6 +218,7 @@ function DigitalHumanPage({
if (e.status === "completed" && e.resultUrl) { if (e.status === "completed" && e.resultUrl) {
setResultVideoUrl(e.resultUrl); setResultVideoUrl(e.resultUrl);
setNotice(`任务完成,结果已接收:${taskId}`); setNotice(`任务完成,结果已接收:${taskId}`);
clearToolTaskState("digital-human");
pushDebugEntry("结果已接收", summarizeUrl(e.resultUrl), "success", { taskId, resultUrl: e.resultUrl }); pushDebugEntry("结果已接收", summarizeUrl(e.resultUrl), "success", { taskId, resultUrl: e.resultUrl });
} }
}, },
+1 -1
View File
@@ -3086,7 +3086,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"} aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"}
durationSeconds={cloneVideoDuration} durationSeconds={cloneVideoDuration}
resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"} resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"}
onRequestLogin={() => ((_props as Record<string, (() => void) | undefined>).onRequireLogin?.())} onRequestLogin={() => ((_props as Record<string, unknown>).isAuthenticated ? undefined : (window.location.hash = "#/login"))}
/> />
</main> </main>
) : clonePreview) : placeholderPreview} ) : clonePreview) : placeholderPreview}
+407 -163
View File
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useRef, useState } from "react"; import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { import {
CopyOutlined, CopyOutlined,
DownloadOutlined, DownloadOutlined,
@@ -9,9 +9,10 @@ import {
SendOutlined, SendOutlined,
StopOutlined, StopOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { runVideoPlan, renderScene, buildSceneTasks } from "./ecommerceVideoService"; import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks } from "./ecommerceVideoService";
import { import {
PLAN_STEP_LABELS, PLAN_STEP_LABELS,
PLAN_STEPS_DISPLAY,
type EcommerceVideoStage, type EcommerceVideoStage,
type EcommerceVideoSceneTask, type EcommerceVideoSceneTask,
type EcommerceVideoPlanResult, type EcommerceVideoPlanResult,
@@ -21,6 +22,11 @@ import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
import { ServerRequestError } from "../../api/serverConnection"; import { ServerRequestError } from "../../api/serverConnection";
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions"; import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
import { useAppStore } from "../../stores"; import { useAppStore } from "../../stores";
import {
saveEcommerceVideoState,
loadEcommerceVideoState,
clearEcommerceVideoState,
} from "./ecommerceVideoKeepalive";
interface EcommerceVideoWorkspaceProps { interface EcommerceVideoWorkspaceProps {
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -56,12 +62,120 @@ export default function EcommerceVideoWorkspace({
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null); const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]); const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]);
const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]); const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]);
const [sourceImageUrls, setSourceImageUrls] = useState<string[]>([]);
const [currentStep, setCurrentStep] = useState<PlanStep | null>(null); const [currentStep, setCurrentStep] = useState<PlanStep | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null); const [actionNotice, setActionNotice] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false }); const renderAbortRef = useRef({ current: false });
const setView = useAppStore((s) => s.setView); const setView = useAppStore((s) => s.setView);
const keepaliveRestoredRef = useRef(false);
const keepalivePollingStartedRef = useRef(false);
// ── Keep-alive: restore saved state on mount ─────────────
useEffect(() => {
if (keepaliveRestoredRef.current) return;
keepaliveRestoredRef.current = true;
const saved = loadEcommerceVideoState();
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);
setScenes(saved.scenes || []);
setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []);
}, []);
// ── Keep-alive: save state on changes ───────────────────
useEffect(() => {
if (stage === "idle" || stage === "cancelled") return;
saveEcommerceVideoState({ stage, completedSteps, planResult, scenes, sourceImageUrls });
}, [stage, completedSteps, planResult, scenes, sourceImageUrls]);
// ── Keep-alive: resume polling for running tasks ──────────
useEffect(() => {
if (keepalivePollingStartedRef.current) return;
if (!scenes.length || stage === "idle" || stage === "cancelled" || stage === "completed") return;
const hasRunningScenes = scenes.some((s) => s.status === "running" || s.status === "pending");
if (!hasRunningScenes) return;
keepalivePollingStartedRef.current = true;
// Resume polling for image generation tasks
if (stage === "imaging") {
renderAbortRef.current = { current: false };
void (async () => {
for (const scene of scenes) {
if (renderAbortRef.current.current) break;
if (scene.status !== "running" && scene.status !== "pending") continue;
if (!scene.imageTaskId) continue;
try {
const { waitForTask } = await import("../../api/taskSubscription");
const resultUrl = await waitForTask(scene.imageTaskId, {
abortRef: renderAbortRef.current,
onProgress: (e) =>
setScenes((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s))),
});
if (resultUrl) {
setScenes((prev) =>
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", progress: 100, imageUrl: resultUrl } : s)),
);
}
} catch {
setScenes((prev) =>
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", error: "恢复任务失败" } : s)),
);
}
}
setScenes((current) => {
const allImaged = current.every((s) => s.imageUrl);
if (allImaged) setStage("imaged");
return current;
});
})();
}
// Resume polling for video rendering tasks
if (stage === "rendering") {
renderAbortRef.current = { current: false };
void (async () => {
for (const scene of scenes) {
if (renderAbortRef.current.current) break;
if (scene.status !== "running" && scene.status !== "pending") continue;
if (!scene.taskId) continue;
try {
const { waitForTask } = await import("../../api/taskSubscription");
const resultUrl = await waitForTask(scene.taskId, {
abortRef: renderAbortRef.current,
onProgress: (e) =>
setScenes((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s))),
});
if (resultUrl) {
setScenes((prev) =>
prev.map((s) =>
s.sceneId === scene.sceneId ? { ...s, status: "completed", progress: 100, resultUrl: resultUrl } : s,
),
);
}
} catch {
setScenes((prev) =>
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "failed", error: "恢复任务失败" } : s)),
);
}
}
setScenes((current) => {
const hasFailed = current.some((s) => s.status === "failed");
const allDone = current.every((s) => s.status === "completed" || s.status === "failed");
if (allDone) setStage(hasFailed ? "partial_failed" : "completed");
return current;
});
})();
}
}, [scenes, stage]);
// Note: keep-alive is NOT cleared on completion — results persist across page switches.
// Only cleared when user explicitly starts a new plan via handlePlan.
const showNotice = (msg: string) => { const showNotice = (msg: string) => {
setActionNotice(msg); setActionNotice(msg);
@@ -71,166 +185,229 @@ export default function EcommerceVideoWorkspace({
const handleDownload = async (url: string) => { const handleDownload = async (url: string) => {
try { try {
await saveToolResultToLocal({ await saveToolResultToLocal({
url, url, name: `ecommerce-video-${Date.now()}`, type: "video",
name: `ecommerce-video-${Date.now()}`, isVideo: true, tags: ["电商", "短视频", "生成视频"],
type: "video",
isVideo: true,
tags: ["电商", "短视频", "生成视频"],
}); });
showNotice("下载完成"); showNotice("下载完成");
} catch { } catch {
const a = document.createElement("a"); const a = document.createElement("a"); a.href = url; a.download = "ecommerce-video.mp4"; a.click();
a.href = url;
a.download = "ecommerce-video.mp4";
a.click();
} }
}; };
const handleSaveAsset = async (url: string) => { const handleSaveAsset = async (url: string) => {
try { try {
const result = await addToolResultToAssetLibrary({ const result = await addToolResultToAssetLibrary({
url, url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频生成结果",
name: `电商短视频-${Date.now()}.mp4`, type: "video", isVideo: true, tags: ["电商", "短视频", "广告视频"],
description: "电商广告视频生成结果",
type: "video",
isVideo: true,
tags: ["电商", "短视频", "广告视频"],
metadata: { source: "ecommerce-video", platform }, metadata: { source: "ecommerce-video", platform },
}); });
showNotice(result === "server" ? "已保存到资产库" : "已保存到本地资产库"); showNotice(result === "server" ? "已保存到资产库" : "已保存到本地资产库");
} catch { } catch { showNotice("保存失败"); }
showNotice("保存失败"); };
const handleSaveAllAssets = async () => {
if (!completedScenes.length) return;
let saved = 0;
for (const scene of completedScenes) {
try {
await addToolResultToAssetLibrary({
url: scene.resultUrl!, name: `电商短视频-镜头${scene.sceneId}-${Date.now()}.mp4`,
description: `电商广告视频 - 镜头${scene.sceneId}`,
type: "video", isVideo: true, tags: ["电商", "短视频", "广告视频"],
metadata: { source: "ecommerce-video", platform, sceneId: scene.sceneId },
});
saved++;
} catch { /* continue */ }
} }
showNotice(saved > 0 ? `已保存 ${saved}/${completedScenes.length} 个视频到资产库` : "保存失败");
};
const handleDownloadAll = async () => {
for (const scene of completedScenes) {
await new Promise((r) => setTimeout(r, 300));
const a = document.createElement("a");
a.href = scene.resultUrl!;
a.download = `ecommerce-video-scene-${scene.sceneId}.mp4`;
a.click();
}
showNotice(`正在下载 ${completedScenes.length} 个视频`);
}; };
const handleImportToCanvas = async (url: string) => { const handleImportToCanvas = async (url: string) => {
try { try {
await addToolResultToAssetLibrary({ await addToolResultToAssetLibrary({
url, url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频 - 导入画布",
name: `电商短视频-${Date.now()}.mp4`, type: "video", isVideo: true, tags: ["电商", "短视频", "画布导入"],
description: "电商广告视频 - 导入画布",
type: "video",
isVideo: true,
tags: ["电商", "短视频", "画布导入"],
metadata: { source: "ecommerce-video", platform }, metadata: { source: "ecommerce-video", platform },
}); });
setView("canvas"); setView("canvas");
showNotice("已保存资产并跳转画布"); showNotice("已保存资产并跳转画布");
} catch { } catch { showNotice("导入失败"); }
showNotice("导入失败");
}
}; };
const buildConfig = useCallback((): AdVideoUserConfig => ({ const buildConfig = useCallback((): AdVideoUserConfig => ({
platform, platform, aspectRatio, durationSeconds,
aspectRatio, style: "痛点解决", language: "中文", market: "中国",
durationSeconds, needVoiceover: true, needSubtitle: true, conversionFocus: "conversion",
style: "痛点解决",
language: "中文",
market: "中国",
needVoiceover: true,
needSubtitle: true,
conversionFocus: "conversion",
}), [platform, aspectRatio, durationSeconds]); }), [platform, aspectRatio, durationSeconds]);
// ── Phase 1: Planning ──────────────────────────────────────
const handlePlan = async () => { const handlePlan = async () => {
if (!isAuthenticated) { onRequestLogin?.(); return; } if (!isAuthenticated) { onRequestLogin?.(); return; }
if (!productImageDataUrls.length && !requirement.trim()) { if (!productImageDataUrls.length && !requirement.trim()) {
setError("请先上传产品图片或填写商品说明"); setError("请先上传产品图片或填写商品说明"); return;
return;
} }
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
abortControllerRef.current = controller; abortControllerRef.current = controller;
setStage("planning"); setStage("planning"); setError(null);
setError(null); setCompletedSteps([]); setCurrentStep(null);
setCompletedSteps([]); setPlanResult(null); setScenes([]); setSourceImageUrls([]);
setCurrentStep(null);
setPlanResult(null);
setScenes([]);
try { try {
const result = await runVideoPlan( const result = await runVideoPlan(
productImageDataUrls, requirement, buildConfig(), productImageDataUrls, requirement, buildConfig(),
{ {
onStepStart: (step) => setCurrentStep(step), onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]), onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]),
onImagesUploaded: (urls) => { setSourceImageUrls(urls); saveEcommerceVideoState({ stage: "planning", completedSteps: ["upload"], planResult: null, scenes: [], sourceImageUrls: urls }); },
signal: controller.signal, signal: controller.signal,
}, },
); );
const builtScenes = buildSceneTasks(result);
setPlanResult(result); setPlanResult(result);
setScenes(buildSceneTasks(result)); setScenes(builtScenes);
setStage("planned"); 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 });
} catch (err) { } catch (err) {
if ((err as Error).name === "AbortError") return; if ((err as Error).name === "AbortError") return;
setError(err instanceof Error ? err.message : "策划失败"); setError(err instanceof Error ? err.message : "策划失败");
setStage("idle"); setStage("idle");
} finally { } finally { setCurrentStep(null); }
setCurrentStep(null);
}
}; };
const handleRender = async () => { // ── Phase 2: Image generation per scene ──────────────────────
if (!planResult || !scenes.length) return;
const imageUrl = planResult.imageUrls[0] || "";
setStage("rendering");
setError(null);
renderAbortRef.current = { current: false };
const quality = mapResolutionToQuality(resolution);
for (const scene of scenes) { const handleGenerateImages = async () => {
if (!planResult || !scenes.length) return;
setStage("imaging"); setError(null);
renderAbortRef.current = { current: false };
const ratio = aspectRatio.includes("9:16") || aspectRatio.includes("916") ? "9:16"
: aspectRatio.includes("16:9") || aspectRatio.includes("169") ? "16:9"
: "1:1";
let currentScenes = [...scenes];
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
currentScenes = next;
setScenes(next);
saveEcommerceVideoState({ stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
};
for (const scene of currentScenes) {
if (renderAbortRef.current.current) break; if (renderAbortRef.current.current) break;
setScenes((prev) => prev.map((s) => persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
try { try {
await renderScene( await renderSceneImage(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl, aspectRatio, resolution: quality }, { sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
{ {
onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)), onSceneImageSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)),
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)), onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneCompleted: (id, url) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)), onSceneImageCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)),
onSceneFailed: (id, err2) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)), onSceneImageFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)),
}, },
renderAbortRef.current, renderAbortRef.current,
); );
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "生成失败"; const message = err instanceof Error ? err.message : "图片生成失败";
const isPaymentError = err instanceof ServerRequestError && err.status === 402; persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "idle", error: message } : s));
setScenes((prev) => prev.map((s) =>
s.sceneId === scene.sceneId ? { ...s, status: "failed", error: isPaymentError ? "余额不足,请充值后继续" : message } : s));
if (isPaymentError) {
setError("余额不足,请充值后再生成视频");
renderAbortRef.current.current = true;
break;
}
} }
} }
setScenes((current) => { const allHaveImages = currentScenes.every((s) => s.imageUrl);
const hasFailed = current.some((s) => s.status === "failed"); const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const;
const allDone = current.every((s) => s.status === "completed" || s.status === "failed"); setStage(finalStage);
if (allDone) setStage(hasFailed ? "partial_failed" : "completed"); saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
return current;
});
}; };
const handleCancel = () => { // ── Phase 3: Video rendering from generated images ──────────
abortControllerRef.current?.abort();
renderAbortRef.current.current = true; const handleRenderVideos = async () => {
setStage("cancelled"); if (!scenes.length) return;
const firstImage = scenes[0]?.imageUrl;
if (!firstImage) { setError("请先生成分镜图片"); return; }
setStage("rendering"); setError(null);
renderAbortRef.current = { current: false };
const quality = mapResolutionToQuality(resolution);
let currentScenes = [...scenes];
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
currentScenes = next;
setScenes(next);
saveEcommerceVideoState({ stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
};
for (const scene of currentScenes) {
if (renderAbortRef.current.current) break;
if (!scene.imageUrl) continue;
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
try {
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)),
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)),
},
renderAbortRef.current,
);
} catch (err) {
const msg = err instanceof Error ? err.message : "生成失败";
const isPayment = err instanceof ServerRequestError && err.status === 402;
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: isPayment ? "余额不足,请充值后继续" : msg } : s));
if (isPayment) { setError("余额不足,请充值后再生成视频"); renderAbortRef.current.current = true; break; }
}
}
const hasFailed = currentScenes.some((s) => s.status === "failed");
const allDone = currentScenes.every((s) => s.status === "completed" || s.status === "failed");
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 });
}; };
const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); };
const handleRetryScene = async (scene: EcommerceVideoSceneTask) => {
if (!scene.imageUrl) return;
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) },
{
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)),
onSceneCompleted: (id, url) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
onSceneFailed: (id, err2) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
},
renderAbortRef.current,
);
} catch (err) {
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: (err as Error).message } : s));
}
};
// ── Derived state ───────────────────────────────────────────
const completedScenes = scenes.filter((s) => s.status === "completed" && s.resultUrl); const completedScenes = scenes.filter((s) => s.status === "completed" && s.resultUrl);
const imagedScenes = scenes.filter((s) => s.imageUrl);
const primaryVideo = completedScenes[0]?.resultUrl; const primaryVideo = completedScenes[0]?.resultUrl;
const canRender = planResult?.compliance.allow_video_generation && stage === "planned"; const sourceImage = sourceImageUrls[0] || planResult?.imageUrls[0] || productImageDataUrls[0] || "";
const sourceImage = planResult?.imageUrls[0] || productImageDataUrls[0] || ""; const flowHasStarted = stage !== "idle" || completedSteps.length > 0;
const flowHasStarted = stage !== "idle" || completedSteps.length > 0 || scenes.length > 0;
const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`; const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`;
const planActionLabel = stage === "planning" const hasImaging = stage === "imaging" || stage === "imaged" || stage === "rendering" || stage === "completed" || stage === "partial_failed";
? "策划中" const hasRendering = stage === "rendering" || stage === "completed" || stage === "partial_failed";
: (stage === "planned" || stage === "completed" || stage === "partial_failed") ? "重新策划" : "一键策划"; const visiblePlanSteps = PLAN_STEPS_DISPLAY.filter((s) => completedSteps.includes(s));
const renderActionLabel = stage === "rendering" ? "生成中" : "确认生成";
return ( return (
<div className="ecom-video-workspace" data-stage={stage}> <div className="ecom-video-workspace" data-stage={stage}>
{/* ── Flow bar ──────────────────────────────────── */}
<header className="ecom-video-flowbar"> <header className="ecom-video-flowbar">
<div className="ecom-video-flowbar__title" aria-label={`短视频分镜流,${flowMeta}`} title={flowMeta}> <div className="ecom-video-flowbar__title" aria-label={`短视频分镜流,${flowMeta}`} title={flowMeta}>
<span className={`ecom-video-flowbar__pulse${flowHasStarted ? " is-active" : ""}`} aria-hidden="true" /> <span className={`ecom-video-flowbar__pulse${flowHasStarted ? " is-active" : ""}`} aria-hidden="true" />
@@ -241,111 +418,178 @@ export default function EcommerceVideoWorkspace({
{ALL_STEPS.map((step) => { {ALL_STEPS.map((step) => {
const isDone = completedSteps.includes(step); const isDone = completedSteps.includes(step);
const isActive = currentStep === step; const isActive = currentStep === step;
const cls = isDone ? "is-done" : isActive ? "is-active" : ""; return <span key={step} className={`ecom-video-step-dot ${isDone ? "is-done" : isActive ? "is-active" : ""}`} title={PLAN_STEP_LABELS[step]} />;
return (
<span
key={step}
className={`ecom-video-step-dot ${cls}`}
title={PLAN_STEP_LABELS[step]}
aria-label={PLAN_STEP_LABELS[step]}
/>
);
})} })}
</div> </div>
<div className="ecom-video-flowbar__actions"> <div className="ecom-video-flowbar__actions">
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null} {error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
<button {stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
type="button" <button type="button" className="ecom-video-flow-action"
className="ecom-video-flow-action" onClick={() => void handlePlan()} title="一键策划">
disabled={stage === "planning" || stage === "rendering"} <PlayCircleOutlined />
onClick={() => void handlePlan()}
aria-label={planActionLabel}
title={planActionLabel}
>
{stage === "planning" ? <LoadingOutlined /> : (stage === "planned" || stage === "completed" || stage === "partial_failed") ? <ReloadOutlined /> : <PlayCircleOutlined />}
</button>
{(stage === "rendering" || stage === "planned") ? (
<button
type="button"
className="ecom-video-flow-action ecom-video-flow-action--ghost"
disabled={!canRender}
onClick={() => void handleRender()}
aria-label={renderActionLabel}
title={renderActionLabel}
>
{stage === "rendering" ? <LoadingOutlined /> : <SendOutlined />}
</button> </button>
) : null} ) : null}
{stage === "planned" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleGenerateImages()} title="生成图片">
<SendOutlined />
</button>
) : null}
{stage === "imaged" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleRenderVideos()} title="生成视频">
<SendOutlined />
</button>
) : null}
{stage === "planning" ? (
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span>
) : null}
{stage === "imaging" ? (
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span>
) : null}
{stage === "rendering" ? ( {stage === "rendering" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--danger" onClick={handleCancel} aria-label="取消生成" title="取消生成"> <span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span>
) : null}
{stage === "planning" || stage === "imaging" || stage === "rendering" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--danger" onClick={handleCancel} title="终止">
<StopOutlined /> <StopOutlined />
</button> </button>
) : null} ) : null}
</div> </div>
</header> </header>
{/* ── Flow canvas ──────────────────────────────────── */}
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图"> <section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
{completedScenes.length === 0 && !sourceImage ? ( {!sourceImage ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "#697486", fontSize: 13 }}> <div className="ecom-video-empty">
<span>"一键策划"</span> <span>"一键策划"</span>
</div> </div>
) : ( ) : (
<div className="ecom-video-flow-map"> <div className="ecom-video-flow-map">
{sourceImage ? ( {/* Source image node */}
<article className="ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label="商品图节点"> <article className="ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label="商品图节点">
<div className="ecom-video-flow-node__media"> <div className="ecom-video-flow-node__media">
<img src={sourceImage} alt="商品图" /> <img src={sourceImage} alt="商品图" />
</div> </div>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" /> <span className="ecom-video-flow-node__label"></span>
</article> <span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
) : null} </article>
{sourceImage && completedScenes.length > 0 ? ( {/* Connector: source → plan text nodes */}
{visiblePlanSteps.length > 0 ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div> <div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null} ) : null}
<div className="ecom-video-scene-strip" aria-label="已完成分镜节点"> {/* Plan text nodes — side by side */}
{completedScenes.map((scene, index) => ( {visiblePlanSteps.length > 0 ? (
<Fragment key={scene.sceneId}> <div className="ecom-video-scene-strip ecom-video-scene-strip--text" aria-label="策划节点">
<article {visiblePlanSteps.map((step, idx) => (
className="ecom-video-flow-node ecom-video-flow-node--scene is-completed" <Fragment key={step}>
aria-label={`镜头 ${scene.sceneId},完成`} <article className={`ecom-video-flow-node ecom-video-flow-node--text is-completed${currentStep === step ? " is-pulsing" : ""}`}
title={`镜头 ${scene.sceneId}`} aria-label={PLAN_STEP_LABELS[step]} title={PLAN_STEP_LABELS[step]}>
> <span className="ecom-video-flow-node__text-icon">
<div className="ecom-video-flow-node__media"> {currentStep === step ? <LoadingOutlined /> : "✓"}
<video src={scene.resultUrl!} muted playsInline loop autoPlay /> </span>
</div> <span className="ecom-video-flow-node__label">{PLAN_STEP_LABELS[step]}</span>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" /> </article>
</article> {idx < visiblePlanSteps.length - 1 ? (
{index < completedScenes.length - 1 ? ( <div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div> ) : null}
) : null} </Fragment>
</Fragment> ))}
))} </div>
</div> ) : null}
{completedScenes.length > 0 && primaryVideo ? ( {/* Connector: plan → images */}
{hasImaging ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div> <div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null} ) : null}
{primaryVideo ? ( {/* Storyboard image nodes — side by side per scene */}
<article className="ecom-video-flow-node ecom-video-flow-node--final is-completed" aria-label="成片节点,已完成"> {hasImaging ? (
<div className="ecom-video-flow-node__media"> <div className="ecom-video-scene-strip" aria-label="分镜图片节点">
<video src={primaryVideo} muted playsInline loop autoPlay /> {scenes.map((scene, idx) => {
</div> const imgReady = !!scene.imageUrl;
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" /> const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
</article> const cls = imgReady ? "is-completed" : imgRunning ? "is-active" : "";
return (
<Fragment key={`img-${scene.sceneId}`}>
<article className={`ecom-video-flow-node ecom-video-flow-node--image ${cls}`}
aria-label={`分镜 ${scene.sceneId}`} title={`分镜 ${scene.sceneId}`}>
<div className="ecom-video-flow-node__media">
{imgReady ? <img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
: imgRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
: <div className="ecom-video-flow-node__placeholder"></div>}
</div>
{imgRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-flow-node__label">{scene.sceneId}</span>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{idx < scenes.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
);
})}
</div>
) : null}
{/* Connector: images → videos */}
{hasRendering ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
{/* Video nodes — side by side per scene */}
{hasRendering ? (
<div className="ecom-video-scene-strip" aria-label="视频分镜节点">
{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 (
<Fragment key={`vid-${scene.sceneId}`}>
<article className={`ecom-video-flow-node ecom-video-flow-node--video ${cls}`}
aria-label={`镜头 ${scene.sceneId}`} title={`镜头 ${scene.sceneId}`}>
<div className="ecom-video-flow-node__media">
{vidReady ? <video src={scene.resultUrl!} muted playsInline loop autoPlay />
: vidRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
: vidFailed ? <div className="ecom-video-flow-node__placeholder"></div>
: <div className="ecom-video-flow-node__placeholder"></div>}
</div>
{vidRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-flow-node__label">{scene.sceneId}</span>
{vidFailed ? (
<button type="button" className="ecom-video-flow-node__retry"
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
title="重试此镜头">
<ReloadOutlined />
</button>
) : null}
{vidFailed && scene.error ? (
<span className="ecom-video-flow-node__error" title={scene.error}>{scene.error.slice(0, 20)}</span>
) : null}
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{idx < scenes.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
);
})}
</div>
) : null} ) : null}
</div> </div>
)} )}
{/* ── Delivery dock ────────────────────────────── */}
{primaryVideo ? ( {primaryVideo ? (
<div className="ecom-video-flow-dock" aria-label="视频交付操作"> <div className="ecom-video-flow-dock" aria-label="视频交付操作">
<button type="button" aria-label="下载当前视频" title="下载当前视频" onClick={() => void handleDownload(primaryVideo)}><DownloadOutlined /></button> <button type="button" onClick={() => void handleDownloadAll()} title={`下载全部 ${completedScenes.length} 个视频`}><DownloadOutlined /></button>
<button type="button" aria-label="保存到资产库" title="保存到资产库" onClick={() => void handleSaveAsset(primaryVideo)}><FolderAddOutlined /></button> <button type="button" onClick={() => void handleSaveAllAssets()} title={`保存全部 ${completedScenes.length} 个视频到资产库`}><FolderAddOutlined /></button>
<button type="button" aria-label="导入画布" title="导入画布" onClick={() => void handleImportToCanvas(primaryVideo)}><SendOutlined /></button> {primaryVideo ? <button type="button" onClick={() => void handleImportToCanvas(primaryVideo)} title="导入画布"><SendOutlined /></button> : null}
<button type="button" aria-label="复制视频链接" title="复制视频链接" onClick={() => void navigator.clipboard.writeText(primaryVideo)}><CopyOutlined /></button> {primaryVideo ? <button type="button" onClick={() => void navigator.clipboard.writeText(primaryVideo)} title="复制链接"><CopyOutlined /></button> : null}
</div> </div>
) : null} ) : null}
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null} {actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
@@ -0,0 +1,60 @@
import type {
EcommerceVideoStage,
EcommerceVideoSceneTask,
EcommerceVideoPlanResult,
PlanStep,
} from "./ecommerceVideoTypes";
const KEEPALIVE_KEY = "omniai:ecommerce-video-workspace";
interface EcommerceVideoKeepalive {
stage: EcommerceVideoStage;
completedSteps: PlanStep[];
planResult: EcommerceVideoPlanResult | null;
scenes: EcommerceVideoSceneTask[];
sourceImageUrls: string[];
savedAt: number;
}
export function saveEcommerceVideoState(state: {
stage: EcommerceVideoStage;
completedSteps: PlanStep[];
planResult: EcommerceVideoPlanResult | null;
scenes: EcommerceVideoSceneTask[];
sourceImageUrls?: string[];
}): void {
try {
const entry: EcommerceVideoKeepalive = {
...state,
sourceImageUrls: state.sourceImageUrls || [],
savedAt: Date.now(),
};
window.localStorage.setItem(KEEPALIVE_KEY, JSON.stringify(entry));
} catch {
// quota exceeded — silently drop
}
}
export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null {
try {
const raw = window.localStorage.getItem(KEEPALIVE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as EcommerceVideoKeepalive;
// Discard entries older than 2 hours
if (Date.now() - (parsed.savedAt || 0) > 2 * 60 * 60 * 1000) {
clearEcommerceVideoState();
return null;
}
return parsed;
} catch {
return null;
}
}
export function clearEcommerceVideoState(): void {
try {
window.localStorage.removeItem(KEEPALIVE_KEY);
} catch {
// ignore
}
}
@@ -20,6 +20,7 @@ import type {
export interface PlanCallbacks { export interface PlanCallbacks {
onStepStart: (step: PlanStep) => void; onStepStart: (step: PlanStep) => void;
onStepDone: (step: PlanStep) => void; onStepDone: (step: PlanStep) => void;
onImagesUploaded?: (urls: string[]) => void;
signal?: AbortSignal; signal?: AbortSignal;
} }
@@ -46,7 +47,9 @@ export async function runVideoPlan(
// skip images that fail to upload // skip images that fail to upload
} }
} }
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
onStepDone("upload"); onStepDone("upload");
callbacks.onImagesUploaded?.(imageUrls);
onStepStart("analyze"); onStepStart("analyze");
const imageDesc = await analyzeProductImages(imageUrls, signal); const imageDesc = await analyzeProductImages(imageUrls, signal);
@@ -80,6 +83,45 @@ export async function runVideoPlan(
return { imageUrls, summary, selling, creatives, storyboard, videoPrompts, compliance }; return { imageUrls, summary, selling, creatives, storyboard, videoPrompts, compliance };
} }
export interface RenderSceneImageInput {
sceneId: number;
prompt: string;
aspectRatio: string;
}
export interface RenderImageCallbacks {
onSceneImageSubmitted: (sceneId: number, taskId: string) => void;
onSceneImageProgress: (sceneId: number, progress: number) => void;
onSceneImageCompleted: (sceneId: number, resultUrl: string) => void;
onSceneImageFailed: (sceneId: number, error: string) => void;
}
export async function renderSceneImage(
input: RenderSceneImageInput,
callbacks: RenderImageCallbacks,
abortRef: { current: boolean },
): Promise<void> {
const { taskId } = await aiGenerationClient.createImageTask({
model: "gpt-image-2",
prompt: input.prompt,
ratio: input.aspectRatio,
quality: "2K",
});
callbacks.onSceneImageSubmitted(input.sceneId, taskId);
const resultUrl = await waitForTask(taskId, {
abortRef,
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
});
if (resultUrl) {
callbacks.onSceneImageCompleted(input.sceneId, resultUrl);
} else {
callbacks.onSceneImageFailed(input.sceneId, "图片生成未返回结果");
}
}
export interface RenderSceneInput { export interface RenderSceneInput {
sceneId: number; sceneId: number;
prompt: string; prompt: string;
@@ -114,7 +156,7 @@ export async function renderScene(
duration: input.durationSeconds, duration: input.durationSeconds,
quality: input.resolution, quality: input.resolution,
resolution: input.resolution, resolution: input.resolution,
imageUrl: input.imageUrl, frameMode: "start-end",
referenceUrls: [input.imageUrl], referenceUrls: [input.imageUrl],
hasReferenceVideo: false, hasReferenceVideo: false,
}); });
@@ -137,12 +179,12 @@ export function buildSceneTasks(
plan: EcommerceVideoPlanResult, plan: EcommerceVideoPlanResult,
): EcommerceVideoSceneTask[] { ): EcommerceVideoSceneTask[] {
return plan.storyboard.scenes.map((scene) => { return plan.storyboard.scenes.map((scene) => {
const prompt = plan.videoPrompts.find((p) => p.scene_id === scene.scene_id); const matchedPrompt = plan.videoPrompts.find((p) => p.scene_id === scene.scene_id);
return { return {
sceneId: scene.scene_id, sceneId: scene.scene_id,
prompt: prompt?.positive_prompt || scene.visual_description, prompt: matchedPrompt?.positive_prompt || scene.visual_description,
durationSeconds: Number.parseInt(scene.duration, 10) || 5, durationSeconds: Number.parseInt(scene.duration, 10) || 5,
status: "idle", status: "idle" as const,
progress: 0, progress: 0,
}; };
}); });
@@ -12,6 +12,8 @@ export type EcommerceVideoStage =
| "uploading" | "uploading"
| "planning" | "planning"
| "planned" | "planned"
| "imaging"
| "imaged"
| "rendering" | "rendering"
| "partial_failed" | "partial_failed"
| "completed" | "completed"
@@ -22,11 +24,13 @@ export type SceneTaskStatus = "idle" | "pending" | "running" | "completed" | "fa
export interface EcommerceVideoSceneTask { export interface EcommerceVideoSceneTask {
sceneId: number; sceneId: number;
taskId?: string; taskId?: string;
imageTaskId?: string;
prompt: string; prompt: string;
durationSeconds: number; durationSeconds: number;
status: SceneTaskStatus; status: SceneTaskStatus;
progress: number; progress: number;
resultUrl?: string | null; resultUrl?: string | null;
imageUrl?: string | null;
error?: string | null; error?: string | null;
} }
@@ -68,3 +72,8 @@ export const PLAN_STEP_LABELS: Record<PlanStep, string> = {
prompts: "生成镜头提示词", prompts: "生成镜头提示词",
compliance: "合规检查", compliance: "合规检查",
}; };
/** Completed plan steps that should show as text nodes in the flow map (skip upload). */
export const PLAN_STEPS_DISPLAY: PlanStep[] = [
"analyze", "summary", "selling", "creative", "storyboard", "prompts", "compliance",
];
+4 -4
View File
@@ -45,7 +45,7 @@ interface HomePageProps {
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void; onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
} }
const HOME_BACKGROUND_VIDEO = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/%E6%A0%B7%E7%89%87.mp4"; const HOME_BACKGROUND_VIDEO = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban/hero-bg.mp4";
const HOME_CAROUSEL_IMAGES = [ const HOME_CAROUSEL_IMAGES = [
{ imageUrl: heroImage1, title: "灵感生成" }, { imageUrl: heroImage1, title: "灵感生成" },
@@ -196,7 +196,7 @@ function EcommerceFeatureShowcase() {
); );
} }
function HomePage({ onOpenGenerate, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor, onSelectView, onOpenImageTool }: HomePageProps) { function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor, onSelectView, onOpenImageTool }: HomePageProps) {
const [splashDismissed, setSplashDismissed] = useState(() => sessionStorage.getItem("omniai:splash-seen") === "1"); const [splashDismissed, setSplashDismissed] = useState(() => sessionStorage.getItem("omniai:splash-seen") === "1");
const [activeSlideIndex, setActiveSlideIndex] = useState(0); const [activeSlideIndex, setActiveSlideIndex] = useState(0);
const [carouselMotion, setCarouselMotion] = useState<HomeCarouselMotion | null>(null); const [carouselMotion, setCarouselMotion] = useState<HomeCarouselMotion | null>(null);
@@ -313,7 +313,7 @@ function HomePage({ onOpenGenerate, onOpenEcommerce, onOpenScriptReview, onOpenT
<section className="omni-home__hero" aria-label="OmniAI 首页"> <section className="omni-home__hero" aria-label="OmniAI 首页">
<div className="omni-home__copy"> <div className="omni-home__copy">
<h1>OmniAI </h1> <h1>OmniAI </h1>
<p>绿</p> <p>AIGC与电商</p>
</div> </div>
<div className={`omni-home__carousel${carouselIsResetting ? " is-resetting" : ""}`} aria-label="创作案例轮播"> <div className={`omni-home__carousel${carouselIsResetting ? " is-resetting" : ""}`} aria-label="创作案例轮播">
@@ -352,7 +352,7 @@ function HomePage({ onOpenGenerate, onOpenEcommerce, onOpenScriptReview, onOpenT
<PlusOutlined /> <PlusOutlined />
<span></span> <span></span>
</button> </button>
<button type="button" className="omni-home__entry omni-home__entry--primary" onClick={onOpenGenerate}> <button type="button" className="omni-home__entry omni-home__entry--primary" onClick={onOpenCanvas || onOpenGenerate}>
<PlayCircleOutlined /> <PlayCircleOutlined />
<span></span> <span></span>
</button> </button>
@@ -27,6 +27,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types"; import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { translateTaskError } from "../../utils/translateTaskError"; import { translateTaskError } from "../../utils/translateTaskError";
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions"; import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
import { useCanvasDrawing } from "./useCanvasDrawing"; import { useCanvasDrawing } from "./useCanvasDrawing";
@@ -139,13 +140,39 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
const [generationError, setGenerationError] = useState<string | null>(null); const [generationError, setGenerationError] = useState<string | null>(null);
const abortRef = useRef(false); const abortRef = useRef(false);
const taskIdRef = useRef<string | null>(null); const taskIdRef = useRef<string | null>(null);
const keepaliveRestoredRef = useRef(false);
// Keep-alive: restore saved task on mount
useEffect(() => {
if (keepaliveRestoredRef.current) return;
keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("imagewb");
if (!saved || saved.resultUrl) return;
setIsGenerating(true);
abortRef.current = false;
taskIdRef.current = saved.taskId;
void waitForTask(saved.taskId, {
onProgress: (e) => {
setTaskProgress(Math.max(0, Math.min(100, Math.trunc(e.progress || 0))));
setStatus(`${e.status} / ${e.progress}%`);
if (e.status === "completed" && e.resultUrl) {
setResultImages([e.resultUrl]);
clearToolTaskState("imagewb");
setIsGenerating(false);
setStatus("恢复任务完成");
}
if (e.status === "failed") {
clearToolTaskState("imagewb");
setIsGenerating(false);
setStatus("恢复任务失败");
}
},
});
}, []);
useEffect(() => { useEffect(() => {
return () => { return () => {
abortRef.current = true; abortRef.current = true;
if (taskIdRef.current) {
aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {});
}
}; };
}, []); }, []);
@@ -155,6 +182,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {}); aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {});
taskIdRef.current = null; taskIdRef.current = null;
} }
clearToolTaskState("imagewb");
setGenerating(false); setGenerating(false);
setGenerationProgress(0); setGenerationProgress(0);
setStatus("已取消"); setStatus("已取消");
@@ -305,6 +333,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
referenceUrls: refUrls, referenceUrls: refUrls,
}); });
taskIdRef.current = taskId; taskIdRef.current = taskId;
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
const tempUrl = await pollTaskUntilDone(taskId); const tempUrl = await pollTaskUntilDone(taskId);
if (tempUrl) { if (tempUrl) {
@@ -385,6 +414,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
referenceUrls: refUrls, referenceUrls: refUrls,
}); });
taskIdRef.current = taskId; taskIdRef.current = taskId;
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
const tempUrl = await pollTaskUntilDone(taskId); const tempUrl = await pollTaskUntilDone(taskId);
if (tempUrl) { if (tempUrl) {
@@ -530,6 +560,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
referenceUrls: refUrls, referenceUrls: refUrls,
}); });
taskIdRef.current = taskId; taskIdRef.current = taskId;
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
const tempUrl = await pollTaskUntilDone(taskId); const tempUrl = await pollTaskUntilDone(taskId);
if (tempUrl) { if (tempUrl) {
@@ -544,6 +575,10 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
} }
setResultImages(results); setResultImages(results);
clearToolTaskState("imagewb");
if (results.length) {
saveToolTaskState("imagewb", { taskId: taskIdRef.current || "", resultUrl: results[0], status: "完成", progress: 100 });
}
setStatus(results.length ? `生成完成,共 ${results.length}` : "生成已取消"); setStatus(results.length ? `生成完成,共 ${results.length}` : "生成已取消");
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "生成失败"; const msg = err instanceof Error ? err.message : "生成失败";
+1 -10
View File
@@ -7,10 +7,7 @@ import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
HighlightOutlined, HighlightOutlined,
PictureOutlined,
ShoppingOutlined,
SwapOutlined, SwapOutlined,
TableOutlined,
ThunderboltOutlined, ThunderboltOutlined,
VideoCameraOutlined, VideoCameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
@@ -23,7 +20,7 @@ interface MorePageProps {
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void; onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
} }
type ToolCategory = "image" | "video" | "template"; type ToolCategory = "image" | "video";
type FilterKey = "all" | ToolCategory | "upcoming"; type FilterKey = "all" | ToolCategory | "upcoming";
interface MoreTool { interface MoreTool {
@@ -49,9 +46,6 @@ const tools: MoreTool[] = [
{ id: "digitalHuman", title: "数字人", text: "参考人像与音频生成口播视频", icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true }, { id: "digitalHuman", title: "数字人", text: "参考人像与音频生成口播视频", icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true },
{ id: "characterMix", title: "角色迁移", text: "人物图迁移到参考视频动作", icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true }, { id: "characterMix", title: "角色迁移", text: "人物图迁移到参考视频动作", icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true },
{ id: "avatarConsole", title: "数字人控制台", text: "形象、播报、互动与接入配置", icon: <DashboardOutlined />, category: "video", target: "avatarConsole", ready: true }, { id: "avatarConsole", title: "数字人控制台", text: "形象、播报、互动与接入配置", icon: <DashboardOutlined />, category: "video", target: "avatarConsole", ready: true },
{ id: "ecommerce", title: "示例模板", text: "电商场景与最近项目", icon: <ShoppingOutlined />, category: "template", target: "ecommerceTemplates", ready: true },
{ id: "grid", title: "多宫格", text: "9/25 宫格快速试拍", icon: <TableOutlined />, category: "template", ready: false, badge: "即将上线" },
{ id: "refOrganize", title: "参考图整理", text: "素材进入资产库前的轻处理", icon: <PictureOutlined />, category: "template", ready: false, badge: "即将上线" },
]; ];
interface FeaturedTool { interface FeaturedTool {
@@ -89,20 +83,17 @@ const featuredTools: FeaturedTool[] = [
const categoryLabels: Record<ToolCategory, string> = { const categoryLabels: Record<ToolCategory, string> = {
image: "图像创作", image: "图像创作",
video: "视频生成", video: "视频生成",
template: "模板与素材",
}; };
const categoryIcons: Record<ToolCategory, ReactNode> = { const categoryIcons: Record<ToolCategory, ReactNode> = {
image: <EditOutlined />, image: <EditOutlined />,
video: <VideoCameraOutlined />, video: <VideoCameraOutlined />,
template: <ShoppingOutlined />,
}; };
const filters: { key: FilterKey; label: string }[] = [ const filters: { key: FilterKey; label: string }[] = [
{ key: "all", label: "全部" }, { key: "all", label: "全部" },
{ key: "image", label: "图像" }, { key: "image", label: "图像" },
{ key: "video", label: "视频" }, { key: "video", label: "视频" },
{ key: "template", label: "模板" },
{ key: "upcoming", label: "即将上线" }, { key: "upcoming", label: "即将上线" },
]; ];
+6 -88
View File
@@ -10,7 +10,6 @@ import {
SafetyOutlined, SafetyOutlined,
ShareAltOutlined, ShareAltOutlined,
UserOutlined, UserOutlined,
WechatOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react"; import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
@@ -37,7 +36,7 @@ interface ProfilePageProps {
onDeleteProject?: (project: WebProjectSummary) => void; onDeleteProject?: (project: WebProjectSummary) => void;
} }
type AuthTab = "password" | "email" | "phone" | "wechat"; type AuthTab = "password" | "email" | "phone";
type ProfilePanel = "works" | "projects" | "assets" | "community"; type ProfilePanel = "works" | "projects" | "assets" | "community";
type AccountPanel = "credits" | "tasks"; type AccountPanel = "credits" | "tasks";
@@ -170,8 +169,6 @@ function ProfilePage({
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [smsCooldown, setSmsCooldown] = useState(0); const [smsCooldown, setSmsCooldown] = useState(0);
const [isSendingSms, setIsSendingSms] = useState(false); const [isSendingSms, setIsSendingSms] = useState(false);
const [wechatTicket, setWechatTicket] = useState<{ url?: string; state?: string; message?: string; configured?: boolean } | null>(null);
const [wechatStatus, setWechatStatus] = useState<string | null>(null);
const [activePanel, setActivePanel] = useState<ProfilePanel>("works"); const [activePanel, setActivePanel] = useState<ProfilePanel>("works");
const [accountPanel, setAccountPanel] = useState<AccountPanel>("credits"); const [accountPanel, setAccountPanel] = useState<AccountPanel>("credits");
@@ -248,55 +245,6 @@ function ProfilePage({
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [smsCooldown]); }, [smsCooldown]);
useEffect(() => {
if (authTab !== "wechat" || isLoggedIn) return;
let cancelled = false;
let pollTimer: number | undefined;
const startWechatLogin = async () => {
setWechatStatus("正在创建微信登录二维码...");
setWechatTicket(null);
try {
const ticket = await keyServerClient.getWechatLoginTicket();
if (cancelled) return;
setWechatTicket(ticket);
if (!ticket.configured || !ticket.url || !ticket.state) {
setWechatStatus(ticket.message || "微信登录暂未配置");
return;
}
setWechatStatus("请使用微信扫码登录");
pollTimer = window.setInterval(() => {
void keyServerClient
.getWechatLoginSession(ticket.state!)
.then(async (result) => {
if (cancelled) return;
if (result.status === "completed" && result.session) {
if (pollTimer) window.clearInterval(pollTimer);
setWechatStatus("微信登录成功,正在进入工作台...");
await onAuthComplete?.(result.session);
} else if (result.status !== "pending") {
if (pollTimer) window.clearInterval(pollTimer);
setWechatStatus(result.error || "微信登录已失效,请刷新二维码");
}
})
.catch((error) => {
if (!cancelled) setWechatStatus(error instanceof Error ? error.message : "微信登录状态查询失败");
});
}, 2000);
} catch (error) {
if (!cancelled) setWechatStatus(error instanceof Error ? error.message : "微信登录二维码创建失败");
}
};
void startWechatLogin();
return () => {
cancelled = true;
if (pollTimer) window.clearInterval(pollTimer);
};
}, [authTab, isLoggedIn, onAuthComplete]);
const handleSendSms = async () => { const handleSendSms = async () => {
if (smsCooldown > 0 || !phone.trim() || isSendingSms) return; if (smsCooldown > 0 || !phone.trim() || isSendingSms) return;
if (mode === "register" && !betaCode.trim()) { if (mode === "register" && !betaCode.trim()) {
@@ -359,7 +307,7 @@ function ProfilePage({
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (isSubmitting || authTab === "wechat") return; if (isSubmitting) return;
const errors: Record<string, string> = {}; const errors: Record<string, string> = {};
if (mode === "register") { if (mode === "register") {
@@ -814,9 +762,6 @@ function ProfilePage({
<button type="button" className={authTab === "phone" ? "is-active" : ""} onClick={() => { setAuthTab("phone"); setFieldErrors({}); }}> <button type="button" className={authTab === "phone" ? "is-active" : ""} onClick={() => { setAuthTab("phone"); setFieldErrors({}); }}>
<MobileOutlined /> <MobileOutlined />
</button> </button>
<button type="button" className={authTab === "wechat" ? "is-active" : ""} onClick={() => { setAuthTab("wechat"); setFieldErrors({}); }}>
<WechatOutlined />
</button>
</div> </div>
{pendingActionLabel ? ( {pendingActionLabel ? (
@@ -827,7 +772,7 @@ function ProfilePage({
) : null} ) : null}
<form className="auth-page__form" onSubmit={(event) => void handleSubmit(event)}> <form className="auth-page__form" onSubmit={(event) => void handleSubmit(event)}>
{mode === "register" && authTab !== "wechat" ? ( {mode === "register" ? (
<label className={`auth-page__field${fieldErrors.betaCode ? " auth-page__field--error" : ""}`}> <label className={`auth-page__field${fieldErrors.betaCode ? " auth-page__field--error" : ""}`}>
<span> <span>
<SafetyOutlined /> / <SafetyOutlined /> /
@@ -967,35 +912,11 @@ function ProfilePage({
</> </>
) : null} ) : null}
{authTab === "wechat" ? (
<div className="auth-page__wechat-qr">
<div className="auth-page__qr-placeholder">
{wechatTicket?.url ? (
<>
<iframe className="auth-page__wechat-frame" title="微信扫码登录" src={wechatTicket.url} />
<a className="auth-page__wechat-link" href={wechatTicket.url} target="_blank" rel="noreferrer">
</a>
</>
) : (
<>
<WechatOutlined />
<span></span>
<p>{wechatStatus || "正在准备微信登录"}</p>
</>
)}
</div>
{wechatStatus ? <p className="auth-page__wechat-status">{wechatStatus}</p> : null}
</div>
) : null}
{notice ? <p className="auth-page__notice">{notice}</p> : null} {notice ? <p className="auth-page__notice">{notice}</p> : null}
{authTab !== "wechat" ? ( <button type="submit" className="auth-page__submit" disabled={isSubmitting}>
<button type="submit" className="auth-page__submit" disabled={isSubmitting}> {isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"} </button>
</button>
) : null}
<div className="auth-page__agreement"> <div className="auth-page__agreement">
<span> <span>
@@ -1009,9 +930,6 @@ function ProfilePage({
</div> </div>
<div className="auth-page__social"> <div className="auth-page__social">
<button type="button" className="auth-page__social-btn" title="微信登录" onClick={() => setAuthTab("wechat")}>
<WechatOutlined />
</button>
<button type="button" className="auth-page__social-btn" title="手机号登录" onClick={() => setAuthTab("phone")}> <button type="button" className="auth-page__social-btn" title="手机号登录" onClick={() => setAuthTab("phone")}>
<MobileOutlined /> <MobileOutlined />
</button> </button>
@@ -18,6 +18,7 @@ import {
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { translateTaskError } from "../../utils/translateTaskError"; import { translateTaskError } from "../../utils/translateTaskError";
import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection"; import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection";
import { summarizeUrl, formatFileSize, fileToDataUrl, wait } from "../../utils/toolPageUtils"; import { summarizeUrl, formatFileSize, fileToDataUrl, wait } from "../../utils/toolPageUtils";
@@ -88,6 +89,7 @@ function ResolutionUpscalePage({
const [isSavingAsset, setIsSavingAsset] = useState(false); const [isSavingAsset, setIsSavingAsset] = useState(false);
const activeTaskIdRef = useRef(activeTaskId); const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId; activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -101,13 +103,25 @@ function ResolutionUpscalePage({
}; };
}, [resultPreview]); }, [resultPreview]);
// Keep-alive: restore saved task on mount
useEffect(() => {
if (keepaliveRestoredRef.current) return;
keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("upscale");
if (!saved || saved.resultUrl) return;
setSourceName(saved.sourceName || "");
setSourceUrl(saved.sourceUrl || "");
setIsProcessing(true);
cancelRef.current = false;
pollRunRef.current += 1;
void waitForTaskResult(saved.taskId, mode).catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
return () => { return () => {
// Stop polling but keep server task alive — keep-alive will resume on remount
pollRunRef.current += 1; pollRunRef.current += 1;
cancelRef.current = true; cancelRef.current = true;
if (activeTaskIdRef.current) {
aiGenerationClient.cancelTask(activeTaskIdRef.current).catch(() => {});
}
}; };
}, []); }, []);
@@ -182,6 +196,7 @@ function ResolutionUpscalePage({
const runId = ++pollRunRef.current; const runId = ++pollRunRef.current;
setActiveTaskId(taskId); setActiveTaskId(taskId);
setTaskProgress(5); setTaskProgress(5);
saveToolTaskState("upscale", { taskId, sourceName, sourceUrl, status: `任务 ${taskId}`, progress: 5 });
await waitForTask(taskId, { await waitForTask(taskId, {
abortRef: cancelRef, abortRef: cancelRef,
@@ -195,6 +210,8 @@ function ResolutionUpscalePage({
setVideoViewMode("result"); setVideoViewMode("result");
setStatus(`${taskMode === "image" ? "图片" : "视频"}超分完成:${summarizeUrl(e.resultUrl)}`); setStatus(`${taskMode === "image" ? "图片" : "视频"}超分完成:${summarizeUrl(e.resultUrl)}`);
setTaskProgress(100); setTaskProgress(100);
clearToolTaskState("upscale");
saveToolTaskState("upscale", { taskId, resultUrl: e.resultUrl, resultPreview: e.resultUrl, sourceName, sourceUrl, status: "完成", progress: 100 });
} }
}, },
}); });
@@ -210,6 +227,7 @@ function ResolutionUpscalePage({
} }
setIsProcessing(false); setIsProcessing(false);
setStatus("已取消"); setStatus("已取消");
clearToolTaskState("upscale");
}, [activeTaskId]); }, [activeTaskId]);
const handleDownload = async () => { const handleDownload = async () => {
@@ -475,7 +475,17 @@ function ScriptTokensPage() {
</div> </div>
<div className={`script-eval-v5-right-content${result ? " is-report" : ""}`}> <div className={`script-eval-v5-right-content${result ? " is-report" : ""}`}>
{!result && ( {loading ? (
<div className="script-eval-v5-input-section">
<div className="script-eval-v5-illustration" aria-label="评测中">
<div className="script-eval-v5-loading">
<div className="page-loading-spinner" />
<strong>AI ...</strong>
<p> 15-30 </p>
</div>
</div>
</div>
) : !result && (
<div className="script-eval-v5-input-section"> <div className="script-eval-v5-input-section">
<div className="script-eval-v5-illustration" aria-label="上传剧本并开始评测"> <div className="script-eval-v5-illustration" aria-label="上传剧本并开始评测">
<div <div
@@ -501,7 +511,10 @@ function ScriptTokensPage() {
{evalError && ( {evalError && (
<div className="script-eval-v5-error" role="alert"> <div className="script-eval-v5-error" role="alert">
<span></span><span>{evalError}</span> <span></span><span>{evalError}</span>
<button type="button" className="script-eval-v5-retry-btn" onClick={() => void handleEvaluate()} disabled={!hasContent}>
</button>
</div> </div>
)} )}
</div> </div>
@@ -523,7 +536,7 @@ function ScriptTokensPage() {
<div className="script-eval-report__score-line"> <div className="script-eval-report__score-line">
<span style={{ width: `${animatedScore}%` }} /> <span style={{ width: `${animatedScore}%` }} />
</div> </div>
<div className="script-eval-report__beat"> <b>{beatPct}%</b> </div> <div className="script-eval-report__beat">{result.totalScore >= 90 ? "优秀" : result.totalScore >= 80 ? "良好" : result.totalScore >= 70 ? "中等" : "待提升"}{result.totalScore >= 85 ? "具备商业开发潜力" : "建议针对性优化后再提交"}</div>
</div> </div>
<div className="script-eval-report__summary"> <div className="script-eval-report__summary">
@@ -84,10 +84,10 @@ function formatDayLabel(value: string): string {
type TrendPoint = { date: string; usedCents: number; taskCount: number }; type TrendPoint = { date: string; usedCents: number; taskCount: number };
function UsageTrendChart({ data }: { data: TrendPoint[] }) { function UsageTrendChart({ data }: { data: TrendPoint[] }) {
const W = 480; const W = 680;
const H = 120; const H = 200;
const padX = 28; const padX = 32;
const padY = 18; const padY = 24;
const maxCents = Math.max(1, ...data.map((d) => d.usedCents)); const maxCents = Math.max(1, ...data.map((d) => d.usedCents));
const stepX = data.length > 1 ? (W - padX * 2) / (data.length - 1) : 0; const stepX = data.length > 1 ? (W - padX * 2) / (data.length - 1) : 0;
const yOf = (cents: number) => H - padY - (cents / maxCents) * (H - padY * 2); const yOf = (cents: number) => H - padY - (cents / maxCents) * (H - padY * 2);
@@ -15,6 +15,7 @@ import {
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection"; import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection";
import { summarizeUrl, formatFileSize, fileToDataUrl } from "../../utils/toolPageUtils"; import { summarizeUrl, formatFileSize, fileToDataUrl } from "../../utils/toolPageUtils";
import TaskStatusBar from "../../components/TaskStatusBar"; import TaskStatusBar from "../../components/TaskStatusBar";
@@ -74,14 +75,26 @@ function SubtitleRemovalPage({
const [isSavingAsset, setIsSavingAsset] = useState(false); const [isSavingAsset, setIsSavingAsset] = useState(false);
const activeTaskIdRef = useRef(activeTaskId); const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId; activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
// Keep-alive: restore saved task on mount
useEffect(() => {
if (keepaliveRestoredRef.current) return;
keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("subtitle");
if (!saved || saved.resultUrl) return;
setSourceName(saved.sourceName || "");
setSourceUrl(saved.sourceUrl || "");
setIsProcessing(true);
cancelRef.current = false;
pollRunRef.current += 1;
void waitForTaskResult(saved.taskId).catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
return () => { return () => {
pollRunRef.current += 1; pollRunRef.current += 1;
cancelRef.current = true; cancelRef.current = true;
if (activeTaskIdRef.current) {
aiGenerationClient.cancelTask(activeTaskIdRef.current).catch(() => {});
}
}; };
}, []); }, []);
@@ -158,6 +171,7 @@ function SubtitleRemovalPage({
const runId = ++pollRunRef.current; const runId = ++pollRunRef.current;
setActiveTaskId(taskId); setActiveTaskId(taskId);
setTaskProgress(5); setTaskProgress(5);
saveToolTaskState("subtitle", { taskId, sourceName, sourceUrl, status: `任务 ${taskId}`, progress: 5 });
await waitForTask(taskId, { await waitForTask(taskId, {
abortRef: cancelRef, abortRef: cancelRef,
@@ -170,6 +184,8 @@ function SubtitleRemovalPage({
setResultUrl(e.resultUrl); setResultUrl(e.resultUrl);
setStatus("字幕去除完成"); setStatus("字幕去除完成");
setTaskProgress(100); setTaskProgress(100);
clearToolTaskState("subtitle");
saveToolTaskState("subtitle", { taskId, resultUrl: e.resultUrl, resultPreview: e.resultUrl, sourceName, sourceUrl, status: "完成", progress: 100 });
} }
}, },
}); });
@@ -185,6 +201,7 @@ function SubtitleRemovalPage({
} }
setIsProcessing(false); setIsProcessing(false);
setStatus("已取消"); setStatus("已取消");
clearToolTaskState("subtitle");
}, [activeTaskId]); }, [activeTaskId]);
const handleDownload = async () => { const handleDownload = async () => {
@@ -15,6 +15,7 @@ import {
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection"; import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection";
import { summarizeUrl, formatFileSize, fileToDataUrl } from "../../utils/toolPageUtils"; import { summarizeUrl, formatFileSize, fileToDataUrl } from "../../utils/toolPageUtils";
import TaskStatusBar from "../../components/TaskStatusBar"; import TaskStatusBar from "../../components/TaskStatusBar";
@@ -50,6 +51,7 @@ function WatermarkRemovalPage({
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const activeTaskIdRef = useRef(activeTaskId); const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId; activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -57,13 +59,24 @@ function WatermarkRemovalPage({
}; };
}, [sourcePreview]); }, [sourcePreview]);
// Keep-alive: restore saved task on mount
useEffect(() => {
if (keepaliveRestoredRef.current) return;
keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("watermark");
if (!saved || saved.resultUrl) return;
setSourceName(saved.sourceName || "");
setSourceUrl(saved.sourceUrl || "");
setIsProcessing(true);
cancelRef.current = false;
pollRunRef.current += 1;
void waitForTaskResult(saved.taskId).catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
return () => { return () => {
pollRunRef.current += 1; pollRunRef.current += 1;
cancelRef.current = true; cancelRef.current = true;
if (activeTaskIdRef.current) {
aiGenerationClient.cancelTask(activeTaskIdRef.current).catch(() => {});
}
}; };
}, []); }, []);
@@ -140,6 +153,7 @@ function WatermarkRemovalPage({
const runId = ++pollRunRef.current; const runId = ++pollRunRef.current;
setActiveTaskId(taskId); setActiveTaskId(taskId);
setTaskProgress(5); setTaskProgress(5);
saveToolTaskState("watermark", { taskId, sourceName, sourceUrl, status: `任务 ${taskId}`, progress: 5 });
await waitForTask(taskId, { await waitForTask(taskId, {
abortRef: cancelRef, abortRef: cancelRef,
@@ -152,6 +166,8 @@ function WatermarkRemovalPage({
setResultPreview(e.resultUrl); setResultPreview(e.resultUrl);
setStatus("去水印完成"); setStatus("去水印完成");
setTaskProgress(100); setTaskProgress(100);
clearToolTaskState("watermark");
saveToolTaskState("watermark", { taskId, resultUrl: e.resultUrl, resultPreview: e.resultUrl, sourceName, sourceUrl, status: "完成", progress: 100 });
} }
}, },
}); });
@@ -167,6 +183,7 @@ function WatermarkRemovalPage({
} }
setIsProcessing(false); setIsProcessing(false);
setStatus("已取消"); setStatus("已取消");
clearToolTaskState("watermark");
}, [activeTaskId]); }, [activeTaskId]);
const handleDownload = async () => { const handleDownload = async () => {
+1 -1
View File
@@ -280,6 +280,7 @@ function WorkbenchPage({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false; let cancelled = false;
assetClient assetClient
.list() .list()
@@ -2429,7 +2430,6 @@ function WorkbenchPage({
projects={conversationRecords} projects={conversationRecords}
activeId={activeConversationId ? String(activeConversationId) : null} activeId={activeConversationId ? String(activeConversationId) : null}
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
filterMode={activeMode}
loading={false} loading={false}
error={projectError} error={projectError}
onToggle={() => setSidebarCollapsed((v) => (hasSidebarRecords ? !v : true))} onToggle={() => setSidebarCollapsed((v) => (hasSidebarRecords ? !v : true))}
+96
View File
@@ -0,0 +1,96 @@
/**
* Generic single-task keep-alive for tool pages.
* Persists task state to localStorage so in-progress tasks survive page switches.
*/
const KEEPALIVE_PREFIX = "omniai:tool-task:";
interface ToolTaskKeepalive {
taskId: string;
resultUrl: string;
resultPreview: string;
status: string;
progress: number;
sourceName: string;
sourceUrl: string;
savedAt: number;
}
export function saveToolTaskState(key: string, state: {
taskId: string;
resultUrl?: string;
resultPreview?: string;
status?: string;
progress?: number;
sourceName?: string;
sourceUrl?: string;
}): void {
if (!state.taskId) return;
try {
const entry: ToolTaskKeepalive = {
taskId: state.taskId,
resultUrl: state.resultUrl || "",
resultPreview: state.resultPreview || "",
status: state.status || "",
progress: state.progress || 0,
sourceName: state.sourceName || "",
sourceUrl: state.sourceUrl || "",
savedAt: Date.now(),
};
window.localStorage.setItem(KEEPALIVE_PREFIX + key, JSON.stringify(entry));
} catch { /* quota */ }
}
export function loadToolTaskState(key: string): ToolTaskKeepalive | null {
try {
const raw = window.localStorage.getItem(KEEPALIVE_PREFIX + key);
if (!raw) return null;
const parsed = JSON.parse(raw) as ToolTaskKeepalive;
if (Date.now() - (parsed.savedAt || 0) > 2 * 60 * 60 * 1000) {
clearToolTaskState(key);
return null;
}
if (!parsed.taskId) return null;
return parsed;
} catch { return null; }
}
export function clearToolTaskState(key: string): void {
try { window.localStorage.removeItem(KEEPALIVE_PREFIX + key); } catch { /* ignore */ }
}
const TASK_POLL_INTERVAL = 3000;
const TASK_POLL_TIMEOUT = 30 * 60 * 1000;
export async function pollTaskUntilDone(
taskId: string,
onProgress?: (progress: number) => void,
abortRef?: { current: boolean },
): Promise<string | null> {
const startTime = Date.now();
const { aiGenerationClient } = await import("../../api/aiGenerationClient");
while (true) {
if (abortRef?.current) return null;
if (Date.now() - startTime > TASK_POLL_TIMEOUT) return null;
try {
const task = await aiGenerationClient.getTaskStatus(taskId);
if (!task) return null;
const progress = Math.min(99, task.progress || 0);
onProgress?.(progress);
if (task.status === "completed") {
return task.resultUrl || null;
}
if (task.status === "failed" || task.status === "cancelled") {
return null;
}
} catch {
// retry on next poll
}
await new Promise((r) => setTimeout(r, TASK_POLL_INTERVAL));
}
}
+4 -3
View File
@@ -1,10 +1,11 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { WebProjectSummary, WebCanvasWorkflow } from '../types'; import type { WebProjectSummary, WebCanvasWorkflow } from '../types';
import { createBlankWorkflow } from '../data/workflows';
interface ProjectState { interface ProjectState {
projects: WebProjectSummary[]; projects: WebProjectSummary[];
projectsLoaded: boolean; projectsLoaded: boolean;
canvasWorkflow: WebCanvasWorkflow | null; canvasWorkflow: WebCanvasWorkflow;
currentCanvasProjectId: string | null; currentCanvasProjectId: string | null;
pendingDeleteProject: WebProjectSummary | null; pendingDeleteProject: WebProjectSummary | null;
deleteProjectSubmitting: boolean; deleteProjectSubmitting: boolean;
@@ -13,7 +14,7 @@ interface ProjectState {
interface ProjectActions { interface ProjectActions {
setProjects: (projects: WebProjectSummary[]) => void; setProjects: (projects: WebProjectSummary[]) => void;
setProjectsLoaded: (loaded: boolean) => void; setProjectsLoaded: (loaded: boolean) => void;
setCanvasWorkflow: (workflow: WebCanvasWorkflow | null) => void; setCanvasWorkflow: (workflow: WebCanvasWorkflow) => void;
setCurrentCanvasProjectId: (id: string | null) => void; setCurrentCanvasProjectId: (id: string | null) => void;
openDeleteProject: (project: WebProjectSummary) => void; openDeleteProject: (project: WebProjectSummary) => void;
closeDeleteProject: () => void; closeDeleteProject: () => void;
@@ -24,7 +25,7 @@ interface ProjectActions {
const initialState: ProjectState = { const initialState: ProjectState = {
projects: [], projects: [],
projectsLoaded: false, projectsLoaded: false,
canvasWorkflow: null, canvasWorkflow: createBlankWorkflow(),
currentCanvasProjectId: null, currentCanvasProjectId: null,
pendingDeleteProject: null, pendingDeleteProject: null,
deleteProjectSubmitting: false, deleteProjectSubmitting: false,
+13
View File
@@ -592,6 +592,19 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.studio-canvas-loading-spinner {
width: 36px;
height: 36px;
border: 3px solid rgba(var(--accent-rgb), 0.18);
border-top-color: var(--accent);
border-radius: 50%;
animation: canvas-spin 0.8s linear infinite;
}
@keyframes canvas-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 640px) { @media (max-width: 640px) {
.studio-canvas-project-bar { .studio-canvas-project-bar {
right: 10px; right: 10px;
+201
View File
@@ -122,6 +122,16 @@
white-space: nowrap; white-space: nowrap;
} }
.ecom-video-flowbar__stage-label {
display: inline-flex;
align-items: center;
gap: 6px;
color: #53e5ff;
font-size: 12px;
font-weight: 800;
animation: ecom-video-node-breathe 1.6s ease-in-out infinite;
}
.ecom-video-flow-action { .ecom-video-flow-action {
display: inline-grid; display: inline-grid;
width: 38px; width: 38px;
@@ -484,6 +494,197 @@
} }
} }
/* ── Empty state ─────────────────────────────── */
.ecom-video-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #697486;
font-size: 13px;
}
/* ── Flow map vertical stacking ────────────────── */
.ecom-video-flow-map {
flex-wrap: wrap;
justify-content: center;
padding: 20px 0 40px;
}
/* ── Text nodes (plan steps) ──────────────────── */
.ecom-video-flow-node--text {
display: grid;
place-items: center;
gap: 6px;
width: clamp(64px, 7vw, 90px);
min-height: 74px;
padding: 12px 8px;
border-color: #2a3d30;
background: #131d1a;
text-align: center;
}
.ecom-video-flow-node--text.is-completed {
border-color: #1c4d3a;
background: #162820;
}
.ecom-video-flow-node--text.is-pulsing {
border-color: #53e5ff;
animation: ecom-video-node-breathe 1.2s ease-in-out infinite;
}
.ecom-video-flow-node__text-icon {
display: grid;
width: 24px;
height: 24px;
place-items: center;
border-radius: 999px;
background: #1c4d3a;
color: #34d399;
font-size: 12px;
font-weight: 900;
}
.ecom-video-flow-node--text.is-pulsing .ecom-video-flow-node__text-icon {
background: #1a4d4d;
color: #53e5ff;
}
/* ── Node labels ──────────────────────────────── */
.ecom-video-flow-node__label {
display: block;
max-width: 100%;
overflow: hidden;
color: #9fadb8;
font-size: 11px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.ecom-video-flow-node--source .ecom-video-flow-node__label,
.ecom-video-flow-node--text .ecom-video-flow-node__label {
margin-top: 2px;
}
/* ── Image nodes (storyboard images) ───────────── */
.ecom-video-flow-node--image {
width: clamp(88px, 9vw, 128px);
aspect-ratio: 9 / 13;
}
.ecom-video-flow-node--image .ecom-video-flow-node__media {
position: relative;
width: 100%;
height: 100%;
}
.ecom-video-flow-node--image .ecom-video-flow-node__label {
position: absolute;
left: 0;
right: 0;
bottom: 6px;
text-align: center;
}
.ecom-video-flow-node--image .ecom-video-flow-node__progress {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: auto;
height: auto;
background: none;
color: #53e5ff;
font-size: 15px;
font-weight: 1000;
}
.ecom-video-flow-node--image .ecom-video-flow-node__placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #18231f;
color: #7eeecf;
font-size: 24px;
}
/* ── Video nodes ──────────────────────────────── */
.ecom-video-flow-node--video {
width: clamp(88px, 9vw, 128px);
aspect-ratio: 9 / 16;
}
.ecom-video-flow-node--video .ecom-video-flow-node__label {
position: absolute;
left: 0;
right: 0;
bottom: 6px;
text-align: center;
}
.ecom-video-flow-node--video .ecom-video-flow-node__progress {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: auto;
height: auto;
background: none;
color: #53e5ff;
font-size: 15px;
font-weight: 1000;
}
.ecom-video-flow-node--video .ecom-video-flow-node__placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #18231f;
color: #7eeecf;
font-size: 24px;
}
.ecom-video-flow-node--video .ecom-video-flow-node__media video {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ── Error label ──────────────────────────────── */
.ecom-video-flow-node__error {
position: absolute;
left: 0;
right: 0;
bottom: 24px;
overflow: hidden;
color: #ff9f9f;
font-size: 10px;
font-weight: 800;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Scene strip overflow ─────────────────────── */
.ecom-video-scene-strip--text {
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: #414958 transparent;
}
.ecom-video-scene-strip--text::-webkit-scrollbar {
height: 3px;
}
.ecom-video-scene-strip--text::-webkit-scrollbar-thumb {
background: #414958;
border-radius: 999px;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.ecom-video-flowbar { .ecom-video-flowbar {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
+42
View File
@@ -520,6 +520,48 @@
font-size: 12px; font-size: 12px;
} }
.script-eval-v5-retry-btn {
margin-left: auto;
min-width: 56px;
padding: 4px 14px;
border: 1px solid rgba(255, 107, 53, 0.35);
border-radius: 6px;
background: transparent;
color: #ff6b35;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: background 140ms ease;
}
.script-eval-v5-retry-btn:hover:not(:disabled) {
background: rgba(255, 107, 53, 0.15);
}
.script-eval-v5-retry-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.script-eval-v5-loading {
display: grid;
place-items: center;
gap: 12px;
padding: 32px 20px;
text-align: center;
}
.script-eval-v5-loading strong {
color: var(--fg-body);
font-size: 15px;
font-weight: 800;
}
.script-eval-v5-loading p {
color: var(--fg-muted);
font-size: 12px;
}
/* Hero */ /* Hero */
.script-eval-v5-hero { .script-eval-v5-hero {
margin-bottom: 18px; margin-bottom: 18px;
+1 -1
View File
@@ -4883,7 +4883,7 @@
} }
.management-status-trend { .management-status-trend {
padding: 6px 14px 10px; padding: 12px 18px 16px;
border-top: 1px solid var(--border-weak); border-top: 1px solid var(--border-weak);
} }
+79
View File
@@ -563,3 +563,82 @@
overflow: auto; overflow: auto;
scrollbar-color: rgba(var(--accent-rgb), 0.42) transparent; scrollbar-color: rgba(var(--accent-rgb), 0.42) transparent;
} }
/* ── Info button & popover ────────────────────── */
.info-button {
display: inline-grid;
width: 32px;
height: 32px;
place-items: center;
border: 1px solid rgba(var(--accent-rgb), 0.22);
border-radius: 8px;
background: transparent;
color: var(--fg-muted);
font-size: 15px;
cursor: pointer;
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
}
.info-button:hover {
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.4);
background: rgba(var(--accent-rgb), 0.06);
}
.info-popover-anchor {
position: relative;
}
.info-popover {
position: absolute;
top: calc(100% + 10px);
right: 0;
z-index: 100;
min-width: 280px;
max-width: min(360px, calc(100vw - 32px));
padding: 20px;
border-radius: 14px;
background: var(--bg-panel);
border: 1px solid var(--border-normal);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
}
.info-popover dl {
display: grid;
gap: 12px;
margin: 0 0 16px;
}
.info-popover dt {
color: var(--fg-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.info-popover dd {
margin: 0 0 0 0;
color: var(--fg-body);
font-size: 13px;
line-height: 1.55;
}
.info-popover__links {
display: flex;
gap: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-weak);
}
.info-popover__links a {
color: var(--accent);
font-size: 13px;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
.info-popover__links a:hover {
text-decoration: underline;
}
+5
View File
@@ -18,6 +18,11 @@ export default defineConfig(({ mode }) => {
target: env.VITE_DEV_PROXY || "http://47.110.225.76:3600", target: env.VITE_DEV_PROXY || "http://47.110.225.76:3600",
changeOrigin: true, changeOrigin: true,
}, },
"/dashscope-api": {
target: "https://dashscope.aliyuncs.com",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/dashscope-api/, "/compatible-mode/v1"),
},
}, },
}, },
preview: { preview: {