Compare commits

...

13 Commits

Author SHA1 Message Date
ludan f0f6f66d60 feat: 登录注册表单体验优化、工作台滚动按钮交互增强、主题层视觉精修
ProfilePage.tsx(登录/注册表单优化):
- 新增邮箱格式、手机号、密码强度前端校验逻辑
- 输入框内联校验提示(CheckCircleFilled图标+绿色文字)
- 登录态展示区增加平台能力Stats(Studio/Assets/Team)
- 表单增加kicker标签区分"账户登录"/"新用户注册"
- 手机验证码tab标签精简为"手机"

WorkbenchPage.tsx(工作台滚动交互增强):
- 新增滚动操作按钮显隐状态管理(scrollActionsVisible/direction)
- 滚动时自动展示上下滚动按钮,950ms后自动隐藏
- scrollMessagesToLatest触发时间接展示滚动按钮
- 组件卸载时清理定时器避免内存泄漏

dark-green.css(主题层视觉精修):
- 工作台激活态页面背景增加微光渐变和径向光晕
- 消息表面区域增加内边距和scrollbar-gutter稳定布局
- 消息列表最大宽度约束为1040px,左右padding自适应
- 消息气泡增加边框、背景、阴影层次感
- AI助手气泡与用户气泡差异化背景色
- 头像增加微边框和accent色区分
- 作者标签字号和颜色精细调整
2026-06-03 15:38:52 +08:00
ludan da45196c6e Merge remote-tracking branch 'origin/master' into feat/profile-ui-polish 2026-06-03 10:48:48 +08:00
stringadmin a8266fc475 Merge pull request 'Fix/ecommerce video 400 bug' (#7) from fix/ecommerce-video-400-bug into master
Reviewed-on: #7
2026-06-03 02:47:07 +00:00
stringadmin d70f7a231f fix: 首页电商模板图片改为线上URL,解决其他开发者本地缺失文件导致构建失败
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:45:58 +08:00
stringadmin 4ed02aaad5 feat: 错误监控面板、生成通知、社区搜索、任务队列优化
- AdminMonitor: admin用户可见的客户端错误实时监控面板,右下角浮窗
- generationNotifier: 生成完成浏览器通知 + 站内Toast
- CommunityPage: 新增搜索框,标题/描述/标签模糊匹配,防抖300ms
- App.tsx: 全局unhandled error/rejection监听上报
- WorkbenchPage: 任务并发提示改为显示当前任务数
- serverConnection: 后端client-errors路由注册
- WelcomeSplash: 欢迎按钮全程显示

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-06-03 02:01:21 +08:00
stringadmin 468d1d27dd fix: 全站页面保活机制、登录拦截优化、UI修复与功能完善
- 移除未登录全页面拦截,改为浏览自由 + 功能使用时弹窗
- 修复PageTransition退出动画卡死导致黑屏的bug
- CanvasPage添加加载中状态避免首次访问黑屏假死
- 全站7个工具页添加页面保活机制,切页后台任务不中断
- 修复未登录时401误触发"用户已在别处登录"弹窗
- 删除MorePage模板板块、微信登录、EcommerceTemplates/SizeTemplate路由
- 剧本评分接入DashScope qwen3.7-max直连API
- 电商视频生成重构为3阶段可视管线(策划→生成图片→生成视频)
- 电商视频保活增强:异步函数直接写localStorage避免卸载丢失
- Workbench侧边栏移除mode过滤,三模式共用同一对话列表
- 首页更新轮播图/背景视频、按钮跳转修正、文案优化
- AppShell顶栏新增网站备案信息按钮
- 多个页面的terminate/cancel按钮覆盖、单镜头重试、批量保存下载

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-06-03 01:39:06 +08:00
stringadmin 8f57e08004 Merge branch 'master' of http://118.145.251.184:3000/OmniAI/omniai-web into fix/ecommerce-video-400-bug 2026-06-02 23:26:23 +08:00
stringadmin e555209516 fix: page transition UI jitter — remove enter phase to prevent double animation
The three-phase exit→enter→idle flow caused a visible "double refresh"
jitter. During the enter phase (220ms), the wrapper animated from
opacity:0 while cancelling child .page-motion with animation:none
!important. When phase switched to idle, the !important rule was
removed and child .page-motion re-triggered, creating a second
entrance animation — the jitter.

Fix: remove the enter phase entirely. After exit animation (180ms),
phase goes directly to idle. The child page's own .page-motion class
handles entrance naturally via React's fresh DOM mount. No wrapper
animation on enter, no double-animation conflict.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 22:19:14 +08:00
stringadmin ec9204437d feat(ecommerce): dynamic set image count + per-type API calls
Previously all 4 image tools generated a single image and duplicated
it across 5 fixed card slots. Now:

- Set (套图) mode: uses cloneSetCounts (卖点图/白底图/场景图 quantity)
  to determine how many images to generate. Each type gets its own
  createImageTask call with a type-specific prompt, so images differ
  by category (selling-point vs white-background vs lifestyle scene).
- Preview cards are dynamically built from cloneSetCounts, not from
  the fixed 5-slot productSetPreviewCards template. A card labeled
  "卖点图 1", "卖点图 2" etc for count > 1, or just "卖点图" for
  count = 1.
- clonePreview: shows dynamic card grid for set mode, single result
  for detail/model/hot modes.
- setPreview: shows original image as main card, then all generated
  set cards in the grid.
- generateSetImages: new async function that loops over each count
  type and generates images sequentially with distinct prompts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 22:09:12 +08:00
stringadmin 51b711226e fix(ecommerce): show generated images in all tool previews
The set/detail preview areas were using static placeholder images
instead of the API-generated results. Fix:

- Add productSetResultImages state for set tool results
- Add detailResultUrl state for detail tool results
- Create setPreviewCards (like clonePreviewCards) that overlays
  generated URLs onto static card templates
- Replace setPreview JSX references from productSetPreviewCards
  to setPreviewCards so generated URLs are displayed
- Replace detailPreview longPage image with detailResultUrl fallback
- Update handleSetGenerate setResultFn to save URLs via
  setProductSetResultImages
- Update handleDetailGenerate setResultFn to save URL via
  setDetailResultUrl

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:31:43 +08:00
stringadmin b07ff439f3 feat(ecommerce): replace mock image generators with real gpt-image-2 API calls
All four image tools (套图, 详情图, 模特图, 爆款图复刻) previously used
setTimeout + static sample images. Now they:

1. Upload product images to OSS via uploadAssetBinary
2. Build contextual prompts including platform/ratio/language/market + user text
3. Call aiGenerationClient.createImageTask with model=gpt-image-2
4. Poll task status via waitForTask (SSE + fallback polling)
5. Display the generated result URL in the preview grid

Key changes:
- Add ServerRequestError + waitForTask imports
- Add imageAbortRef for task cancellation
- Add uploadCloneImages() (generic version of uploadProductImages)
- Add buildEcommerceImagePrompt() with output-type-specific instructions
- Add generateEcommerceImage() orchestrating upload → prompt → API → result
- Replace all 5 mock handlers (handleGenerate, handleSetGenerate,
  handleDetailGenerate, handleTryOnGenerate, handleGenerateModel) with
  real async API calls
- Handle 402 (Payment Required) with user-friendly error message

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:19:52 +08:00
stringadmin 3f19829126 fix(ecommerce): handle 402 Payment Required — stop rendering loop and show balance warning
When the server returns 402 (balance insufficient), the rendering loop
continued submitting all remaining scenes, each failing with the same 402.
Now it immediately stops the loop, sets a clear "余额不足,请充值后再生成视频"
error message, and aborts further scene submissions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 19:42:20 +08:00
stringadmin 5fcd225825 fix(ecommerce): video 400 error — use OSS URLs instead of data URLs for video generation
The renderScene function was passing local data URLs (data:image/png;base64,...)
as imageUrl and referenceUrls to createVideoTask, which the /api/ai/video endpoint
rejects with 400 Bad Request. The planning phase already uploads images to OSS
but the resulting URLs were not returned to the component.

- Add imageUrls field to EcommerceVideoPlanResult
- Return OSS imageUrls from runVideoPlan alongside existing plan data
- Use planResult.imageUrls[0] in handleRender instead of productImageDataUrls[0]
- Use planResult?.imageUrls[0] for sourceImage display fallback

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 19:37:29 +08:00
42 changed files with 3516 additions and 547 deletions
+30 -62
View File
@@ -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":
+5
View File
@@ -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
View File
@@ -1,5 +1,3 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
export interface ScriptEvalResult { export interface ScriptEvalResult {
totalScore: number; totalScore: number;
grade: string; grade: string;
@@ -10,6 +8,10 @@ export interface ScriptEvalResult {
suggestions: string[]; suggestions: string[];
} }
const DASHSCOPE_API_KEY = import.meta.env.VITE_DASHSCOPE_API_KEY || "";
const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions";
const MODEL = "qwen3.7-max";
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。 const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
【剧本类型识别】 【剧本类型识别】
@@ -67,31 +69,35 @@ function extractJson(text: string): unknown {
} }
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> { export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
console.log("[API] 发送评测请求,剧本长度:", script.slice(0, 8000).length, "字符"); if (!DASHSCOPE_API_KEY) {
const res = await fetch(buildApiUrl("ai/chat"), { throw new Error("DashScope API key 未配置,请在 .env.local 中设置 VITE_DASHSCOPE_API_KEY");
}
const res = await fetch(DASHSCOPE_ENDPOINT, {
method: "POST", method: "POST",
headers: buildAuthHeaders(), headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${DASHSCOPE_API_KEY}`,
},
body: JSON.stringify({ body: JSON.stringify({
model: "qwen3.7-max", model: MODEL,
messages: [ messages: [
{ role: "system", content: EVAL_SYSTEM_PROMPT }, { role: "system", content: EVAL_SYSTEM_PROMPT },
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` }, { role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
], ],
stream: false, stream: false,
temperature: 0.3, temperature: 0.3,
max_tokens: 4096,
}), }),
signal, signal,
}); });
console.log("[API] 响应状态:", res.status, res.statusText);
if (!res.ok) { if (!res.ok) {
throw new Error(`评测请求失败 (${res.status})`); const errText = await res.text().catch(() => "");
throw new Error(`评测请求失败 (${res.status}): ${errText.slice(0, 200)}`);
} }
const payload = await res.json(); const payload = await res.json();
console.log("[API] 原始响应体:", payload);
const content: string = payload?.choices?.[0]?.message?.content const content: string = payload?.choices?.[0]?.message?.content
?? payload?.result?.content ?? payload?.result?.content
?? payload?.content ?? payload?.content
@@ -100,11 +106,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
if (!content) throw new Error("模型未返回有效内容"); if (!content) throw new Error("模型未返回有效内容");
console.log("[API] 模型返回内容 (前500字符):", content.slice(0, 500));
const parsed = extractJson(content) as Record<string, unknown>; const parsed = extractJson(content) as Record<string, unknown>;
console.log("[API] 解析后的JSON:", parsed);
const dimensionScores: Record<string, number> = {}; const dimensionScores: Record<string, number> = {};
const rawScores = parsed.dimensionScores as Record<string, number> | undefined; const rawScores = parsed.dimensionScores as Record<string, number> | undefined;
if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常"); if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常");
@@ -115,7 +117,6 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
} }
const { totalScore, grade } = computeTotalAndGrade(dimensionScores); const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
console.log("[API] 计算后总分:", totalScore, "等级:", grade);
return { return {
totalScore, totalScore,
+5
View File
@@ -234,6 +234,10 @@ function notifySessionExpired(status: number, response: Response, payload: unkno
if (/\/auth\//i.test(response.url)) return; if (/\/auth\//i.test(response.url)) return;
// SESSION_REPLACED has its own dedicated handling/modal. // SESSION_REPLACED has its own dedicated handling/modal.
if (getPayloadCode(payload) === "SESSION_REPLACED") return; if (getPayloadCode(payload) === "SESSION_REPLACED") return;
// If the user never had a session, a 401 is expected — not a session expiry.
if (!readStoredSession()) return;
// Deliberate early-exit for unauthenticated users — not a real auth failure.
if (getPayloadCode(payload) === "NOT_LOGGED_IN") return;
const now = Date.now(); const now = Date.now();
if (now - lastSessionExpiredEventAt < 1500) return; if (now - lastSessionExpiredEventAt < 1500) return;
@@ -250,6 +254,7 @@ function notifySessionReplaced(status: number, payload: unknown, fallbackMessage
const message = getPayloadMessage(payload) || fallbackMessage || "您已在别处登录"; const message = getPayloadMessage(payload) || fallbackMessage || "您已在别处登录";
const isSessionReplaced = code === "SESSION_REPLACED" || message.includes("您已在别处登录"); const isSessionReplaced = code === "SESSION_REPLACED" || message.includes("您已在别处登录");
if (!isSessionReplaced || typeof window === "undefined") return; if (!isSessionReplaced || typeof window === "undefined") return;
if (!readStoredSession()) return;
const now = Date.now(); const now = Date.now();
if (now - lastSessionReplacedEventAt < 1500) return; if (now - lastSessionReplacedEventAt < 1500) return;
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

+110
View File
@@ -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;
+43
View File
@@ -3,8 +3,12 @@ import {
ArrowUpOutlined, ArrowUpOutlined,
CheckCircleOutlined, CheckCircleOutlined,
FlagOutlined, FlagOutlined,
InfoCircleOutlined,
LoginOutlined, LoginOutlined,
LogoutOutlined, LogoutOutlined,
PhoneOutlined,
SafetyOutlined,
EnvironmentOutlined,
PlusCircleOutlined, PlusCircleOutlined,
UserOutlined, UserOutlined,
WalletOutlined, WalletOutlined,
@@ -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>9A楼501</dd>
<dt></dt>
<dd>15155073618</dd>
</dl>
<div className="info-popover__links">
<a href="#" onClick={(e) => { e.preventDefault(); setInfoOpen(false); }}></a>
<a href="#" onClick={(e) => { e.preventDefault(); setInfoOpen(false); }}></a>
</div>
</AnimatedPanel>
</div>
<button <button
className="member-button" className="member-button"
type="button" type="button"
@@ -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>
); );
+19 -7
View File
@@ -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,11 +79,11 @@ 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>
); );
} }
+46 -30
View File
@@ -48,6 +48,7 @@ import {
} from "./canvasCommunityPublish"; } from "./canvasCommunityPublish";
import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence"; import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence";
import { normalizeCanvasWorkflowSchema } from "./canvasWorkflowSchema"; import { normalizeCanvasWorkflowSchema } from "./canvasWorkflowSchema";
import { createBlankWorkflow } from "../../data/workflows";
import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory"; import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory";
import { useCanvasKeyboard } from "./useCanvasKeyboard"; import { useCanvasKeyboard } from "./useCanvasKeyboard";
import { useCanvasNodeDrag } from "./useCanvasNodeDrag"; import { useCanvasNodeDrag } from "./useCanvasNodeDrag";
@@ -310,7 +311,7 @@ function getCameraMotionPrompt(value: string): string {
} }
function CanvasPage({ function CanvasPage({
workflow, workflow: rawWorkflow,
projectId, projectId,
projects = [], projects = [],
projectsLoaded = true, projectsLoaded = true,
@@ -323,6 +324,7 @@ function CanvasPage({
onSaveWorkflow, onSaveWorkflow,
onCreateTask, onCreateTask,
}: CanvasPageProps) { }: CanvasPageProps) {
const workflow = rawWorkflow || createBlankWorkflow();
const [contextMenu, setContextMenu] = useState<CanvasFloatingMenuPosition | null>(null); const [contextMenu, setContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
const [nodeMenu, setNodeMenu] = useState<CanvasFloatingMenuPosition | null>(null); const [nodeMenu, setNodeMenu] = useState<CanvasFloatingMenuPosition | null>(null);
const [textNodeMenu, setTextNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null); const [textNodeMenu, setTextNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
@@ -404,6 +406,7 @@ function CanvasPage({
textGenerationState, imageGenerationState, videoGenerationState, textGenerationState, imageGenerationState, videoGenerationState,
generationToast, setGenerationToast, generationToast, setGenerationToast,
imageGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef, imageGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus, setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
restoreKeepaliveTasks, resetGenerationState, restoreKeepaliveTasks, resetGenerationState,
} = useCanvasGeneration({ setImageNodes, setVideoNodes }); } = useCanvasGeneration({ setImageNodes, setVideoNodes });
@@ -524,6 +527,7 @@ function CanvasPage({
const canvasAssets = serverAssets.filter((asset) => asset.imageUrl); const canvasAssets = serverAssets.filter((asset) => asset.imageUrl);
const shouldShowEmptyProjectState = const shouldShowEmptyProjectState =
projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0; projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0;
const isWaitingForProjects = isAuthenticated && !projectsLoaded;
const [projectSaveState, setProjectSaveState] = useState<CanvasProjectSaveState>({ const [projectSaveState, setProjectSaveState] = useState<CanvasProjectSaveState>({
status: "idle", status: "idle",
message: "", message: "",
@@ -571,10 +575,13 @@ function CanvasPage({
imageNodeIdRef.current = nextImageNodes.length + 1; imageNodeIdRef.current = nextImageNodes.length + 1;
videoNodeIdRef.current = nextVideoNodes.length + 1; videoNodeIdRef.current = nextVideoNodes.length + 1;
// Reset keepalive flag so tasks can be restored for this project
canvasGenKeepaliveRestoredRef.current = false;
if (projectId && isAuthenticated) { if (projectId && isAuthenticated) {
restoreKeepaliveTasks(projectId, nextImageNodes, nextVideoNodes); restoreKeepaliveTasks(projectId, nextImageNodes, nextVideoNodes);
} }
}, [workflow.id, workflow.nodes, projectId, isAuthenticated]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflow.id, workflow.nodes, projectId]);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) { if (!isAuthenticated) {
@@ -3524,16 +3531,16 @@ function CanvasPage({
return ( return (
<WorkspacePageShell title="画布" fullWidth className="canvas-page page-motion"> <WorkspacePageShell title="画布" fullWidth className="canvas-page page-motion">
<div className={`studio-tool-layout studio-tool-layout--no-top studio-tool-layout--no-left studio-tool-layout--no-right studio-tool-layout--canvas${shouldShowEmptyProjectState ? " studio-tool-layout--canvas-empty" : ""}`}> <div className={`studio-tool-layout studio-tool-layout--no-top studio-tool-layout--no-left studio-tool-layout--no-right studio-tool-layout--canvas${(shouldShowEmptyProjectState || isWaitingForProjects) ? " studio-tool-layout--canvas-empty" : ""}`}>
<section <section
className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${shouldShowEmptyProjectState ? " is-empty-projects" : ""}`} className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${(shouldShowEmptyProjectState || isWaitingForProjects) ? " is-empty-projects" : ""}`}
ref={canvasRef} ref={canvasRef}
onAuxClick={shouldShowEmptyProjectState ? undefined : handleCanvasAuxClick} onAuxClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasAuxClick}
onContextMenu={shouldShowEmptyProjectState ? (event) => event.preventDefault() : handleCanvasContextMenu} onContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? (event) => event.preventDefault() : handleCanvasContextMenu}
onMouseDownCapture={shouldShowEmptyProjectState ? undefined : handleCanvasMouseDown} onMouseDownCapture={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseDown}
onDoubleClick={shouldShowEmptyProjectState ? undefined : handleCanvasDoubleClick} onDoubleClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDoubleClick}
onMouseMove={shouldShowEmptyProjectState ? undefined : handleCanvasMouseMove} onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove}
onWheel={shouldShowEmptyProjectState ? undefined : handleCanvasWheel} onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel}
style={{ style={{
"--canvas-bg-size": `${24 * canvasViewport.zoom}px`, "--canvas-bg-size": `${24 * canvasViewport.zoom}px`,
"--canvas-bg-x": `${canvasViewport.x}px`, "--canvas-bg-x": `${canvasViewport.x}px`,
@@ -3555,7 +3562,7 @@ function CanvasPage({
className="studio-canvas-hidden-input" className="studio-canvas-hidden-input"
onChange={(event) => handleImageFileSelected(event, pendingImagePosition)} onChange={(event) => handleImageFileSelected(event, pendingImagePosition)}
/> />
{!shouldShowEmptyProjectState ? ( {(!shouldShowEmptyProjectState || isWaitingForProjects) ? (
<div className="studio-canvas-project-bar" onMouseDown={(event) => event.stopPropagation()}> <div className="studio-canvas-project-bar" onMouseDown={(event) => event.stopPropagation()}>
<div className="studio-canvas-project-bar__identity"> <div className="studio-canvas-project-bar__identity">
{projectNameEditing ? ( {projectNameEditing ? (
@@ -3650,7 +3657,7 @@ function CanvasPage({
</button> </button>
</div> </div>
) : null} ) : null}
{!shouldShowEmptyProjectState && recentProjectsOpen ? ( {(!shouldShowEmptyProjectState || isWaitingForProjects) && recentProjectsOpen ? (
<aside <aside
id="studio-canvas-recent-drawer" id="studio-canvas-recent-drawer"
className="studio-canvas-recent-drawer" className="studio-canvas-recent-drawer"
@@ -3718,8 +3725,8 @@ function CanvasPage({
zoomOnPinch={false} zoomOnPinch={false}
zoomOnScroll={false} zoomOnScroll={false}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
onPaneClick={shouldShowEmptyProjectState ? undefined : handlePaneClick} onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick}
onPaneContextMenu={shouldShowEmptyProjectState ? undefined : handlePaneContextMenu} onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
> >
<Background gap={24} color="transparent" className="studio-canvas__background" /> <Background gap={24} color="transparent" className="studio-canvas__background" />
</ReactFlow> </ReactFlow>
@@ -3731,7 +3738,7 @@ function CanvasPage({
<button type="button" title="放大" onClick={zoomCanvasIn}>+</button> <button type="button" title="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" onClick={fitCanvasView}></button> <button type="button" title="适应视图" onClick={fitCanvasView}></button>
</div> </div>
{shouldShowEmptyProjectState ? ( {(shouldShowEmptyProjectState || isWaitingForProjects) ? (
<div <div
className="studio-canvas-empty-projects" className="studio-canvas-empty-projects"
role="status" role="status"
@@ -3742,21 +3749,30 @@ function CanvasPage({
event.stopPropagation(); event.stopPropagation();
}} }}
> >
<strong></strong> {isWaitingForProjects ? (
<button <>
type="button" <div className="studio-canvas-loading-spinner" />
className="studio-canvas-empty-projects__button" <strong></strong>
onClick={(event) => { </>
event.stopPropagation(); ) : (
if (onStartCreate) { <>
onStartCreate(); <strong></strong>
return; <button
} type="button"
onOpenLogin(); className="studio-canvas-empty-projects__button"
}} onClick={(event) => {
> event.stopPropagation();
if (onStartCreate) {
</button> onStartCreate();
return;
}
onOpenLogin();
}}
>
</button>
</>
)}
</div> </div>
) : null} ) : null}
{selectionRect ? ( {selectionRect ? (
@@ -22,6 +22,7 @@ import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { uploadAssetWithProgress } from "../../api/uploadWithProgress"; import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions"; import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
import { CheckCircleOutlined, InfoCircleOutlined } from "@ant-design/icons"; import { CheckCircleOutlined, InfoCircleOutlined } from "@ant-design/icons";
@@ -85,12 +86,32 @@ function CharacterMixPage({
}; };
}, [checkImage, characterPreview]); }, [checkImage, characterPreview]);
const keepaliveRestoredRef = useRef(false);
// Keep-alive: restore saved task on mount
useEffect(() => {
if (keepaliveRestoredRef.current) return;
keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("charactermix");
if (!saved || saved.resultUrl) return;
setIsCreating(true);
abortRef.current = false;
void pollTaskUntilDone(saved.taskId).then((result) => {
setResultUrl(result);
setNotice(result ? "角色迁移完成" : "已取消");
setIsCreating(false);
setProgress(0);
if (result) {
saveToolTaskState("charactermix", { taskId: saved.taskId, resultUrl: result, status: "完成", progress: 100 });
} else {
clearToolTaskState("charactermix");
}
});
}, []);
useEffect(() => { useEffect(() => {
return () => { return () => {
abortRef.current = true; abortRef.current = true;
if (taskIdRef.current) {
aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {});
}
}; };
}, []); }, []);
@@ -100,6 +121,7 @@ function CharacterMixPage({
aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {}); aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {});
taskIdRef.current = null; taskIdRef.current = null;
} }
clearToolTaskState("charactermix");
}, []); }, []);
const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => { const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => {
@@ -153,10 +175,16 @@ function CharacterMixPage({
muted: !watermark, muted: !watermark,
}); });
taskIdRef.current = taskId; taskIdRef.current = taskId;
saveToolTaskState("charactermix", { taskId, status: "running", progress: 0 });
const result = await pollTaskUntilDone(taskId); const result = await pollTaskUntilDone(taskId);
setResultUrl(result); setResultUrl(result);
setNotice(result ? "角色迁移完成" : "已取消"); setNotice(result ? "角色迁移完成" : "已取消");
if (result) {
saveToolTaskState("charactermix", { taskId, resultUrl: result, status: "完成", progress: 100 });
} else {
clearToolTaskState("charactermix");
}
} catch (error) { } catch (error) {
setNotice(error instanceof Error ? error.message : "任务创建失败,请稍后重试。"); setNotice(error instanceof Error ? error.message : "任务创建失败,请稍后重试。");
} finally { } finally {
+26 -37
View File
@@ -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 });
} }
}, },
+273 -38
View File
@@ -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" ? (
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}> clonePreviewCards.map((card) => (
<img src={card.src} alt={card.label} /> <button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
<span>{card.label}</span> <img src={card.src} alt={card.label} />
<span>{card.label}</span>
</button>
))
) : results[0]?.src ? (
<button type="button" onClick={() => openProductSetPreview(results[0])}>
<img src={results[0].src} alt={selectedCloneOutput.label} />
<span>{selectedCloneOutput.label}</span>
</button> </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("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"} aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"}
durationSeconds={cloneVideoDuration} durationSeconds={cloneVideoDuration}
resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"} resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"}
onRequestLogin={() => ((_props as Record<string, (() => void) | undefined>).onRequireLogin?.())} onRequestLogin={() => ((_props as Record<string, unknown>).isAuthenticated ? undefined : (window.location.hash = "#/login"))}
/> />
</main> </main>
) : clonePreview) : placeholderPreview} ) : clonePreview) : placeholderPreview}
+408 -156
View File
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useRef, useState } from "react"; import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { import {
CopyOutlined, CopyOutlined,
DownloadOutlined, DownloadOutlined,
@@ -9,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,159 +185,229 @@ export default function EcommerceVideoWorkspace({
const handleDownload = async (url: string) => { const handleDownload = async (url: string) => {
try { try {
await saveToolResultToLocal({ await saveToolResultToLocal({
url, url, name: `ecommerce-video-${Date.now()}`, type: "video",
name: `ecommerce-video-${Date.now()}`, isVideo: true, tags: ["电商", "短视频", "生成视频"],
type: "video",
isVideo: true,
tags: ["电商", "短视频", "生成视频"],
}); });
showNotice("下载完成"); showNotice("下载完成");
} catch { } catch {
const a = document.createElement("a"); const a = document.createElement("a"); a.href = url; a.download = "ecommerce-video.mp4"; a.click();
a.href = url;
a.download = "ecommerce-video.mp4";
a.click();
} }
}; };
const handleSaveAsset = async (url: string) => { const handleSaveAsset = async (url: string) => {
try { try {
const result = await addToolResultToAssetLibrary({ const result = await addToolResultToAssetLibrary({
url, url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频生成结果",
name: `电商短视频-${Date.now()}.mp4`, type: "video", isVideo: true, tags: ["电商", "短视频", "广告视频"],
description: "电商广告视频生成结果",
type: "video",
isVideo: true,
tags: ["电商", "短视频", "广告视频"],
metadata: { source: "ecommerce-video", platform }, metadata: { source: "ecommerce-video", platform },
}); });
showNotice(result === "server" ? "已保存到资产库" : "已保存到本地资产库"); showNotice(result === "server" ? "已保存到资产库" : "已保存到本地资产库");
} catch { } catch { showNotice("保存失败"); }
showNotice("保存失败"); };
const handleSaveAllAssets = async () => {
if (!completedScenes.length) return;
let saved = 0;
for (const scene of completedScenes) {
try {
await addToolResultToAssetLibrary({
url: scene.resultUrl!, name: `电商短视频-镜头${scene.sceneId}-${Date.now()}.mp4`,
description: `电商广告视频 - 镜头${scene.sceneId}`,
type: "video", isVideo: true, tags: ["电商", "短视频", "广告视频"],
metadata: { source: "ecommerce-video", platform, sceneId: scene.sceneId },
});
saved++;
} catch { /* continue */ }
} }
showNotice(saved > 0 ? `已保存 ${saved}/${completedScenes.length} 个视频到资产库` : "保存失败");
};
const handleDownloadAll = async () => {
for (const scene of completedScenes) {
await new Promise((r) => setTimeout(r, 300));
const a = document.createElement("a");
a.href = scene.resultUrl!;
a.download = `ecommerce-video-scene-${scene.sceneId}.mp4`;
a.click();
}
showNotice(`正在下载 ${completedScenes.length} 个视频`);
}; };
const handleImportToCanvas = async (url: string) => { const handleImportToCanvas = async (url: string) => {
try { try {
await addToolResultToAssetLibrary({ await addToolResultToAssetLibrary({
url, url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频 - 导入画布",
name: `电商短视频-${Date.now()}.mp4`, type: "video", isVideo: true, tags: ["电商", "短视频", "画布导入"],
description: "电商广告视频 - 导入画布",
type: "video",
isVideo: true,
tags: ["电商", "短视频", "画布导入"],
metadata: { source: "ecommerce-video", platform }, metadata: { source: "ecommerce-video", platform },
}); });
setView("canvas"); setView("canvas");
showNotice("已保存资产并跳转画布"); showNotice("已保存资产并跳转画布");
} catch { } catch { showNotice("导入失败"); }
showNotice("导入失败");
}
}; };
const buildConfig = useCallback((): AdVideoUserConfig => ({ const buildConfig = useCallback((): AdVideoUserConfig => ({
platform, platform, aspectRatio, durationSeconds,
aspectRatio, style: "痛点解决", language: "中文", market: "中国",
durationSeconds, needVoiceover: true, needSubtitle: true, conversionFocus: "conversion",
style: "痛点解决",
language: "中文",
market: "中国",
needVoiceover: true,
needSubtitle: true,
conversionFocus: "conversion",
}), [platform, aspectRatio, durationSeconds]); }), [platform, aspectRatio, durationSeconds]);
// ── Phase 1: Planning ──────────────────────────────────────
const handlePlan = async () => { const handlePlan = async () => {
if (!isAuthenticated) { onRequestLogin?.(); return; } if (!isAuthenticated) { onRequestLogin?.(); return; }
if (!productImageDataUrls.length && !requirement.trim()) { if (!productImageDataUrls.length && !requirement.trim()) {
setError("请先上传产品图片或填写商品说明"); setError("请先上传产品图片或填写商品说明"); return;
return;
} }
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
abortControllerRef.current = controller; abortControllerRef.current = controller;
setStage("planning"); setStage("planning"); setError(null);
setError(null); setCompletedSteps([]); setCurrentStep(null);
setCompletedSteps([]); setPlanResult(null); setScenes([]); setSourceImageUrls([]);
setCurrentStep(null);
setPlanResult(null);
setScenes([]);
try { try {
const result = await runVideoPlan( const result = await runVideoPlan(
productImageDataUrls, requirement, buildConfig(), productImageDataUrls, requirement, buildConfig(),
{ {
onStepStart: (step) => setCurrentStep(step), onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]), onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]),
onImagesUploaded: (urls) => { setSourceImageUrls(urls); saveEcommerceVideoState({ stage: "planning", completedSteps: ["upload"], planResult: null, scenes: [], sourceImageUrls: urls }); },
signal: controller.signal, signal: controller.signal,
}, },
); );
const builtScenes = buildSceneTasks(result);
setPlanResult(result); setPlanResult(result);
setScenes(buildSceneTasks(result)); setScenes(builtScenes);
setStage("planned"); setStage("planned");
// Persist immediately — component may be unmounted by the time React re-renders
saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, scenes: builtScenes, sourceImageUrls: result.imageUrls });
} catch (err) { } catch (err) {
if ((err as Error).name === "AbortError") return; if ((err as Error).name === "AbortError") return;
setError(err instanceof Error ? err.message : "策划失败"); setError(err instanceof Error ? err.message : "策划失败");
setStage("idle"); setStage("idle");
} finally { } finally { setCurrentStep(null); }
setCurrentStep(null);
}
}; };
const handleRender = async () => { // ── Phase 2: Image generation per scene ──────────────────────
if (!planResult || !scenes.length) return;
const imageUrl = productImageDataUrls[0] || "";
setStage("rendering");
setError(null);
renderAbortRef.current = { current: false };
const quality = mapResolutionToQuality(resolution);
for (const scene of scenes) { const handleGenerateImages = async () => {
if (!planResult || !scenes.length) return;
setStage("imaging"); setError(null);
renderAbortRef.current = { current: false };
const ratio = aspectRatio.includes("9:16") || aspectRatio.includes("916") ? "9:16"
: aspectRatio.includes("16:9") || aspectRatio.includes("169") ? "16:9"
: "1:1";
let currentScenes = [...scenes];
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
currentScenes = next;
setScenes(next);
saveEcommerceVideoState({ stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
};
for (const scene of currentScenes) {
if (renderAbortRef.current.current) break; if (renderAbortRef.current.current) break;
setScenes((prev) => prev.map((s) => persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
try { try {
await renderScene( await renderSceneImage(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl, aspectRatio, resolution: quality }, { sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
{ {
onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)), onSceneImageSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)),
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)), onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneCompleted: (id, url) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)), onSceneImageCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)),
onSceneFailed: (id, err2) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)), onSceneImageFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)),
}, },
renderAbortRef.current, renderAbortRef.current,
); );
} catch (err) { } catch (err) {
setScenes((prev) => prev.map((s) => const message = err instanceof Error ? err.message : "图片生成失败";
s.sceneId === scene.sceneId ? { ...s, status: "failed", error: err instanceof Error ? err.message : "生成失败" } : s)); persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "idle", error: message } : s));
} }
} }
setScenes((current) => { const allHaveImages = currentScenes.every((s) => s.imageUrl);
const hasFailed = current.some((s) => s.status === "failed"); const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const;
const allDone = current.every((s) => s.status === "completed" || s.status === "failed"); setStage(finalStage);
if (allDone) setStage(hasFailed ? "partial_failed" : "completed"); saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
return current;
});
}; };
const handleCancel = () => { // ── Phase 3: Video rendering from generated images ──────────
abortControllerRef.current?.abort();
renderAbortRef.current.current = true; const handleRenderVideos = async () => {
setStage("cancelled"); if (!scenes.length) return;
const firstImage = scenes[0]?.imageUrl;
if (!firstImage) { setError("请先生成分镜图片"); return; }
setStage("rendering"); setError(null);
renderAbortRef.current = { current: false };
const quality = mapResolutionToQuality(resolution);
let currentScenes = [...scenes];
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
currentScenes = next;
setScenes(next);
saveEcommerceVideoState({ stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
};
for (const scene of currentScenes) {
if (renderAbortRef.current.current) break;
if (!scene.imageUrl) continue;
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
try {
await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality },
{
onSceneSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
onSceneFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
},
renderAbortRef.current,
);
} catch (err) {
const msg = err instanceof Error ? err.message : "生成失败";
const isPayment = err instanceof ServerRequestError && err.status === 402;
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: isPayment ? "余额不足,请充值后继续" : msg } : s));
if (isPayment) { setError("余额不足,请充值后再生成视频"); renderAbortRef.current.current = true; break; }
}
}
const hasFailed = currentScenes.some((s) => s.status === "failed");
const allDone = currentScenes.every((s) => s.status === "completed" || s.status === "failed");
const finalStage = allDone ? (hasFailed ? "partial_failed" as const : "completed" as const) : "rendering" as const;
setScenes(currentScenes);
setStage(finalStage);
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
}; };
const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); };
const handleRetryScene = async (scene: EcommerceVideoSceneTask) => {
if (!scene.imageUrl) return;
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
try {
await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, aspectRatio, resolution: mapResolutionToQuality(resolution) },
{
onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneCompleted: (id, url) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
onSceneFailed: (id, err2) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
},
renderAbortRef.current,
);
} catch (err) {
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: (err as Error).message } : s));
}
};
// ── Derived state ───────────────────────────────────────────
const completedScenes = scenes.filter((s) => s.status === "completed" && s.resultUrl); const completedScenes = scenes.filter((s) => s.status === "completed" && s.resultUrl);
const imagedScenes = scenes.filter((s) => s.imageUrl);
const primaryVideo = completedScenes[0]?.resultUrl; const primaryVideo = completedScenes[0]?.resultUrl;
const canRender = planResult?.compliance.allow_video_generation && stage === "planned"; const sourceImage = sourceImageUrls[0] || planResult?.imageUrls[0] || productImageDataUrls[0] || "";
const sourceImage = 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__status-orb" aria-hidden="true" /> <span className="ecom-video-flow-node__label"></span>
</article> <span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
) : null} </article>
{sourceImage && completedScenes.length > 0 ? ( {/* Connector: source → plan text nodes */}
{visiblePlanSteps.length > 0 ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div> <div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null} ) : null}
<div className="ecom-video-scene-strip" aria-label="已完成分镜节点"> {/* Plan text nodes — side by side */}
{completedScenes.map((scene, index) => ( {visiblePlanSteps.length > 0 ? (
<Fragment key={scene.sceneId}> <div className="ecom-video-scene-strip ecom-video-scene-strip--text" aria-label="策划节点">
<article {visiblePlanSteps.map((step, idx) => (
className="ecom-video-flow-node ecom-video-flow-node--scene is-completed" <Fragment key={step}>
aria-label={`镜头 ${scene.sceneId},完成`} <article className={`ecom-video-flow-node ecom-video-flow-node--text is-completed${currentStep === step ? " is-pulsing" : ""}`}
title={`镜头 ${scene.sceneId}`} aria-label={PLAN_STEP_LABELS[step]} title={PLAN_STEP_LABELS[step]}>
> <span className="ecom-video-flow-node__text-icon">
<div className="ecom-video-flow-node__media"> {currentStep === step ? <LoadingOutlined /> : "✓"}
<video src={scene.resultUrl!} muted playsInline loop autoPlay /> </span>
</div> <span className="ecom-video-flow-node__label">{PLAN_STEP_LABELS[step]}</span>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" /> </article>
</article> {idx < visiblePlanSteps.length - 1 ? (
{index < completedScenes.length - 1 ? ( <div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div> ) : null}
) : null} </Fragment>
</Fragment> ))}
))} </div>
</div> ) : null}
{completedScenes.length > 0 && primaryVideo ? ( {/* Connector: plan → images */}
{hasImaging ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div> <div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null} ) : null}
{primaryVideo ? ( {/* Storyboard image nodes — side by side per scene */}
<article className="ecom-video-flow-node ecom-video-flow-node--final is-completed" aria-label="成片节点,已完成"> {hasImaging ? (
<div className="ecom-video-flow-node__media"> <div className="ecom-video-scene-strip" aria-label="分镜图片节点">
<video src={primaryVideo} muted playsInline loop autoPlay /> {scenes.map((scene, idx) => {
</div> const imgReady = !!scene.imageUrl;
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" /> const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
</article> const cls = imgReady ? "is-completed" : imgRunning ? "is-active" : "";
return (
<Fragment key={`img-${scene.sceneId}`}>
<article className={`ecom-video-flow-node ecom-video-flow-node--image ${cls}`}
aria-label={`分镜 ${scene.sceneId}`} title={`分镜 ${scene.sceneId}`}>
<div className="ecom-video-flow-node__media">
{imgReady ? <img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
: imgRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
: <div className="ecom-video-flow-node__placeholder"></div>}
</div>
{imgRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-flow-node__label">{scene.sceneId}</span>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{idx < scenes.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
);
})}
</div>
) : null}
{/* Connector: images → videos */}
{hasRendering ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
{/* Video nodes — side by side per scene */}
{hasRendering ? (
<div className="ecom-video-scene-strip" aria-label="视频分镜节点">
{scenes.map((scene, idx) => {
const vidReady = scene.status === "completed" && scene.resultUrl;
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
const vidFailed = scene.status === "failed";
const cls = vidReady ? "is-completed" : vidRunning ? "is-active" : vidFailed ? "is-failed" : "";
return (
<Fragment key={`vid-${scene.sceneId}`}>
<article className={`ecom-video-flow-node ecom-video-flow-node--video ${cls}`}
aria-label={`镜头 ${scene.sceneId}`} title={`镜头 ${scene.sceneId}`}>
<div className="ecom-video-flow-node__media">
{vidReady ? <video src={scene.resultUrl!} muted playsInline loop autoPlay />
: vidRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
: vidFailed ? <div className="ecom-video-flow-node__placeholder"></div>
: <div className="ecom-video-flow-node__placeholder"></div>}
</div>
{vidRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-flow-node__label">{scene.sceneId}</span>
{vidFailed ? (
<button type="button" className="ecom-video-flow-node__retry"
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
title="重试此镜头">
<ReloadOutlined />
</button>
) : null}
{vidFailed && scene.error ? (
<span className="ecom-video-flow-node__error" title={scene.error}>{scene.error.slice(0, 20)}</span>
) : null}
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{idx < scenes.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
);
})}
</div>
) : null} ) : null}
</div> </div>
)} )}
{/* ── Delivery dock ────────────────────────────── */}
{primaryVideo ? ( {primaryVideo ? (
<div className="ecom-video-flow-dock" aria-label="视频交付操作"> <div className="ecom-video-flow-dock" aria-label="视频交付操作">
<button type="button" aria-label="下载当前视频" title="下载当前视频" onClick={() => void handleDownload(primaryVideo)}><DownloadOutlined /></button> <button type="button" onClick={() => void handleDownloadAll()} title={`下载全部 ${completedScenes.length} 个视频`}><DownloadOutlined /></button>
<button type="button" aria-label="保存到资产库" title="保存到资产库" onClick={() => void handleSaveAsset(primaryVideo)}><FolderAddOutlined /></button> <button type="button" onClick={() => void handleSaveAllAssets()} title={`保存全部 ${completedScenes.length} 个视频到资产库`}><FolderAddOutlined /></button>
<button type="button" aria-label="导入画布" title="导入画布" onClick={() => void handleImportToCanvas(primaryVideo)}><SendOutlined /></button> {primaryVideo ? <button type="button" onClick={() => void handleImportToCanvas(primaryVideo)} title="导入画布"><SendOutlined /></button> : null}
<button type="button" aria-label="复制视频链接" title="复制视频链接" onClick={() => void navigator.clipboard.writeText(primaryVideo)}><CopyOutlined /></button> {primaryVideo ? <button type="button" onClick={() => void navigator.clipboard.writeText(primaryVideo)} title="复制链接"><CopyOutlined /></button> : null}
</div> </div>
) : null} ) : null}
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null} {actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
@@ -0,0 +1,60 @@
import type {
EcommerceVideoStage,
EcommerceVideoSceneTask,
EcommerceVideoPlanResult,
PlanStep,
} from "./ecommerceVideoTypes";
const KEEPALIVE_KEY = "omniai:ecommerce-video-workspace";
interface EcommerceVideoKeepalive {
stage: EcommerceVideoStage;
completedSteps: PlanStep[];
planResult: EcommerceVideoPlanResult | null;
scenes: EcommerceVideoSceneTask[];
sourceImageUrls: string[];
savedAt: number;
}
export function saveEcommerceVideoState(state: {
stage: EcommerceVideoStage;
completedSteps: PlanStep[];
planResult: EcommerceVideoPlanResult | null;
scenes: EcommerceVideoSceneTask[];
sourceImageUrls?: string[];
}): void {
try {
const entry: EcommerceVideoKeepalive = {
...state,
sourceImageUrls: state.sourceImageUrls || [],
savedAt: Date.now(),
};
window.localStorage.setItem(KEEPALIVE_KEY, JSON.stringify(entry));
} catch {
// quota exceeded — silently drop
}
}
export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null {
try {
const raw = window.localStorage.getItem(KEEPALIVE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as EcommerceVideoKeepalive;
// Discard entries older than 2 hours
if (Date.now() - (parsed.savedAt || 0) > 2 * 60 * 60 * 1000) {
clearEcommerceVideoState();
return null;
}
return parsed;
} catch {
return null;
}
}
export function clearEcommerceVideoState(): void {
try {
window.localStorage.removeItem(KEEPALIVE_KEY);
} catch {
// ignore
}
}
@@ -20,6 +20,7 @@ import type {
export interface PlanCallbacks { export interface PlanCallbacks {
onStepStart: (step: PlanStep) => void; onStepStart: (step: PlanStep) => void;
onStepDone: (step: PlanStep) => void; onStepDone: (step: PlanStep) => void;
onImagesUploaded?: (urls: string[]) => void;
signal?: AbortSignal; signal?: AbortSignal;
} }
@@ -46,7 +47,9 @@ export async function runVideoPlan(
// skip images that fail to upload // skip images that fail to upload
} }
} }
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
onStepDone("upload"); onStepDone("upload");
callbacks.onImagesUploaded?.(imageUrls);
onStepStart("analyze"); onStepStart("analyze");
const imageDesc = await analyzeProductImages(imageUrls, signal); const imageDesc = await analyzeProductImages(imageUrls, signal);
@@ -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",
];
+7 -7
View File
@@ -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>
+1 -1
View File
@@ -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 : "生成失败";
+1 -10
View File
@@ -7,10 +7,7 @@ import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
HighlightOutlined, HighlightOutlined,
PictureOutlined,
ShoppingOutlined,
SwapOutlined, SwapOutlined,
TableOutlined,
ThunderboltOutlined, ThunderboltOutlined,
VideoCameraOutlined, VideoCameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
@@ -23,7 +20,7 @@ interface MorePageProps {
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void; onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
} }
type ToolCategory = "image" | "video" | "template"; type ToolCategory = "image" | "video";
type FilterKey = "all" | ToolCategory | "upcoming"; type FilterKey = "all" | ToolCategory | "upcoming";
interface MoreTool { interface MoreTool {
@@ -49,9 +46,6 @@ const tools: MoreTool[] = [
{ id: "digitalHuman", title: "数字人", text: "参考人像与音频生成口播视频", icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true }, { id: "digitalHuman", title: "数字人", text: "参考人像与音频生成口播视频", icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true },
{ id: "characterMix", title: "角色迁移", text: "人物图迁移到参考视频动作", icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true }, { id: "characterMix", title: "角色迁移", text: "人物图迁移到参考视频动作", icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true },
{ id: "avatarConsole", title: "数字人控制台", text: "形象、播报、互动与接入配置", icon: <DashboardOutlined />, category: "video", target: "avatarConsole", ready: true }, { id: "avatarConsole", title: "数字人控制台", text: "形象、播报、互动与接入配置", icon: <DashboardOutlined />, category: "video", target: "avatarConsole", ready: true },
{ id: "ecommerce", title: "示例模板", text: "电商场景与最近项目", icon: <ShoppingOutlined />, category: "template", target: "ecommerceTemplates", ready: true },
{ id: "grid", title: "多宫格", text: "9/25 宫格快速试拍", icon: <TableOutlined />, category: "template", ready: false, badge: "即将上线" },
{ id: "refOrganize", title: "参考图整理", text: "素材进入资产库前的轻处理", icon: <PictureOutlined />, category: "template", ready: false, badge: "即将上线" },
]; ];
interface FeaturedTool { interface FeaturedTool {
@@ -89,20 +83,17 @@ const featuredTools: FeaturedTool[] = [
const categoryLabels: Record<ToolCategory, string> = { const categoryLabels: Record<ToolCategory, string> = {
image: "图像创作", image: "图像创作",
video: "视频生成", video: "视频生成",
template: "模板与素材",
}; };
const categoryIcons: Record<ToolCategory, ReactNode> = { const categoryIcons: Record<ToolCategory, ReactNode> = {
image: <EditOutlined />, image: <EditOutlined />,
video: <VideoCameraOutlined />, video: <VideoCameraOutlined />,
template: <ShoppingOutlined />,
}; };
const filters: { key: FilterKey; label: string }[] = [ const filters: { key: FilterKey; label: string }[] = [
{ key: "all", label: "全部" }, { key: "all", label: "全部" },
{ key: "image", label: "图像" }, { key: "image", label: "图像" },
{ key: "video", label: "视频" }, { key: "video", label: "视频" },
{ key: "template", label: "模板" },
{ key: "upcoming", label: "即将上线" }, { key: "upcoming", label: "即将上线" },
]; ];
+61 -95
View File
@@ -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");
@@ -238,6 +235,9 @@ function ProfilePage({
const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分"; const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分";
const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null; const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null;
const displayedBio = profileBio.trim() || "这个人还没有填写个性签名"; const displayedBio = profileBio.trim() || "这个人还没有填写个性签名";
const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
const phoneLooksValid = /^1[3-9]\d{9}$/.test(phone.trim());
const passwordLooksReady = password.length >= (mode === "register" ? 6 : 1);
useEffect(() => { useEffect(() => {
setLocalAvatarUrl(session?.user.avatarUrl || readLocalProfileValue(userId, "avatar")); setLocalAvatarUrl(session?.user.avatarUrl || readLocalProfileValue(userId, "avatar"));
@@ -296,55 +296,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 +358,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") {
@@ -864,12 +815,30 @@ function ProfilePage({
<source src={AUTH_SHOWCASE_VIDEO_URL} type="video/mp4" /> <source src={AUTH_SHOWCASE_VIDEO_URL} type="video/mp4" />
</video> </video>
<div className="auth-page__video-overlay"> <div className="auth-page__video-overlay">
<h1 className="auth-page__brand">OmniAI</h1> <div className="auth-page__showcase-content">
<p className="auth-page__tagline"></p> <div className="auth-page__brand-row">
<div className="auth-page__features"> <h1 className="auth-page__brand">OmniAI</h1>
<span>AI </span> </div>
<span>AI </span> <p className="auth-page__tagline"></p>
<span>AI </span> <div className="auth-page__features">
<span>AI </span>
<span>AI </span>
<span>AI </span>
</div>
<div className="auth-page__showcase-stats" aria-label="平台能力">
<span>
<strong>Studio</strong>
</span>
<span>
<strong>Assets</strong>
</span>
<span>
<strong>Team</strong>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -881,6 +850,7 @@ function ProfilePage({
<span className="auth-page__logo"> <span className="auth-page__logo">
<img src={AUTH_LOGO_URL} alt="OmniAI" /> <img src={AUTH_LOGO_URL} alt="OmniAI" />
</span> </span>
<span className="auth-page__form-kicker">{mode === "login" ? "账户登录" : "新用户注册"}</span>
<h2 className="auth-page__title">{mode === "login" ? "欢迎回来" : "创建账号"}</h2> <h2 className="auth-page__title">{mode === "login" ? "欢迎回来" : "创建账号"}</h2>
<p className="auth-page__subtitle"> <p className="auth-page__subtitle">
{mode === "login" ? "登录后继续你的 AI 创作之旅" : "注册即可免费体验全部功能"} {mode === "login" ? "登录后继续你的 AI 创作之旅" : "注册即可免费体验全部功能"}
@@ -920,10 +890,8 @@ function ProfilePage({
<MailOutlined /> <MailOutlined />
</button> </button>
<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> <span className="auth-page__tab-label-short"></span>
<button type="button" className={authTab === "wechat" ? "is-active" : ""} onClick={() => { setAuthTab("wechat"); setFieldErrors({}); }}>
<WechatOutlined />
</button> </button>
</div> </div>
@@ -935,7 +903,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 /> /
@@ -979,6 +947,11 @@ function ProfilePage({
autoComplete={mode === "login" ? "current-password" : "new-password"} autoComplete={mode === "login" ? "current-password" : "new-password"}
/> />
{fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null} {fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null}
{mode === "register" && passwordLooksReady && !fieldErrors.password ? (
<span className="auth-page__field-hint">
<CheckCircleFilled />
</span>
) : null}
</label> </label>
{mode === "login" ? ( {mode === "login" ? (
<div className="auth-page__forgot"> <div className="auth-page__forgot">
@@ -1016,6 +989,11 @@ function ProfilePage({
autoComplete="email" autoComplete="email"
/> />
{fieldErrors.email ? <span className="auth-page__field-error">{fieldErrors.email}</span> : null} {fieldErrors.email ? <span className="auth-page__field-error">{fieldErrors.email}</span> : null}
{emailLooksValid && !fieldErrors.email ? (
<span className="auth-page__field-hint">
<CheckCircleFilled />
</span>
) : null}
</label> </label>
<label className={`auth-page__field${fieldErrors.password ? " auth-page__field--error" : ""}`}> <label className={`auth-page__field${fieldErrors.password ? " auth-page__field--error" : ""}`}>
<span> <span>
@@ -1030,6 +1008,11 @@ function ProfilePage({
autoComplete={mode === "login" ? "current-password" : "new-password"} autoComplete={mode === "login" ? "current-password" : "new-password"}
/> />
{fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null} {fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null}
{mode === "register" && passwordLooksReady && !fieldErrors.password ? (
<span className="auth-page__field-hint">
<CheckCircleFilled />
</span>
) : null}
</label> </label>
</> </>
) : null} ) : null}
@@ -1045,6 +1028,11 @@ function ProfilePage({
<input type="tel" value={phone} onChange={(event) => { setPhone(event.target.value); clearFieldError("phone"); }} onBlur={() => handleFieldBlur("phone", phone)} placeholder="输入手机号" autoComplete="tel" /> <input type="tel" value={phone} onChange={(event) => { setPhone(event.target.value); clearFieldError("phone"); }} onBlur={() => handleFieldBlur("phone", phone)} placeholder="输入手机号" autoComplete="tel" />
</div> </div>
{fieldErrors.phone ? <span className="auth-page__field-error">{fieldErrors.phone}</span> : null} {fieldErrors.phone ? <span className="auth-page__field-error">{fieldErrors.phone}</span> : null}
{phoneLooksValid && !fieldErrors.phone ? (
<span className="auth-page__field-hint">
<CheckCircleFilled />
</span>
) : null}
</label> </label>
<label className={`auth-page__field${fieldErrors.smsCode ? " auth-page__field--error" : ""}`}> <label className={`auth-page__field${fieldErrors.smsCode ? " auth-page__field--error" : ""}`}>
<span> <span>
@@ -1070,40 +1058,21 @@ function ProfilePage({
</span> </span>
<input type="password" value={password} onChange={(event) => { setPassword(event.target.value); clearFieldError("password"); }} onBlur={() => handleFieldBlur("password", password)} placeholder="至少 6 位" autoComplete="new-password" /> <input type="password" value={password} onChange={(event) => { setPassword(event.target.value); clearFieldError("password"); }} onBlur={() => handleFieldBlur("password", password)} placeholder="至少 6 位" autoComplete="new-password" />
{fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null} {fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null}
{passwordLooksReady && !fieldErrors.password ? (
<span className="auth-page__field-hint">
<CheckCircleFilled />
</span>
) : null}
</label> </label>
) : null} ) : null}
</> </>
) : 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 +1086,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 () => {
+51 -9
View File
@@ -236,6 +236,7 @@ function WorkbenchPage({
const keepaliveTasksRef = useRef<Record<string, WorkbenchKeepaliveTask>>(readStoredKeepaliveTasks()); const keepaliveTasksRef = useRef<Record<string, WorkbenchKeepaliveTask>>(readStoredKeepaliveTasks());
const taskAbortControllersRef = useRef<Map<string, AbortController>>(new Map()); const taskAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
const lastScrollTopRef = useRef(0); const lastScrollTopRef = useRef(0);
const scrollActionsHideTimerRef = useRef<number | null>(null);
const shouldFollowNewMessagesRef = useRef(true); const shouldFollowNewMessagesRef = useRef(true);
const pendingScrollToLatestRef = useRef(true); const pendingScrollToLatestRef = useRef(true);
const renderedMessageIdsRef = useRef<string[]>([]); const renderedMessageIdsRef = useRef<string[]>([]);
@@ -273,6 +274,8 @@ function WorkbenchPage({
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 }); const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
const [mentionActiveIndex, setMentionActiveIndex] = useState(0); const [mentionActiveIndex, setMentionActiveIndex] = useState(0);
const [composerHidden, setComposerHidden] = useState(false); const [composerHidden, setComposerHidden] = useState(false);
const [scrollActionsVisible, setScrollActionsVisible] = useState(false);
const [scrollActionDirection, setScrollActionDirection] = useState<"top" | "bottom" | null>(null);
const [workspaceStarted, setWorkspaceStarted] = useState(false); const [workspaceStarted, setWorkspaceStarted] = useState(false);
useEffect(() => { useEffect(() => {
@@ -280,6 +283,7 @@ function WorkbenchPage({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false; let cancelled = false;
assetClient assetClient
.list() .list()
@@ -440,6 +444,27 @@ function WorkbenchPage({
"--accent-glow": `0 0 24px rgba(${accentRgb}, 0.22)`, "--accent-glow": `0 0 24px rgba(${accentRgb}, 0.22)`,
} as CSSProperties; } as CSSProperties;
const revealScrollActionsTemporarily = useCallback((direction: "top" | "bottom") => {
setScrollActionDirection(direction);
setScrollActionsVisible(true);
if (scrollActionsHideTimerRef.current !== null) {
window.clearTimeout(scrollActionsHideTimerRef.current);
}
scrollActionsHideTimerRef.current = window.setTimeout(() => {
setScrollActionsVisible(false);
setScrollActionDirection(null);
scrollActionsHideTimerRef.current = null;
}, 950);
}, []);
useEffect(() => {
return () => {
if (scrollActionsHideTimerRef.current !== null) {
window.clearTimeout(scrollActionsHideTimerRef.current);
}
};
}, []);
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior = "smooth") => { const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
const scroll = () => { const scroll = () => {
const surface = messagesSurfaceRef.current; const surface = messagesSurfaceRef.current;
@@ -450,6 +475,7 @@ function WorkbenchPage({
setComposerHidden(false); setComposerHidden(false);
shouldFollowNewMessagesRef.current = true; shouldFollowNewMessagesRef.current = true;
revealScrollActionsTemporarily("bottom");
surface.scrollTo({ top: surface.scrollHeight, behavior }); surface.scrollTo({ top: surface.scrollHeight, behavior });
lastScrollTopRef.current = surface.scrollTop; lastScrollTopRef.current = surface.scrollTop;
}; };
@@ -458,7 +484,7 @@ function WorkbenchPage({
scroll(); scroll();
window.setTimeout(scroll, 80); window.setTimeout(scroll, 80);
}); });
}, []); }, [revealScrollActionsTemporarily]);
const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>( const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>(
() => [ () => [
@@ -948,6 +974,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 +1022,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;
} }
@@ -1362,6 +1398,9 @@ function WorkbenchPage({
const delta = top - lastScrollTopRef.current; const delta = top - lastScrollTopRef.current;
const atTop = top <= edgeThreshold; const atTop = top <= edgeThreshold;
const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold; const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold;
if (surface.scrollHeight > surface.clientHeight + edgeThreshold && Math.abs(delta) > 1) {
revealScrollActionsTemporarily(delta > 0 ? "bottom" : "top");
}
shouldFollowNewMessagesRef.current = atBottom; shouldFollowNewMessagesRef.current = atBottom;
if (atTop || atBottom) { if (atTop || atBottom) {
setComposerHidden(false); setComposerHidden(false);
@@ -1373,7 +1412,7 @@ function WorkbenchPage({
surface.addEventListener("scroll", handleScroll, { passive: true }); surface.addEventListener("scroll", handleScroll, { passive: true });
return () => surface.removeEventListener("scroll", handleScroll); return () => surface.removeEventListener("scroll", handleScroll);
}, [hasActivatedWorkspace]); }, [hasActivatedWorkspace, revealScrollActionsTemporarily]);
const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => { const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => {
const surface = messagesSurfaceRef.current; const surface = messagesSurfaceRef.current;
@@ -1381,8 +1420,9 @@ function WorkbenchPage({
const top = direction === "top" ? 0 : surface.scrollHeight; const top = direction === "top" ? 0 : surface.scrollHeight;
setComposerHidden(false); setComposerHidden(false);
revealScrollActionsTemporarily(direction);
surface.scrollTo({ top, behavior: "smooth" }); surface.scrollTo({ top, behavior: "smooth" });
}, []); }, [revealScrollActionsTemporarily]);
const closeToolbarMenus = () => setToolbarMenuId(null); const closeToolbarMenus = () => setToolbarMenuId(null);
const toggleToolbarMenu = (menuId: Exclude<ToolbarMenuId, null>) => { const toggleToolbarMenu = (menuId: Exclude<ToolbarMenuId, null>) => {
@@ -2104,7 +2144,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 +2266,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 +2469,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))}
@@ -3012,10 +3051,13 @@ function WorkbenchPage({
{renderComposerToolbar(false, isGenerating)} {renderComposerToolbar(false, isGenerating)}
</div> </div>
</section> </section>
<div className="wb-chat-scroll-actions" aria-label="聊天滚动"> <div
className={`wb-chat-scroll-actions${scrollActionsVisible ? " is-visible" : ""}${scrollActionDirection ? ` is-${scrollActionDirection}` : ""}`}
aria-label="聊天滚动"
>
<button <button
type="button" type="button"
className="wb-chat-scroll-actions__button" className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--top"
title="返回聊天顶部" title="返回聊天顶部"
aria-label="返回聊天顶部" aria-label="返回聊天顶部"
onClick={() => scrollMessagesSurface("top")} onClick={() => scrollMessagesSurface("top")}
@@ -3024,7 +3066,7 @@ function WorkbenchPage({
</button> </button>
<button <button
type="button" type="button"
className="wb-chat-scroll-actions__button" className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--bottom"
title="到达聊天底部" title="到达聊天底部"
aria-label="到达聊天底部" aria-label="到达聊天底部"
onClick={() => scrollMessagesSurface("bottom")} onClick={() => scrollMessagesSurface("bottom")}
+96
View File
@@ -0,0 +1,96 @@
/**
* Generic single-task keep-alive for tool pages.
* Persists task state to localStorage so in-progress tasks survive page switches.
*/
const KEEPALIVE_PREFIX = "omniai:tool-task:";
interface ToolTaskKeepalive {
taskId: string;
resultUrl: string;
resultPreview: string;
status: string;
progress: number;
sourceName: string;
sourceUrl: string;
savedAt: number;
}
export function saveToolTaskState(key: string, state: {
taskId: string;
resultUrl?: string;
resultPreview?: string;
status?: string;
progress?: number;
sourceName?: string;
sourceUrl?: string;
}): void {
if (!state.taskId) return;
try {
const entry: ToolTaskKeepalive = {
taskId: state.taskId,
resultUrl: state.resultUrl || "",
resultPreview: state.resultPreview || "",
status: state.status || "",
progress: state.progress || 0,
sourceName: state.sourceName || "",
sourceUrl: state.sourceUrl || "",
savedAt: Date.now(),
};
window.localStorage.setItem(KEEPALIVE_PREFIX + key, JSON.stringify(entry));
} catch { /* quota */ }
}
export function loadToolTaskState(key: string): ToolTaskKeepalive | null {
try {
const raw = window.localStorage.getItem(KEEPALIVE_PREFIX + key);
if (!raw) return null;
const parsed = JSON.parse(raw) as ToolTaskKeepalive;
if (Date.now() - (parsed.savedAt || 0) > 2 * 60 * 60 * 1000) {
clearToolTaskState(key);
return null;
}
if (!parsed.taskId) return null;
return parsed;
} catch { return null; }
}
export function clearToolTaskState(key: string): void {
try { window.localStorage.removeItem(KEEPALIVE_PREFIX + key); } catch { /* ignore */ }
}
const TASK_POLL_INTERVAL = 3000;
const TASK_POLL_TIMEOUT = 30 * 60 * 1000;
export async function pollTaskUntilDone(
taskId: string,
onProgress?: (progress: number) => void,
abortRef?: { current: boolean },
): Promise<string | null> {
const startTime = Date.now();
const { aiGenerationClient } = await import("../../api/aiGenerationClient");
while (true) {
if (abortRef?.current) return null;
if (Date.now() - startTime > TASK_POLL_TIMEOUT) return null;
try {
const task = await aiGenerationClient.getTaskStatus(taskId);
if (!task) return null;
const progress = Math.min(99, task.progress || 0);
onProgress?.(progress);
if (task.status === "completed") {
return task.resultUrl || null;
}
if (task.status === "failed" || task.status === "cancelled") {
return null;
}
} catch {
// retry on next poll
}
await new Promise((r) => setTimeout(r, TASK_POLL_INTERVAL));
}
}
+4 -3
View File
@@ -1,10 +1,11 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { WebProjectSummary, WebCanvasWorkflow } from '../types'; import type { WebProjectSummary, WebCanvasWorkflow } from '../types';
import { createBlankWorkflow } from '../data/workflows';
interface ProjectState { interface ProjectState {
projects: WebProjectSummary[]; projects: WebProjectSummary[];
projectsLoaded: boolean; projectsLoaded: boolean;
canvasWorkflow: WebCanvasWorkflow | null; canvasWorkflow: WebCanvasWorkflow;
currentCanvasProjectId: string | null; currentCanvasProjectId: string | null;
pendingDeleteProject: WebProjectSummary | null; pendingDeleteProject: WebProjectSummary | null;
deleteProjectSubmitting: boolean; deleteProjectSubmitting: boolean;
@@ -13,7 +14,7 @@ interface ProjectState {
interface ProjectActions { interface ProjectActions {
setProjects: (projects: WebProjectSummary[]) => void; setProjects: (projects: WebProjectSummary[]) => void;
setProjectsLoaded: (loaded: boolean) => void; setProjectsLoaded: (loaded: boolean) => void;
setCanvasWorkflow: (workflow: WebCanvasWorkflow | null) => void; setCanvasWorkflow: (workflow: WebCanvasWorkflow) => void;
setCurrentCanvasProjectId: (id: string | null) => void; setCurrentCanvasProjectId: (id: string | null) => void;
openDeleteProject: (project: WebProjectSummary) => void; openDeleteProject: (project: WebProjectSummary) => void;
closeDeleteProject: () => void; closeDeleteProject: () => void;
@@ -24,7 +25,7 @@ interface ProjectActions {
const initialState: ProjectState = { const initialState: ProjectState = {
projects: [], projects: [],
projectsLoaded: false, projectsLoaded: false,
canvasWorkflow: null, canvasWorkflow: createBlankWorkflow(),
currentCanvasProjectId: null, currentCanvasProjectId: null,
pendingDeleteProject: null, pendingDeleteProject: null,
deleteProjectSubmitting: false, deleteProjectSubmitting: false,
+14 -28
View File
@@ -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 {
@@ -67,4 +53,4 @@
opacity: 0; opacity: 0;
transform: translateX(16px); transform: translateX(16px);
} }
} }
+1 -12
View File
@@ -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;
+13
View File
@@ -592,6 +592,19 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.studio-canvas-loading-spinner {
width: 36px;
height: 36px;
border: 3px solid rgba(var(--accent-rgb), 0.18);
border-top-color: var(--accent);
border-radius: 50%;
animation: canvas-spin 0.8s linear infinite;
}
@keyframes canvas-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 640px) { @media (max-width: 640px) {
.studio-canvas-project-bar { .studio-canvas-project-bar {
right: 10px; right: 10px;
+201
View File
@@ -122,6 +122,16 @@
white-space: nowrap; white-space: nowrap;
} }
.ecom-video-flowbar__stage-label {
display: inline-flex;
align-items: center;
gap: 6px;
color: #53e5ff;
font-size: 12px;
font-weight: 800;
animation: ecom-video-node-breathe 1.6s ease-in-out infinite;
}
.ecom-video-flow-action { .ecom-video-flow-action {
display: inline-grid; display: inline-grid;
width: 38px; width: 38px;
@@ -484,6 +494,197 @@
} }
} }
/* ── Empty state ─────────────────────────────── */
.ecom-video-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #697486;
font-size: 13px;
}
/* ── Flow map vertical stacking ────────────────── */
.ecom-video-flow-map {
flex-wrap: wrap;
justify-content: center;
padding: 20px 0 40px;
}
/* ── Text nodes (plan steps) ──────────────────── */
.ecom-video-flow-node--text {
display: grid;
place-items: center;
gap: 6px;
width: clamp(64px, 7vw, 90px);
min-height: 74px;
padding: 12px 8px;
border-color: #2a3d30;
background: #131d1a;
text-align: center;
}
.ecom-video-flow-node--text.is-completed {
border-color: #1c4d3a;
background: #162820;
}
.ecom-video-flow-node--text.is-pulsing {
border-color: #53e5ff;
animation: ecom-video-node-breathe 1.2s ease-in-out infinite;
}
.ecom-video-flow-node__text-icon {
display: grid;
width: 24px;
height: 24px;
place-items: center;
border-radius: 999px;
background: #1c4d3a;
color: #34d399;
font-size: 12px;
font-weight: 900;
}
.ecom-video-flow-node--text.is-pulsing .ecom-video-flow-node__text-icon {
background: #1a4d4d;
color: #53e5ff;
}
/* ── Node labels ──────────────────────────────── */
.ecom-video-flow-node__label {
display: block;
max-width: 100%;
overflow: hidden;
color: #9fadb8;
font-size: 11px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.ecom-video-flow-node--source .ecom-video-flow-node__label,
.ecom-video-flow-node--text .ecom-video-flow-node__label {
margin-top: 2px;
}
/* ── Image nodes (storyboard images) ───────────── */
.ecom-video-flow-node--image {
width: clamp(88px, 9vw, 128px);
aspect-ratio: 9 / 13;
}
.ecom-video-flow-node--image .ecom-video-flow-node__media {
position: relative;
width: 100%;
height: 100%;
}
.ecom-video-flow-node--image .ecom-video-flow-node__label {
position: absolute;
left: 0;
right: 0;
bottom: 6px;
text-align: center;
}
.ecom-video-flow-node--image .ecom-video-flow-node__progress {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: auto;
height: auto;
background: none;
color: #53e5ff;
font-size: 15px;
font-weight: 1000;
}
.ecom-video-flow-node--image .ecom-video-flow-node__placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #18231f;
color: #7eeecf;
font-size: 24px;
}
/* ── Video nodes ──────────────────────────────── */
.ecom-video-flow-node--video {
width: clamp(88px, 9vw, 128px);
aspect-ratio: 9 / 16;
}
.ecom-video-flow-node--video .ecom-video-flow-node__label {
position: absolute;
left: 0;
right: 0;
bottom: 6px;
text-align: center;
}
.ecom-video-flow-node--video .ecom-video-flow-node__progress {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: auto;
height: auto;
background: none;
color: #53e5ff;
font-size: 15px;
font-weight: 1000;
}
.ecom-video-flow-node--video .ecom-video-flow-node__placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #18231f;
color: #7eeecf;
font-size: 24px;
}
.ecom-video-flow-node--video .ecom-video-flow-node__media video {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ── Error label ──────────────────────────────── */
.ecom-video-flow-node__error {
position: absolute;
left: 0;
right: 0;
bottom: 24px;
overflow: hidden;
color: #ff9f9f;
font-size: 10px;
font-weight: 800;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Scene strip overflow ─────────────────────── */
.ecom-video-scene-strip--text {
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: #414958 transparent;
}
.ecom-video-scene-strip--text::-webkit-scrollbar {
height: 3px;
}
.ecom-video-scene-strip--text::-webkit-scrollbar-thumb {
background: #414958;
border-radius: 999px;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.ecom-video-flowbar { .ecom-video-flowbar {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
+42
View File
@@ -520,6 +520,48 @@
font-size: 12px; font-size: 12px;
} }
.script-eval-v5-retry-btn {
margin-left: auto;
min-width: 56px;
padding: 4px 14px;
border: 1px solid rgba(255, 107, 53, 0.35);
border-radius: 6px;
background: transparent;
color: #ff6b35;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: background 140ms ease;
}
.script-eval-v5-retry-btn:hover:not(:disabled) {
background: rgba(255, 107, 53, 0.15);
}
.script-eval-v5-retry-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.script-eval-v5-loading {
display: grid;
place-items: center;
gap: 12px;
padding: 32px 20px;
text-align: center;
}
.script-eval-v5-loading strong {
color: var(--fg-body);
font-size: 15px;
font-weight: 800;
}
.script-eval-v5-loading p {
color: var(--fg-muted);
font-size: 12px;
}
/* Hero */ /* Hero */
.script-eval-v5-hero { .script-eval-v5-hero {
margin-bottom: 18px; margin-bottom: 18px;
+1 -1
View File
@@ -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);
} }
+263
View File
@@ -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);
}
File diff suppressed because it is too large Load Diff
+50
View File
@@ -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 });
}
}
+5
View File
@@ -18,6 +18,11 @@ export default defineConfig(({ mode }) => {
target: env.VITE_DEV_PROXY || "http://47.110.225.76:3600", target: env.VITE_DEV_PROXY || "http://47.110.225.76:3600",
changeOrigin: true, changeOrigin: true,
}, },
"/dashscope-api": {
target: "https://dashscope.aliyuncs.com",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/dashscope-api/, "/compatible-mode/v1"),
},
}, },
}, },
preview: { preview: {