Merge remote-tracking branch 'origin/master' into feat/profile-ui-polish
This commit is contained in:
+30
-62
@@ -16,6 +16,8 @@ import {
|
|||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
|
import { reportError } from "./utils/errorReporting";
|
||||||
|
import { initNotificationPermission } from "./utils/generationNotifier";
|
||||||
import PageTransition from "./components/PageTransition";
|
import PageTransition from "./components/PageTransition";
|
||||||
import ToastContainer from "./components/toast/ToastContainer";
|
import ToastContainer from "./components/toast/ToastContainer";
|
||||||
import { aiGenerationClient } from "./api/aiGenerationClient";
|
import { aiGenerationClient } from "./api/aiGenerationClient";
|
||||||
@@ -42,8 +44,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 +54,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 +100,6 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
|||||||
"assets",
|
"assets",
|
||||||
"ecommerceHub",
|
"ecommerceHub",
|
||||||
"ecommerce",
|
"ecommerce",
|
||||||
"ecommerceTemplates",
|
|
||||||
"scriptTokens",
|
"scriptTokens",
|
||||||
"tokenUsage",
|
"tokenUsage",
|
||||||
"settings",
|
"settings",
|
||||||
@@ -113,14 +111,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 =
|
||||||
@@ -287,6 +284,25 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Pre-warm notification permission (lazy, on first click)
|
||||||
|
useEffect(() => { initNotificationPermission(); }, []);
|
||||||
|
|
||||||
|
// Global unhandled error / rejection listeners — report to server
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUnhandled = (event: ErrorEvent) => {
|
||||||
|
reportError(event.error || new Error(event.message), "unhandled");
|
||||||
|
};
|
||||||
|
const handleRejection = (event: PromiseRejectionEvent) => {
|
||||||
|
reportError(event.reason instanceof Error ? event.reason : new Error(String(event.reason)), "rejection");
|
||||||
|
};
|
||||||
|
window.addEventListener("error", handleUnhandled);
|
||||||
|
window.addEventListener("unhandledrejection", handleRejection);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("error", handleUnhandled);
|
||||||
|
window.removeEventListener("unhandledrejection", handleRejection);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Initialize canvasWorkflow if null
|
// Initialize canvasWorkflow if null
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canvasWorkflow) {
|
if (!canvasWorkflow) {
|
||||||
@@ -312,12 +328,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 /> },
|
||||||
@@ -362,7 +372,7 @@ function App() {
|
|||||||
}, [clearSessionState, setProjects, setProjectsLoaded, setUsage, clearTasks, setRuntimeNotifications, setServerNotifications, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded, handleSetView]);
|
}, [clearSessionState, setProjects, setProjectsLoaded, setUsage, clearTasks, setRuntimeNotifications, setServerNotifications, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded, handleSetView]);
|
||||||
|
|
||||||
const showSessionReplacedModal = useCallback((message?: string) => {
|
const showSessionReplacedModal = useCallback((message?: string) => {
|
||||||
clearAuthenticatedState();
|
clearAuthenticatedState({ resetView: true });
|
||||||
showSessionReplaced(message);
|
showSessionReplaced(message);
|
||||||
}, [clearAuthenticatedState, showSessionReplaced]);
|
}, [clearAuthenticatedState, showSessionReplaced]);
|
||||||
|
|
||||||
@@ -380,11 +390,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 +686,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 +995,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 +1038,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 +1070,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 +1095,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":
|
||||||
|
|||||||
@@ -928,4 +928,9 @@ export const keyServerClient = {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getClientErrors(page = 1): Promise<{ items: unknown[]; total: number }> {
|
||||||
|
const data = await request<{ items: unknown[]; total: number }>(`/client-errors?page=${page}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+17
-16
@@ -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,
|
||||||
|
|||||||
@@ -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 |
@@ -0,0 +1,110 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { keyServerClient } from "../api/keyServerClient";
|
||||||
|
|
||||||
|
interface ClientErrorItem {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
source: string;
|
||||||
|
url: string;
|
||||||
|
user_agent?: string;
|
||||||
|
user_id?: number;
|
||||||
|
count: number;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "omniai:admin-monitor-open";
|
||||||
|
const POLL_INTERVAL = 30000;
|
||||||
|
|
||||||
|
function formatTime(iso: string) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminMonitor() {
|
||||||
|
const [open, setOpen] = useState(() => {
|
||||||
|
try { return sessionStorage.getItem(STORAGE_KEY) === "1"; } catch { return false; }
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<ClientErrorItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval>>();
|
||||||
|
|
||||||
|
const fetchErrors = useCallback(async (p = 1) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await keyServerClient.getClientErrors(p);
|
||||||
|
setErrors(data.items);
|
||||||
|
setTotal(data.total);
|
||||||
|
setPage(p);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
void fetchErrors(1);
|
||||||
|
intervalRef.current = setInterval(() => fetchErrors(1), POLL_INTERVAL);
|
||||||
|
return () => clearInterval(intervalRef.current);
|
||||||
|
}, [open, fetchErrors]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try { sessionStorage.setItem(STORAGE_KEY, open ? "1" : "0"); } catch { /* */ }
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const maxPage = Math.max(1, Math.ceil(total / 50));
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button type="button" className="admin-monitor-trigger" onClick={() => setOpen(true)} title="错误监控">
|
||||||
|
<span className="admin-monitor-trigger__dot" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-monitor" role="dialog" aria-label="客户端错误监控">
|
||||||
|
<header className="admin-monitor__header">
|
||||||
|
<strong>客户端错误 ({total})</strong>
|
||||||
|
<div className="admin-monitor__actions">
|
||||||
|
<button type="button" onClick={() => void fetchErrors(1)} disabled={loading}>
|
||||||
|
{loading ? "刷新中..." : "刷新"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setOpen(false)}>关闭</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section className="admin-monitor__list">
|
||||||
|
{errors.length === 0 ? (
|
||||||
|
<div className="admin-monitor__empty">暂无错误</div>
|
||||||
|
) : (
|
||||||
|
errors.map((err) => (
|
||||||
|
<details key={err.id} className="admin-monitor__item">
|
||||||
|
<summary>
|
||||||
|
<span className="admin-monitor__source">{err.source}</span>
|
||||||
|
<span className="admin-monitor__msg">{err.message.slice(0, 120)}</span>
|
||||||
|
<span className="admin-monitor__count">{err.count}</span>
|
||||||
|
<time>{formatTime(err.last_seen)}</time>
|
||||||
|
</summary>
|
||||||
|
<div className="admin-monitor__detail">
|
||||||
|
<div><b>URL:</b> {err.url}</div>
|
||||||
|
<div><b>User:</b> {err.user_id || "匿名"}</div>
|
||||||
|
{err.stack ? <pre>{err.stack.slice(0, 1000)}</pre> : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{maxPage > 1 ? (
|
||||||
|
<footer className="admin-monitor__pager">
|
||||||
|
<button type="button" disabled={page <= 1} onClick={() => fetchErrors(page - 1)}>上一页</button>
|
||||||
|
<span>{page} / {maxPage}</span>
|
||||||
|
<button type="button" disabled={page >= maxPage} onClick={() => fetchErrors(page + 1)}>下一页</button>
|
||||||
|
</footer>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminMonitor;
|
||||||
@@ -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,
|
||||||
@@ -17,6 +21,7 @@ import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebV
|
|||||||
import NotificationCenter from "./NotificationCenter";
|
import NotificationCenter from "./NotificationCenter";
|
||||||
import { RechargeModal } from "./RechargeModal/RechargeModal";
|
import { RechargeModal } from "./RechargeModal/RechargeModal";
|
||||||
import { AnimatedPanel } from "./AnimatedPanel";
|
import { AnimatedPanel } from "./AnimatedPanel";
|
||||||
|
import AdminMonitor from "./AdminMonitor";
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
activeView: WebViewKey;
|
activeView: WebViewKey;
|
||||||
@@ -61,6 +66,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 +147,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 +325,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>江苏省南京市江北新区扬子江数字视听产业园9栋A楼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"
|
||||||
@@ -429,6 +471,7 @@ function AppShell({
|
|||||||
<div className="web-shell__page">{children}</div>
|
<div className="web-shell__page">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
{session?.user.role === "admin" ? <AdminMonitor /> : null}
|
||||||
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,25 +40,37 @@ function getNavIndex(key: string): number {
|
|||||||
export default function PageTransition({ viewKey, children }: PageTransitionProps) {
|
export default function PageTransition({ viewKey, children }: PageTransitionProps) {
|
||||||
const [displayedChildren, setDisplayedChildren] = useState(children);
|
const [displayedChildren, setDisplayedChildren] = useState(children);
|
||||||
const [phase, setPhase] = useState<"idle" | "exit">("idle");
|
const [phase, setPhase] = useState<"idle" | "exit">("idle");
|
||||||
const [direction, setDirection] = useState<"forward" | "backward" | "neutral">("neutral");
|
const [exitDirection, setExitDirection] = useState<"forward" | "backward" | "neutral">("neutral");
|
||||||
const prevKeyRef = useRef(viewKey);
|
const prevKeyRef = useRef(viewKey);
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
prevKeyRef.current = viewKey;
|
||||||
|
setDisplayedChildren(children);
|
||||||
|
setPhase("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const prevIndex = getNavIndex(prevKeyRef.current);
|
const prevIndex = getNavIndex(prevKeyRef.current);
|
||||||
const nextIndex = getNavIndex(viewKey);
|
const nextIndex = getNavIndex(viewKey);
|
||||||
if (prevIndex < nextIndex) {
|
if (prevIndex < nextIndex) {
|
||||||
setDirection("forward");
|
setExitDirection("forward");
|
||||||
} else if (prevIndex > nextIndex) {
|
} else if (prevIndex > nextIndex) {
|
||||||
setDirection("backward");
|
setExitDirection("backward");
|
||||||
} else {
|
} else {
|
||||||
setDirection("neutral");
|
setExitDirection("neutral");
|
||||||
}
|
}
|
||||||
prevKeyRef.current = viewKey;
|
prevKeyRef.current = viewKey;
|
||||||
|
|
||||||
setPhase("exit");
|
setPhase("exit");
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
setDisplayedChildren(children);
|
setDisplayedChildren(children);
|
||||||
@@ -67,10 +79,10 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp
|
|||||||
return () => clearTimeout(timerRef.current);
|
return () => clearTimeout(timerRef.current);
|
||||||
}, [viewKey, children]);
|
}, [viewKey, children]);
|
||||||
|
|
||||||
const dirClass = direction === "forward" ? " is-forward" : direction === "backward" ? " is-backward" : "";
|
const dirClass = exitDirection === "forward" ? " is-forward" : exitDirection === "backward" ? " is-backward" : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={phase === "exit" ? `page-transition-wrap page-motion--exit${dirClass}` : `page-transition-wrap${phase === "idle" && direction !== "neutral" ? ` page-motion--enter${dirClass}` : ""}`}>
|
<div className={phase === "exit" ? `page-transition-wrap page-motion--exit${dirClass}` : "page-transition-wrap"}>
|
||||||
{displayedChildren}
|
{displayedChildren}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,6 +3749,13 @@ function CanvasPage({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{isWaitingForProjects ? (
|
||||||
|
<>
|
||||||
|
<div className="studio-canvas-loading-spinner" />
|
||||||
|
<strong>正在加载项目数据…</strong>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<strong>没有画布项目,是否需要新建画布?</strong>
|
<strong>没有画布项目,是否需要新建画布?</strong>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3757,6 +3771,8 @@ function CanvasPage({
|
|||||||
>
|
>
|
||||||
新建画布
|
新建画布
|
||||||
</button>
|
</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 {
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
|
SearchOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useDebounce } from "../../hooks/useDebounce";
|
||||||
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
||||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||||
import OptimizedImage from "../../components/OptimizedImage";
|
import OptimizedImage from "../../components/OptimizedImage";
|
||||||
@@ -70,6 +72,8 @@ function buildWorkflowFromServerCase(item: ServerCommunityCase, fallback: WebCan
|
|||||||
function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject, onDeleteProject, onImportWorkflow, onRequireLogin }: CommunityPageProps) {
|
function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject, onDeleteProject, onImportWorkflow, onRequireLogin }: CommunityPageProps) {
|
||||||
const [serverCases, setServerCases] = useState<ServerCommunityCase[]>([]);
|
const [serverCases, setServerCases] = useState<ServerCommunityCase[]>([]);
|
||||||
const [serverNotice, setServerNotice] = useState<string | null>(null);
|
const [serverNotice, setServerNotice] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const debouncedQuery = useDebounce(query, 300);
|
||||||
const [favoriteIds, setFavoriteIds] = useState<string[]>([]);
|
const [favoriteIds, setFavoriteIds] = useState<string[]>([]);
|
||||||
const canUseProtectedAction = (action: string) => onRequireLogin?.(action) !== false;
|
const canUseProtectedAction = (action: string) => onRequireLogin?.(action) !== false;
|
||||||
|
|
||||||
@@ -232,40 +236,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) =>
|
||||||
@@ -294,7 +264,17 @@ function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const liveCases: ServerCommunityCase[] = serverCases.slice(0, 12);
|
const filteredCases = useMemo(() => {
|
||||||
|
const q = debouncedQuery.trim().toLowerCase();
|
||||||
|
if (!q) return serverCases;
|
||||||
|
return serverCases.filter((c) =>
|
||||||
|
(c.title || "").toLowerCase().includes(q) ||
|
||||||
|
(c.description || "").toLowerCase().includes(q) ||
|
||||||
|
(c.tags || []).some((t: string) => t.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}, [serverCases, debouncedQuery]);
|
||||||
|
|
||||||
|
const liveCases: ServerCommunityCase[] = filteredCases.slice(0, 12);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspacePageShell title="社区" fullWidth className="community-page page-motion">
|
<WorkspacePageShell title="社区" fullWidth className="community-page page-motion">
|
||||||
@@ -421,6 +401,15 @@ function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject
|
|||||||
<div>
|
<div>
|
||||||
<h2>社区精选</h2>
|
<h2>社区精选</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="asset-search">
|
||||||
|
<SearchOutlined />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="搜索案例..."
|
||||||
|
/>
|
||||||
|
{query ? <button type="button" className="asset-search__clear" onClick={() => setQuery("")} aria-label="清除搜索">×</button> : null}
|
||||||
|
</label>
|
||||||
{serverNotice ? <span className="studio-pill">{serverNotice}</span> : null}
|
{serverNotice ? <span className="studio-pill">{serverNotice}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{liveCases.length ? (
|
{liveCases.length ? (
|
||||||
@@ -507,8 +496,8 @@ function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject
|
|||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<ImportOutlined style={{ fontSize: 48 }} />}
|
icon={<ImportOutlined style={{ fontSize: 48 }} />}
|
||||||
title="社区暂无模板"
|
title={debouncedQuery ? "无匹配结果" : "社区暂无模板"}
|
||||||
description="管理员审核通过后,画布社区案例会显示在这里。"
|
description={debouncedQuery ? "尝试其他关键词,或清除搜索查看全部案例" : "管理员审核通过后,画布社区案例会显示在这里。"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const ecommerceSlide5 = `${OSS_MUBAN}/slide-5.png`;
|
|||||||
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
|
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
|
||||||
import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace";
|
import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
|
import { ServerRequestError } from "../../api/serverConnection";
|
||||||
|
import { waitForTask } from "../../api/taskSubscription";
|
||||||
import {
|
import {
|
||||||
analyzeProductImages,
|
analyzeProductImages,
|
||||||
buildProductSummary,
|
buildProductSummary,
|
||||||
@@ -771,6 +773,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const [productSetRequirement, setProductSetRequirement] = useState("");
|
const [productSetRequirement, setProductSetRequirement] = useState("");
|
||||||
const [productSetOutput, setProductSetOutput] = useState<ProductSetOutputKey>("video");
|
const [productSetOutput, setProductSetOutput] = useState<ProductSetOutputKey>("video");
|
||||||
const [productSetStatus, setProductSetStatus] = useState<ProductSetStatus>("idle");
|
const [productSetStatus, setProductSetStatus] = useState<ProductSetStatus>("idle");
|
||||||
|
const [productSetResultImages, setProductSetResultImages] = useState<string[]>([]);
|
||||||
const [isSetUploadDragging, setIsSetUploadDragging] = useState(false);
|
const [isSetUploadDragging, setIsSetUploadDragging] = useState(false);
|
||||||
const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<{ src: string; label: string } | null>(null);
|
const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<{ src: string; label: string } | null>(null);
|
||||||
const [showHostingModal, setShowHostingModal] = useState(false);
|
const [showHostingModal, setShowHostingModal] = useState(false);
|
||||||
@@ -819,6 +822,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const [ratio, setRatio] = useState(getPlatformDefaultRatio(platformOptions[0]));
|
const [ratio, setRatio] = useState(getPlatformDefaultRatio(platformOptions[0]));
|
||||||
const [status, setStatus] = useState<ProductCloneStatus>("idle");
|
const [status, setStatus] = useState<ProductCloneStatus>("idle");
|
||||||
const [results, setResults] = useState<CloneResult[]>([]);
|
const [results, setResults] = useState<CloneResult[]>([]);
|
||||||
|
const imageAbortRef = useRef({ current: false });
|
||||||
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
|
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
|
||||||
const [modelSource, setModelSource] = useState<TryOnModelSource>("ai");
|
const [modelSource, setModelSource] = useState<TryOnModelSource>("ai");
|
||||||
const [modelGender, setModelGender] = useState(tryOnModelOptions.gender[0]);
|
const [modelGender, setModelGender] = useState(tryOnModelOptions.gender[0]);
|
||||||
@@ -840,6 +844,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const [detailRequirement, setDetailRequirement] = useState("");
|
const [detailRequirement, setDetailRequirement] = useState("");
|
||||||
const [selectedDetailModules, setSelectedDetailModules] = useState<string[]>(defaultDetailModuleIds);
|
const [selectedDetailModules, setSelectedDetailModules] = useState<string[]>(defaultDetailModuleIds);
|
||||||
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
|
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
|
||||||
|
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null);
|
||||||
const productSetRatioOptions = getPlatformRatioOptions(productSetPlatform, productSetOutput);
|
const productSetRatioOptions = getPlatformRatioOptions(productSetPlatform, productSetOutput);
|
||||||
const hotUploadedRatioOption = cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null;
|
const hotUploadedRatioOption = cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null;
|
||||||
const baseCloneRatioOptions = getPlatformRatioOptions(platform, cloneOutput);
|
const baseCloneRatioOptions = getPlatformRatioOptions(platform, cloneOutput);
|
||||||
@@ -1339,6 +1344,180 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
return urls;
|
return urls;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uploadCloneImages = async (images: CloneImageItem[]): Promise<string[]> => {
|
||||||
|
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const item of images) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(item.src);
|
||||||
|
const rawBlob = await resp.blob();
|
||||||
|
const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png";
|
||||||
|
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
||||||
|
const { url } = await aiGenerationClient.uploadAssetBinary(blob, { name: item.name, mimeType, scope: "ecommerce-product" });
|
||||||
|
urls.push(url);
|
||||||
|
} catch {
|
||||||
|
// skip images that fail to upload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IMAGE_MODEL = "gpt-image-2";
|
||||||
|
|
||||||
|
const setCountLabels: Record<CloneSetCountKey, { label: string; promptDesc: string }> = {
|
||||||
|
selling: { label: "卖点图", promptDesc: "selling-point infographic image highlighting core product advantages and detail close-ups" },
|
||||||
|
white: { label: "白底图", promptDesc: "clean white-background product photo showing the item from its best angle, studio lighting, no props" },
|
||||||
|
scene: { label: "场景图", promptDesc: "lifestyle scene image showing the product in a realistic usage environment with natural surroundings" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSetSubPrompt = (countKey: CloneSetCountKey, index: number, totalCount: number, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => {
|
||||||
|
const info = setCountLabels[countKey];
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`);
|
||||||
|
parts.push(info.promptDesc);
|
||||||
|
if (totalCount > 1) {
|
||||||
|
parts.push(`This is variant ${index + 1} of ${totalCount} — vary the angle, composition, or emphasis to make each distinct.`);
|
||||||
|
}
|
||||||
|
parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`);
|
||||||
|
parts.push("Must comply with platform image guidelines — proper margins, no watermark, professional quality.");
|
||||||
|
return parts.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildEcommerceImagePrompt = (outputKey: CloneOutputKey, userText: string, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (outputKey === "detail") {
|
||||||
|
parts.push("Generate a professional A+ detail page hero image for an e-commerce product listing.");
|
||||||
|
parts.push("Create a high-impact first-screen visual that combines the product photo with key selling points, usage scenes, and detailed specifications in a cohesive layout.");
|
||||||
|
parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`);
|
||||||
|
parts.push("Follow platform A+ page best practices — clear hierarchy, professional typography, high visual impact.");
|
||||||
|
} else if (outputKey === "model") {
|
||||||
|
parts.push("Generate model/try-on lifestyle images for an e-commerce product listing.");
|
||||||
|
parts.push("Show the product being used or worn by a model in attractive lifestyle settings.");
|
||||||
|
parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`);
|
||||||
|
parts.push("Model should appear natural and appealing. Background should complement the product. Image must meet platform standards.");
|
||||||
|
} else if (outputKey === "hot") {
|
||||||
|
parts.push("Generate a high-conversion e-commerce product image that closely replicates the style and composition of the reference image while adapting it to the target platform.");
|
||||||
|
parts.push(`Replicate the visual style, color palette, and layout feel of the source product image, then adapt it for ${pPlatform} marketplace standards.`);
|
||||||
|
parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`);
|
||||||
|
parts.push("The result must look professional and optimized for high click-through rate and conversion on the specified platform.");
|
||||||
|
}
|
||||||
|
if (userText.trim()) {
|
||||||
|
parts.push(`Additional user requirements: ${userText.trim()}`);
|
||||||
|
}
|
||||||
|
return parts.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateSetImages = async (
|
||||||
|
images: CloneImageItem[],
|
||||||
|
counts: Record<CloneSetCountKey, number>,
|
||||||
|
userText: string,
|
||||||
|
pPlatform: string,
|
||||||
|
pRatio: string,
|
||||||
|
pLanguage: string,
|
||||||
|
pMarket: string,
|
||||||
|
setStatusFn: (status: "generating" | "done" | "idle") => void,
|
||||||
|
setResultFn: (urls: string[]) => void,
|
||||||
|
): Promise<void> => {
|
||||||
|
setStatusFn("generating");
|
||||||
|
try {
|
||||||
|
const referenceUrls = await uploadCloneImages(images);
|
||||||
|
if (!referenceUrls.length) {
|
||||||
|
setStatusFn("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedUrls: string[] = [];
|
||||||
|
const stamp = Date.now();
|
||||||
|
|
||||||
|
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
|
||||||
|
const count = counts[countKey];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
if (imageAbortRef.current.current) break;
|
||||||
|
const subPrompt = buildSetSubPrompt(countKey, i, count, pPlatform, pRatio, pLanguage, pMarket);
|
||||||
|
const fullPrompt = userText.trim() ? `${subPrompt} Additional user requirements: ${userText.trim()}` : subPrompt;
|
||||||
|
|
||||||
|
const { taskId } = await aiGenerationClient.createImageTask({
|
||||||
|
model: IMAGE_MODEL,
|
||||||
|
prompt: fullPrompt,
|
||||||
|
ratio: pRatio,
|
||||||
|
quality: pRatio.includes("720") ? "720P" : "1080P",
|
||||||
|
gridMode: "single",
|
||||||
|
referenceUrls,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultUrl = await waitForTask(taskId, {
|
||||||
|
abortRef: imageAbortRef.current,
|
||||||
|
onProgress: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resultUrl) {
|
||||||
|
generatedUrls.push(resultUrl);
|
||||||
|
} else {
|
||||||
|
generatedUrls.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setResultFn(generatedUrls);
|
||||||
|
setStatusFn(generatedUrls.some(Boolean) ? "done" : "idle");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServerRequestError && err.status === 402) {
|
||||||
|
setResultFn([]);
|
||||||
|
}
|
||||||
|
setStatusFn("idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateEcommerceImage = async (
|
||||||
|
outputKey: CloneOutputKey,
|
||||||
|
images: CloneImageItem[],
|
||||||
|
userText: string,
|
||||||
|
pPlatform: string,
|
||||||
|
pRatio: string,
|
||||||
|
pLanguage: string,
|
||||||
|
pMarket: string,
|
||||||
|
setStatusFn: (status: "generating" | "done" | "idle") => void,
|
||||||
|
setResultFn: (results: CloneResult[]) => void,
|
||||||
|
): Promise<void> => {
|
||||||
|
setStatusFn("generating");
|
||||||
|
try {
|
||||||
|
const referenceUrls = await uploadCloneImages(images);
|
||||||
|
if (!referenceUrls.length) {
|
||||||
|
setStatusFn("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket);
|
||||||
|
const stamp = Date.now();
|
||||||
|
|
||||||
|
const { taskId } = await aiGenerationClient.createImageTask({
|
||||||
|
model: IMAGE_MODEL,
|
||||||
|
prompt,
|
||||||
|
ratio: pRatio,
|
||||||
|
quality: pRatio.includes("720") ? "720P" : "1080P",
|
||||||
|
gridMode: "single",
|
||||||
|
referenceUrls,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultUrl = await waitForTask(taskId, {
|
||||||
|
abortRef: imageAbortRef.current,
|
||||||
|
onProgress: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resultUrl) {
|
||||||
|
setResultFn([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
|
||||||
|
setStatusFn("done");
|
||||||
|
} else {
|
||||||
|
setStatusFn("idle");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServerRequestError && err.status === 402) {
|
||||||
|
setResultFn([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
|
||||||
|
}
|
||||||
|
setStatusFn("idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const adVideoUploadedUrlsRef = useRef<string[]>([]);
|
const adVideoUploadedUrlsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
const handleAdVideoPlan = async () => {
|
const handleAdVideoPlan = async () => {
|
||||||
@@ -1540,32 +1719,46 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
|
|
||||||
const handleGenerate = () => {
|
const handleGenerate = () => {
|
||||||
if (!canGenerate) return;
|
if (!canGenerate) return;
|
||||||
setStatus("generating");
|
imageAbortRef.current = { current: false };
|
||||||
window.setTimeout(() => {
|
if (cloneOutput === "set") {
|
||||||
const stamp = Date.now();
|
void generateSetImages(
|
||||||
setResults(
|
productImages, cloneSetCounts, requirement,
|
||||||
sampleResults.map((src, index) => ({
|
platform, ratio, language, market,
|
||||||
id: `clone-result-${stamp}-${index}`,
|
(s) => setStatus(s as ProductCloneStatus),
|
||||||
src,
|
(urls) => setProductSetResultImages(urls),
|
||||||
label: index === 0 ? "高度复刻" : index === 1 ? "参考风格" : "平台适配",
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
setStatus("done");
|
} else {
|
||||||
}, 900);
|
void generateEcommerceImage(
|
||||||
|
cloneOutput, productImages, requirement,
|
||||||
|
platform, ratio, language, market,
|
||||||
|
(s) => setStatus(s as ProductCloneStatus), setResults,
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateModel = () => {
|
const handleGenerateModel = () => {
|
||||||
|
imageAbortRef.current = { current: false };
|
||||||
setTryOnStatus("modeling");
|
setTryOnStatus("modeling");
|
||||||
window.setTimeout(() => setTryOnStatus("ready"), 700);
|
void generateEcommerceImage(
|
||||||
|
"model", garmentImages, requirement,
|
||||||
|
platform, ratio, language, market,
|
||||||
|
(s) => {
|
||||||
|
if (s === "done") setTryOnStatus("ready");
|
||||||
|
else setTryOnStatus(s as TryOnStatus);
|
||||||
|
},
|
||||||
|
() => { setTryOnStatus("ready"); },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTryOnGenerate = () => {
|
const handleTryOnGenerate = () => {
|
||||||
if (!canGenerateTryOn) return;
|
if (!canGenerateTryOn) return;
|
||||||
setTryOnStatus("generating");
|
imageAbortRef.current = { current: false };
|
||||||
window.setTimeout(() => {
|
void generateEcommerceImage(
|
||||||
setTryOnResultImages([tryOnAssets.tryA, tryOnAssets.tryB, tryOnAssets.hatResultA]);
|
"model", garmentImages, requirement,
|
||||||
setTryOnStatus("done");
|
platform, ratio, language, market,
|
||||||
}, 900);
|
(s) => setTryOnStatus(s as TryOnStatus),
|
||||||
|
(res) => setTryOnResultImages(res.map((r) => r.src).filter(Boolean)),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleScene = (scene: string) => {
|
const toggleScene = (scene: string) => {
|
||||||
@@ -1582,8 +1775,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
|
|
||||||
const handleSetGenerate = () => {
|
const handleSetGenerate = () => {
|
||||||
if (!canGenerateSet) return;
|
if (!canGenerateSet) return;
|
||||||
setProductSetStatus("generating");
|
imageAbortRef.current = { current: false };
|
||||||
window.setTimeout(() => setProductSetStatus("done"), 900);
|
void generateSetImages(
|
||||||
|
setImages, cloneSetCounts, productSetRequirement,
|
||||||
|
productSetPlatform, productSetRatio, productSetLanguage, productSetMarket,
|
||||||
|
(s) => setProductSetStatus(s as ProductSetStatus),
|
||||||
|
(urls) => setProductSetResultImages(urls),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openProductSetPreview = (card: { src: string; label: string }) => {
|
const openProductSetPreview = (card: { src: string; label: string }) => {
|
||||||
@@ -1598,8 +1796,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
|
|
||||||
const handleDetailGenerate = () => {
|
const handleDetailGenerate = () => {
|
||||||
if (!canGenerateDetail) return;
|
if (!canGenerateDetail) return;
|
||||||
setDetailStatus("generating");
|
imageAbortRef.current = { current: false };
|
||||||
window.setTimeout(() => setDetailStatus("done"), 900);
|
void generateEcommerceImage(
|
||||||
|
"detail", detailProductImages, detailRequirement,
|
||||||
|
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
|
||||||
|
(s) => setDetailStatus(s as DetailStatus),
|
||||||
|
(res) => setDetailResultUrl(res[0]?.src ?? null),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetTask = () => {
|
const resetTask = () => {
|
||||||
@@ -1667,10 +1870,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
detailProductImages.length === 0 ? "请上传产品图" : detailStatus === "generating" ? "生成中..." : "生成A+详情页";
|
detailProductImages.length === 0 ? "请上传产品图" : detailStatus === "generating" ? "生成中..." : "生成A+详情页";
|
||||||
const clonePrimaryLabel =
|
const clonePrimaryLabel =
|
||||||
productImages.length === 0 ? "请先上传商品原图" : status === "generating" ? "生成中..." : `生成${selectedCloneOutput.label}`;
|
productImages.length === 0 ? "请先上传商品原图" : status === "generating" ? "生成中..." : `生成${selectedCloneOutput.label}`;
|
||||||
const clonePreviewCards = productSetPreviewCards.map((card, index) => ({
|
const setPreviewCards: CloneResult[] = [];
|
||||||
...card,
|
let setIndex = 0;
|
||||||
src: results[index]?.src ?? card.src,
|
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
|
||||||
}));
|
const count = cloneSetCounts[countKey];
|
||||||
|
const info = setCountLabels[countKey];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
setPreviewCards.push({
|
||||||
|
id: `${countKey}-${i}`,
|
||||||
|
src: productSetResultImages[setIndex] ?? productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src ?? "",
|
||||||
|
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
|
||||||
|
});
|
||||||
|
setIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clonePreviewCards: CloneResult[] = [];
|
||||||
|
let cloneIndex = 0;
|
||||||
|
for (const countKey of cloneSetCountOptions.map((o) => o.key)) {
|
||||||
|
const count = cloneSetCounts[countKey];
|
||||||
|
const info = setCountLabels[countKey];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
clonePreviewCards.push({
|
||||||
|
id: `${countKey}-${i}`,
|
||||||
|
src: results[cloneIndex]?.src ?? productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src ?? "",
|
||||||
|
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
|
||||||
|
});
|
||||||
|
cloneIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
const cloneBasicSelects: Array<{
|
const cloneBasicSelects: Array<{
|
||||||
key: CloneBasicSelectKey;
|
key: CloneBasicSelectKey;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -2589,14 +2817,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="product-set-main-card"
|
className="product-set-main-card"
|
||||||
onClick={() => openProductSetPreview(productSetPreviewCards[0])}
|
onClick={() => openProductSetPreview(setPreviewCards[0] ?? productSetPreviewCards[0])}
|
||||||
>
|
>
|
||||||
<img src={productSetPreviewCards[0].src} alt="01 主图" />
|
<img src={setImages[0]?.src ?? (setPreviewCards[0]?.src ?? productSetPreviewCards[0].src)} alt="商品原图" />
|
||||||
<span>{productSetPreviewCards[0].label}</span>
|
<span>原图素材</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="product-set-flow-arrow" aria-hidden="true" />
|
<div className="product-set-flow-arrow" aria-hidden="true" />
|
||||||
<div className="product-set-card-grid result-reveal">
|
<div className="product-set-card-grid result-reveal">
|
||||||
{productSetPreviewCards.slice(1).map((card) => (
|
{setPreviewCards.map((card) => (
|
||||||
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
|
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
|
||||||
<img src={card.src} alt={card.label} />
|
<img src={card.src} alt={card.label} />
|
||||||
<span>{card.label}</span>
|
<span>{card.label}</span>
|
||||||
@@ -2649,18 +2877,25 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
|
|
||||||
{status === "done" ? (
|
{status === "done" ? (
|
||||||
<section className="clone-ai-preview-showcase" aria-label="生成结果">
|
<section className="clone-ai-preview-showcase" aria-label="生成结果">
|
||||||
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(clonePreviewCards[0])}>
|
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
|
||||||
<img src={productImages[0]?.src ?? clonePreviewCards[0].src} alt="上传商品原图" />
|
<img src={productImages[0]?.src ?? (cloneOutput === "set" ? clonePreviewCards[0].src : results[0]?.src ?? "")} alt="上传商品原图" />
|
||||||
<span>原图素材</span>
|
<span>原图素材</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||||
<div className="clone-ai-result-grid result-reveal">
|
<div className="clone-ai-result-grid result-reveal">
|
||||||
{clonePreviewCards.map((card) => (
|
{cloneOutput === "set" ? (
|
||||||
|
clonePreviewCards.map((card) => (
|
||||||
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
|
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
|
||||||
<img src={card.src} alt={card.label} />
|
<img src={card.src} alt={card.label} />
|
||||||
<span>{card.label}</span>
|
<span>{card.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))
|
||||||
|
) : results[0]?.src ? (
|
||||||
|
<button type="button" onClick={() => openProductSetPreview(results[0])}>
|
||||||
|
<img src={results[0].src} alt={selectedCloneOutput.label} />
|
||||||
|
<span>{selectedCloneOutput.label}</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
@@ -2726,7 +2961,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="product-detail-flow-arrow" aria-hidden="true" />
|
<div className="product-detail-flow-arrow" aria-hidden="true" />
|
||||||
<div className="product-detail-long-result">
|
<div className="product-detail-long-result">
|
||||||
<img src={detailAssets.longPage} alt="生成电商长图" />
|
<img src={detailResultUrl ?? detailAssets.longPage} alt="生成电商长图" />
|
||||||
<span>{detailStatus === "done" ? "已生成电商长图" : "生成电商长图"}</span>
|
<span>{detailStatus === "done" ? "已生成电商长图" : "生成电商长图"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="product-detail-grid-result">
|
<div className="product-detail-grid-result">
|
||||||
@@ -2851,7 +3086,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
aspectRatio={ratio.includes("9:16") || ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") || ratio.includes("16:9") ? "16:9" : ratio.includes("3:4") || ratio.includes("3:4") ? "3:4" : "9:16"}
|
aspectRatio={ratio.includes("9:16") || ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") || ratio.includes("16:9") ? "16:9" : ratio.includes("3:4") || 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}
|
||||||
|
|||||||
@@ -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,17 +9,24 @@ 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,
|
||||||
type PlanStep,
|
type PlanStep,
|
||||||
} from "./ecommerceVideoTypes";
|
} from "./ecommerceVideoTypes";
|
||||||
import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
|
import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
|
||||||
|
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;
|
||||||
@@ -55,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);
|
||||||
@@ -70,119 +185,201 @@ 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 ──────────────────────
|
||||||
|
|
||||||
|
const handleGenerateImages = async () => {
|
||||||
if (!planResult || !scenes.length) return;
|
if (!planResult || !scenes.length) return;
|
||||||
const imageUrl = productImageDataUrls[0] || "";
|
setStage("imaging"); setError(null);
|
||||||
setStage("rendering");
|
renderAbortRef.current = { current: false };
|
||||||
setError(null);
|
const ratio = aspectRatio.includes("9:16") || aspectRatio.includes("9:16") ? "9:16"
|
||||||
|
: aspectRatio.includes("16:9") || aspectRatio.includes("16:9") ? "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;
|
||||||
|
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
|
||||||
|
try {
|
||||||
|
await renderSceneImage(
|
||||||
|
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
|
||||||
|
{
|
||||||
|
onSceneImageSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)),
|
||||||
|
onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||||
|
onSceneImageCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)),
|
||||||
|
onSceneImageFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)),
|
||||||
|
},
|
||||||
|
renderAbortRef.current,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "图片生成失败";
|
||||||
|
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "idle", error: message } : s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const allHaveImages = currentScenes.every((s) => s.imageUrl);
|
||||||
|
const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const;
|
||||||
|
setStage(finalStage);
|
||||||
|
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Phase 3: Video rendering from generated images ──────────
|
||||||
|
|
||||||
|
const handleRenderVideos = async () => {
|
||||||
|
if (!scenes.length) return;
|
||||||
|
const firstImage = scenes[0]?.imageUrl;
|
||||||
|
if (!firstImage) { setError("请先生成分镜图片"); return; }
|
||||||
|
setStage("rendering"); setError(null);
|
||||||
renderAbortRef.current = { current: false };
|
renderAbortRef.current = { current: false };
|
||||||
const quality = mapResolutionToQuality(resolution);
|
const quality = mapResolutionToQuality(resolution);
|
||||||
|
let currentScenes = [...scenes];
|
||||||
for (const scene of 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 (renderAbortRef.current.current) break;
|
||||||
setScenes((prev) => prev.map((s) =>
|
if (!scene.imageUrl) continue;
|
||||||
s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
|
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
|
||||||
try {
|
try {
|
||||||
await renderScene(
|
await renderScene(
|
||||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl, aspectRatio, resolution: quality },
|
{ 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)),
|
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)),
|
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||||
@@ -192,37 +389,25 @@ export default function EcommerceVideoWorkspace({
|
|||||||
renderAbortRef.current,
|
renderAbortRef.current,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setScenes((prev) => prev.map((s) =>
|
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: (err as Error).message } : s));
|
||||||
s.sceneId === scene.sceneId ? { ...s, status: "failed", error: err instanceof Error ? err.message : "生成失败" } : 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;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
// ── Derived state ───────────────────────────────────────────
|
||||||
abortControllerRef.current?.abort();
|
|
||||||
renderAbortRef.current.current = true;
|
|
||||||
setStage("cancelled");
|
|
||||||
};
|
|
||||||
|
|
||||||
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 = 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" />
|
||||||
@@ -233,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__label">商品原图</span>
|
||||||
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
||||||
</article>
|
</article>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{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>
|
||||||
{index < completedScenes.length - 1 ? (
|
{idx < visiblePlanSteps.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-scene-strip" aria-label="分镜图片节点">
|
||||||
|
{scenes.map((scene, idx) => {
|
||||||
|
const imgReady = !!scene.imageUrl;
|
||||||
|
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
|
||||||
|
const cls = imgReady ? "is-completed" : imgRunning ? "is-active" : "";
|
||||||
|
return (
|
||||||
|
<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">
|
<div className="ecom-video-flow-node__media">
|
||||||
<video src={primaryVideo} muted playsInline loop autoPlay />
|
{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>
|
</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" />
|
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
||||||
</article>
|
</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);
|
||||||
@@ -77,7 +80,46 @@ export async function runVideoPlan(
|
|||||||
const compliance = await checkCompliance(summary, selling, storyboard, signal);
|
const compliance = await checkCompliance(summary, selling, storyboard, signal);
|
||||||
onStepDone("compliance");
|
onStepDone("compliance");
|
||||||
|
|
||||||
return { 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 {
|
||||||
@@ -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,15 +24,18 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EcommerceVideoPlanResult {
|
export interface EcommerceVideoPlanResult {
|
||||||
|
imageUrls: string[];
|
||||||
summary: ProductSummary;
|
summary: ProductSummary;
|
||||||
selling: SellingPointResult;
|
selling: SellingPointResult;
|
||||||
creatives: CreativeOption[];
|
creatives: CreativeOption[];
|
||||||
@@ -67,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",
|
||||||
|
];
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import WelcomeSplash from "./WelcomeSplash";
|
|||||||
import ToolboxSection from "./ToolboxSection";
|
import ToolboxSection from "./ToolboxSection";
|
||||||
import ScriptReviewShowcase from "./ScriptReviewShowcase";
|
import ScriptReviewShowcase from "./ScriptReviewShowcase";
|
||||||
import ModelGenerationShowcase from "./ModelGenerationShowcase";
|
import ModelGenerationShowcase from "./ModelGenerationShowcase";
|
||||||
import ecommerceTemplate1 from "../../assets/home-features/home-ecommerce-template-1.png";
|
const ecommerceTemplate1 = "https://www.omniai.net.cn/static/home-ecommerce-template-1.png";
|
||||||
import ecommerceTemplate2 from "../../assets/home-features/home-ecommerce-template-2.png";
|
const ecommerceTemplate2 = "https://www.omniai.net.cn/static/home-ecommerce-template-2.png";
|
||||||
import ecommerceTemplate3 from "../../assets/home-features/home-ecommerce-template-3.png";
|
const ecommerceTemplate3 = "https://www.omniai.net.cn/static/home-ecommerce-template-3.png";
|
||||||
|
|
||||||
function ScrollEntrance({ children, className, ...rest }: { children: React.ReactNode; className?: string } & React.HTMLAttributes<HTMLElement>) {
|
function ScrollEntrance({ children, className, ...rest }: { children: React.ReactNode; className?: string } & React.HTMLAttributes<HTMLElement>) {
|
||||||
const { ref, isVisible } = useScrollEntrance<HTMLElement>();
|
const { ref, isVisible } = useScrollEntrance<HTMLElement>();
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const prefersReducedMotion = typeof window !== "undefined"
|
|||||||
export default function WelcomeSplash({ onEnter }: WelcomeSplashProps) {
|
export default function WelcomeSplash({ onEnter }: WelcomeSplashProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const rafRef = useRef(0);
|
const rafRef = useRef(0);
|
||||||
const [showWelcome, setShowWelcome] = useState(false);
|
const [showWelcome, setShowWelcome] = useState(true);
|
||||||
const [exiting, setExiting] = useState(false);
|
const [exiting, setExiting] = useState(false);
|
||||||
|
|
||||||
const handleEnter = useCallback(() => {
|
const handleEnter = useCallback(() => {
|
||||||
|
|||||||
@@ -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 : "生成失败";
|
||||||
|
|||||||
@@ -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: "即将上线" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,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";
|
||||||
@@ -40,7 +39,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";
|
||||||
|
|
||||||
@@ -215,8 +214,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");
|
||||||
@@ -296,55 +293,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()) {
|
||||||
@@ -407,7 +355,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") {
|
||||||
@@ -922,9 +870,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 ? (
|
||||||
@@ -935,7 +880,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 /> 企业邀请码 / 内测码
|
||||||
@@ -1075,35 +1020,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>
|
||||||
@@ -1117,9 +1038,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 () => {
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ function WorkbenchPage({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
assetClient
|
assetClient
|
||||||
.list()
|
.list()
|
||||||
@@ -948,6 +949,11 @@ function WorkbenchPage({
|
|||||||
await patchConversationMessage(task.conversationId, task.assistantMessageId, completedPatch);
|
await patchConversationMessage(task.conversationId, task.assistantMessageId, completedPatch);
|
||||||
removeKeepaliveTask(task.taskId);
|
removeKeepaliveTask(task.taskId);
|
||||||
onRefreshUsage?.();
|
onRefreshUsage?.();
|
||||||
|
if (status.status === "completed") {
|
||||||
|
import("../../utils/generationNotifier").then((m) =>
|
||||||
|
m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"),
|
||||||
|
);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (status.resultUrl) {
|
if (status.resultUrl) {
|
||||||
const persistedResult = await persistWorkbenchResultAsset({
|
const persistedResult = await persistWorkbenchResultAsset({
|
||||||
@@ -991,6 +997,11 @@ function WorkbenchPage({
|
|||||||
});
|
});
|
||||||
removeKeepaliveTask(task.taskId);
|
removeKeepaliveTask(task.taskId);
|
||||||
onRefreshUsage?.();
|
onRefreshUsage?.();
|
||||||
|
if (status.status === "completed") {
|
||||||
|
import("../../utils/generationNotifier").then((m) =>
|
||||||
|
m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"),
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2104,7 +2115,7 @@ function WorkbenchPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
|
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
|
||||||
setProjectError("当前任务数已达上限(3个),请等待任务完成后再试");
|
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -2226,7 +2237,7 @@ function WorkbenchPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
|
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
|
||||||
setProjectError("当前任务数已达上限(3个),请等待任务完成后再试");
|
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -2429,7 +2440,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))}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -16,43 +16,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Directional page transitions */
|
/* Exit: fade + directional slide */
|
||||||
.page-motion--enter.is-forward {
|
.page-motion--exit {
|
||||||
animation: page-slide-in-forward 200ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both;
|
animation: page-out 180ms ease forwards;
|
||||||
}
|
pointer-events: none;
|
||||||
|
|
||||||
.page-motion--enter.is-backward {
|
|
||||||
animation: page-slide-in-backward 200ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-motion--exit.is-forward {
|
.page-motion--exit.is-forward {
|
||||||
animation: page-slide-out-forward 180ms ease both;
|
animation: page-slide-out-forward 180ms ease forwards;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-motion--exit.is-backward {
|
.page-motion--exit.is-backward {
|
||||||
animation: page-slide-out-backward 180ms ease both;
|
animation: page-slide-out-backward 180ms ease forwards;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes page-slide-in-forward {
|
/* Cancel child's own entrance animation during exit */
|
||||||
from {
|
.page-motion--exit .page-motion {
|
||||||
opacity: 0;
|
animation: none !important;
|
||||||
transform: translateX(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes page-slide-in-backward {
|
@keyframes page-out {
|
||||||
from {
|
to { opacity: 0; transform: translateY(-6px); }
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes page-slide-out-forward {
|
@keyframes page-slide-out-forward {
|
||||||
|
|||||||
@@ -65,18 +65,7 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-motion--exit {
|
/* page-motion--exit moved to page-transition.css */
|
||||||
animation: page-out 180ms ease both;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-motion--exit .page-motion {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes page-out {
|
|
||||||
to { opacity: 0; transform: translateY(-6px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-loading-center {
|
.page-loading-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -4890,7 +4890,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -563,3 +563,266 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Admin monitor ──────────────────────────── */
|
||||||
|
.admin-monitor-trigger {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 200;
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor-trigger__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
animation: admin-monitor-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes admin-monitor-pulse {
|
||||||
|
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||||
|
50% { opacity: 1; transform: scale(1.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 60px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 199;
|
||||||
|
width: min(480px, calc(100vw - 32px));
|
||||||
|
max-height: 70vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--border-normal);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: 0 8px 40px rgba(0,0,0,0.35);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-weak);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__header strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--fg-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__actions button {
|
||||||
|
padding: 3px 12px;
|
||||||
|
border: 1px solid var(--border-normal);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__actions button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__list {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__item {
|
||||||
|
border-bottom: 1px solid var(--border-weak);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__item summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 1fr 36px 100px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__source {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__msg {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--fg-body);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__count {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 24px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 107, 53, 0.15);
|
||||||
|
color: #ff6b35;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__item time {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__detail {
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__detail pre {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
font-size: 10px;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid var(--border-weak);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__pager button {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border: 1px solid var(--border-normal);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor__pager button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Browser notification + in-app toast for generation task completions.
|
||||||
|
* Falls back gracefully when Notification API is unavailable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let permissionGranted = false;
|
||||||
|
|
||||||
|
async function requestPermission(): Promise<boolean> {
|
||||||
|
if (permissionGranted) return true;
|
||||||
|
if (typeof Notification === "undefined") return false;
|
||||||
|
if (Notification.permission === "granted") { permissionGranted = true; return true; }
|
||||||
|
if (Notification.permission === "denied") return false;
|
||||||
|
try {
|
||||||
|
const result = await Notification.requestPermission();
|
||||||
|
permissionGranted = result === "granted";
|
||||||
|
return permissionGranted;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyTaskCompleted(label: string, mode: "image" | "video" = "image") {
|
||||||
|
const emoji = mode === "video" ? "🎬" : "🖼️";
|
||||||
|
const title = `${emoji} ${label}生成完成`;
|
||||||
|
const body = "点击返回查看生成结果";
|
||||||
|
|
||||||
|
// Browser notification (background tab)
|
||||||
|
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
|
||||||
|
try { new Notification(title, { body, icon: "/favicon.ico", tag: "gen-complete" }); } catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-app toast
|
||||||
|
dispatchGenToast(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the existing toast system for in-app notifications
|
||||||
|
function dispatchGenToast(msg: string) {
|
||||||
|
try {
|
||||||
|
import("../components/toast/toastStore").then((m) => m.toast(msg, "success"));
|
||||||
|
} catch { /* toast system not loaded */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call once on app init to pre-warm permission. */
|
||||||
|
export async function initNotificationPermission() {
|
||||||
|
if (typeof Notification === "undefined") return;
|
||||||
|
if (Notification.permission === "default") {
|
||||||
|
// Don't prompt immediately — wait for first user interaction
|
||||||
|
document.addEventListener("click", () => requestPermission(), { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user