Compare commits

...

19 Commits

Author SHA1 Message Date
ludan 9282e6c0d8 feat: 电商页面视觉精修,全局页面滚动按钮交互简化
AppShell.tsx:
- 全局页面滚动操作按钮(floating-page-scroll-actions)暂时关闭
- 后续将通过页面级滚动交互替代全局滚动按钮

EcommercePage.tsx(电商生成页功能增强):
- 预览区header下方新增生成配置摘要条(clone-ai-preview-summary)
- 展示当前选择的输出类型、平台、市场、语言、比例参数
- 方便用户快速确认当前生成配置无误

ecommerce.css(电商页面视觉精修):
- 新增CSS自定义属性体系(ecm-*),统一电商页面颜色/间距/阴影
- 页面背景增加accent径向光晕和半透明渐变叠加
- 设置面板/预览区域卡片增加毛玻璃质感和微阴影
- 文本颜色层级化(ecm-text/ecm-muted/ecm-soft)
- 保持现有布局结构不变,仅提升视觉效果

dark-green.css(暗绿主题电商按钮状态精修):
- 电商页面各工具模块按钮active态增加相对定位和overflow控制
- clone/set/detail/wear工具中is-active按钮状态一致性增强
- 限定在[data-view="ecommerce"]作用域内,避免全局污染
2026-06-03 16:42:57 +08:00
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
ludan da06187a9c Merge branch 'master' of http://118.145.251.184:3000/OmniAI/omniai-web into feat/profile-ui-polish 2026-06-03 09:55:15 +08:00
ludan 87d81d2c86 feat: 个人中心界面UI优化,提升商业产品精致度
ProfilePage组件优化:
- 新增个性签名编辑功能(内联编辑/确认/取消交互)
- 新增 icon 导入(EditOutlined, CheckOutlined, CloseOutlined)
- 新增 formatProfileDate 工具函数,统一日期格式化(中文友好)
- 新增 formatTaskType 函数,任务类型中文化显示
- 新增 formatTaskStatus 函数,任务状态中文化显示
- 新增 formatAssetStatus 函数,资产状态中文化显示
- 优化空状态展示,增加图标占位

dark-green主题层样式增强:
- 个人中心背景增加微光渐变(accent光晕+半透明遮罩)
- Banner区域高度优化为214px,增加底部渐变分割线
- Banner叠加层增加径向光晕效果
- Banner编辑按钮增加毛玻璃质感(backdrop-filter)
- 侧边栏/主体/选项卡/列表卡片等多处细节增强
- 按钮、标签、统计数字等组件加入微交互
- 保持暗绿主题配色体系不变,仅提升精致度
2026-06-03 09:53:05 +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
ludan 178c2ec695 Merge branch 'master' of http://118.145.251.184:3000/OmniAI/omniai-web into feat/home-entry-buttons-refined 2026-06-02 21:30:06 +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
ludan 324ebf5ce5 feat: 优化首页入口按钮质感,提升商业产品精致度
- 按钮背景改为微渐变+毛玻璃效果(backdrop-filter)
- 边框改为半透明白色,圆角从8px升级到12px
- 增加内高光+外层深度阴影提升层次感
- 字间距、字重大小幅调整,更精致克制
- hover态增加accent光晕+图标变绿+放大效果
- 主按钮增加渐变绿底+内高光+绿色辉光阴影
- 增加按压态scale(0.97)反馈
- 主按钮图标hover放大1.12倍
2026-06-02 19:09:00 +08:00
ludan 9ababfda46 fix: 修复剧本评分文本框随内容无限下拉导致按钮被挤出视口
- textarea 增加 max-height 限制高度不随内容增长
- textarea 增加 overflow-y: auto 启用内部滚动
- text-shell 同步增加 max-height 约束
2026-06-02 18:18:00 +08:00
44 changed files with 5343 additions and 587 deletions
+30 -62
View File
@@ -16,6 +16,8 @@ import {
} from "@ant-design/icons";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from "react";
import ErrorBoundary from "./components/ErrorBoundary";
import { reportError } from "./utils/errorReporting";
import { initNotificationPermission } from "./utils/generationNotifier";
import PageTransition from "./components/PageTransition";
import ToastContainer from "./components/toast/ToastContainer";
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 DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
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 ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
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 SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage"));
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 SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage"));
@@ -101,7 +100,6 @@ const VIEW_KEYS = new Set<WebViewKey>([
"assets",
"ecommerceHub",
"ecommerce",
"ecommerceTemplates",
"scriptTokens",
"tokenUsage",
"settings",
@@ -113,14 +111,13 @@ const VIEW_KEYS = new Set<WebViewKey>([
"avatarConsole",
"characterMix",
"more",
"sizeTemplate",
"communityReview",
"communityCaseAdd",
"report",
"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 {
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
useEffect(() => {
if (!canvasWorkflow) {
@@ -312,12 +328,6 @@ function App() {
hint: "AI创作与海报生成",
icon: <ShoppingOutlined />,
},
{
key: "sizeTemplate",
label: "示例模板",
hint: "平台比例与导出尺寸",
icon: <LayoutOutlined />,
},
{ key: "canvas", label: "画布", hint: "进入自由画布编排", icon: <BranchesOutlined /> },
{ key: "community", label: "社区", hint: "案例分享与导入", icon: <GlobalOutlined /> },
{ key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: <BarChartOutlined /> },
@@ -362,7 +372,7 @@ function App() {
}, [clearSessionState, setProjects, setProjectsLoaded, setUsage, clearTasks, setRuntimeNotifications, setServerNotifications, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded, handleSetView]);
const showSessionReplacedModal = useCallback((message?: string) => {
clearAuthenticatedState();
clearAuthenticatedState({ resetView: true });
showSessionReplaced(message);
}, [clearAuthenticatedState, showSessionReplaced]);
@@ -380,11 +390,6 @@ function App() {
};
}, [showSessionReplacedModal]);
const handleOpenEcommerceTemplate = useCallback((template: TemplateCase) => {
setPendingEcommerceTemplate(template);
handleSetView("ecommerce");
}, [setPendingEcommerceTemplate, handleSetView]);
const hydrateAccountData = useCallback(async (nextSession: WebUserSession | null) => {
setProjectsLoaded(false);
if (!nextSession) {
@@ -681,11 +686,14 @@ function App() {
}
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,
canvasWorkflow?.nodes.length,
canvasWorkflow?.source,
canvasWorkflow.nodes.length,
canvasWorkflow.source,
currentCanvasProjectId,
handleOpenProject,
projects,
@@ -987,25 +995,6 @@ function App() {
}, [activeView, session]); // eslint-disable-line react-hooks/exhaustive-deps
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) {
case "login":
return (
@@ -1049,7 +1038,7 @@ function App() {
case "canvas":
return (
<CanvasPage
workflow={canvasWorkflow!}
workflow={canvasWorkflow}
projectId={currentCanvasProjectId}
projects={projects}
projectsLoaded={projectsLoaded}
@@ -1081,18 +1070,6 @@ function App() {
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":
return (
<DigitalHumanPage
@@ -1118,15 +1095,6 @@ function App() {
);
case "more":
return <MorePage onSelectView={handleSetView} onOpenImageTool={handleOpenImageWorkbenchTool} />;
case "sizeTemplate":
return (
<SizeTemplatePage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onSelectView={handleSetView}
/>
);
case "scriptTokens":
return <ScriptTokensPage />;
case "tokenUsage":
+5
View File
@@ -928,4 +928,9 @@ export const keyServerClient = {
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 {
totalScore: number;
grade: string;
@@ -10,6 +8,10 @@ export interface ScriptEvalResult {
suggestions: string[];
}
const DASHSCOPE_API_KEY = import.meta.env.VITE_DASHSCOPE_API_KEY || "";
const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions";
const MODEL = "qwen3.7-max";
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
【剧本类型识别】
@@ -67,31 +69,35 @@ function extractJson(text: string): unknown {
}
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
console.log("[API] 发送评测请求,剧本长度:", script.slice(0, 8000).length, "字符");
const res = await fetch(buildApiUrl("ai/chat"), {
if (!DASHSCOPE_API_KEY) {
throw new Error("DashScope API key 未配置,请在 .env.local 中设置 VITE_DASHSCOPE_API_KEY");
}
const res = await fetch(DASHSCOPE_ENDPOINT, {
method: "POST",
headers: buildAuthHeaders(),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${DASHSCOPE_API_KEY}`,
},
body: JSON.stringify({
model: "qwen3.7-max",
model: MODEL,
messages: [
{ role: "system", content: EVAL_SYSTEM_PROMPT },
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
],
stream: false,
temperature: 0.3,
max_tokens: 4096,
}),
signal,
});
console.log("[API] 响应状态:", res.status, res.statusText);
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();
console.log("[API] 原始响应体:", payload);
const content: string = payload?.choices?.[0]?.message?.content
?? payload?.result?.content
?? payload?.content
@@ -100,11 +106,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
if (!content) throw new Error("模型未返回有效内容");
console.log("[API] 模型返回内容 (前500字符):", content.slice(0, 500));
const parsed = extractJson(content) as Record<string, unknown>;
console.log("[API] 解析后的JSON:", parsed);
const dimensionScores: Record<string, number> = {};
const rawScores = parsed.dimensionScores as Record<string, number> | undefined;
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);
console.log("[API] 计算后总分:", totalScore, "等级:", grade);
return {
totalScore,
+5
View File
@@ -234,6 +234,10 @@ function notifySessionExpired(status: number, response: Response, payload: unkno
if (/\/auth\//i.test(response.url)) return;
// SESSION_REPLACED has its own dedicated handling/modal.
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();
if (now - lastSessionExpiredEventAt < 1500) return;
@@ -250,6 +254,7 @@ function notifySessionReplaced(status: number, payload: unknown, fallbackMessage
const message = getPayloadMessage(payload) || fallbackMessage || "您已在别处登录";
const isSessionReplaced = code === "SESSION_REPLACED" || message.includes("您已在别处登录");
if (!isSessionReplaced || typeof window === "undefined") return;
if (!readStoredSession()) return;
const now = Date.now();
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;
+44 -1
View File
@@ -3,8 +3,12 @@ import {
ArrowUpOutlined,
CheckCircleOutlined,
FlagOutlined,
InfoCircleOutlined,
LoginOutlined,
LogoutOutlined,
PhoneOutlined,
SafetyOutlined,
EnvironmentOutlined,
PlusCircleOutlined,
UserOutlined,
WalletOutlined,
@@ -17,6 +21,7 @@ import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebV
import NotificationCenter from "./NotificationCenter";
import { RechargeModal } from "./RechargeModal/RechargeModal";
import { AnimatedPanel } from "./AnimatedPanel";
import AdminMonitor from "./AdminMonitor";
interface AppShellProps {
activeView: WebViewKey;
@@ -61,6 +66,8 @@ function AppShell({
const submenuHideTimerRef = useRef<number | null>(null);
const [profileOpen, setProfileOpen] = 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 prevActiveViewRef = useRef<WebViewKey>(activeView);
const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null);
@@ -81,7 +88,7 @@ function AppShell({
"avatarConsole",
"characterMix",
] as WebViewKey[];
const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView);
const showPageScrollActions = false;
const visibleNavItems = useMemo(
() => {
@@ -140,6 +147,17 @@ function AppShell({
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [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(() => {
if (!session) {
setProfileOpen(false);
@@ -307,6 +325,30 @@ function AppShell({
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
className="member-button"
type="button"
@@ -429,6 +471,7 @@ function AppShell({
<div className="web-shell__page">{children}</div>
</main>
</div>
{session?.user.role === "admin" ? <AdminMonitor /> : null}
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
</div>
);
+18 -6
View File
@@ -40,25 +40,37 @@ function getNavIndex(key: string): number {
export default function PageTransition({ viewKey, children }: PageTransitionProps) {
const [displayedChildren, setDisplayedChildren] = useState(children);
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 timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (viewKey === prevKeyRef.current) {
setDisplayedChildren(children);
// Cancel any active exit animation — children updated but viewKey stable.
setPhase("idle");
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 nextIndex = getNavIndex(viewKey);
if (prevIndex < nextIndex) {
setDirection("forward");
setExitDirection("forward");
} else if (prevIndex > nextIndex) {
setDirection("backward");
setExitDirection("backward");
} else {
setDirection("neutral");
setExitDirection("neutral");
}
prevKeyRef.current = viewKey;
setPhase("exit");
timerRef.current = setTimeout(() => {
setDisplayedChildren(children);
@@ -67,10 +79,10 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp
return () => clearTimeout(timerRef.current);
}, [viewKey, children]);
const dirClass = direction === "forward" ? " is-forward" : direction === "backward" ? " is-backward" : "";
const dirClass = exitDirection === "forward" ? " is-forward" : exitDirection === "backward" ? " is-backward" : "";
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}
</div>
);
+46 -30
View File
@@ -48,6 +48,7 @@ import {
} from "./canvasCommunityPublish";
import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence";
import { normalizeCanvasWorkflowSchema } from "./canvasWorkflowSchema";
import { createBlankWorkflow } from "../../data/workflows";
import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory";
import { useCanvasKeyboard } from "./useCanvasKeyboard";
import { useCanvasNodeDrag } from "./useCanvasNodeDrag";
@@ -310,7 +311,7 @@ function getCameraMotionPrompt(value: string): string {
}
function CanvasPage({
workflow,
workflow: rawWorkflow,
projectId,
projects = [],
projectsLoaded = true,
@@ -323,6 +324,7 @@ function CanvasPage({
onSaveWorkflow,
onCreateTask,
}: CanvasPageProps) {
const workflow = rawWorkflow || createBlankWorkflow();
const [contextMenu, setContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
const [nodeMenu, setNodeMenu] = useState<CanvasFloatingMenuPosition | null>(null);
const [textNodeMenu, setTextNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
@@ -404,6 +406,7 @@ function CanvasPage({
textGenerationState, imageGenerationState, videoGenerationState,
generationToast, setGenerationToast,
imageGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
restoreKeepaliveTasks, resetGenerationState,
} = useCanvasGeneration({ setImageNodes, setVideoNodes });
@@ -524,6 +527,7 @@ function CanvasPage({
const canvasAssets = serverAssets.filter((asset) => asset.imageUrl);
const shouldShowEmptyProjectState =
projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0;
const isWaitingForProjects = isAuthenticated && !projectsLoaded;
const [projectSaveState, setProjectSaveState] = useState<CanvasProjectSaveState>({
status: "idle",
message: "",
@@ -571,10 +575,13 @@ function CanvasPage({
imageNodeIdRef.current = nextImageNodes.length + 1;
videoNodeIdRef.current = nextVideoNodes.length + 1;
// Reset keepalive flag so tasks can be restored for this project
canvasGenKeepaliveRestoredRef.current = false;
if (projectId && isAuthenticated) {
restoreKeepaliveTasks(projectId, nextImageNodes, nextVideoNodes);
}
}, [workflow.id, workflow.nodes, projectId, isAuthenticated]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflow.id, workflow.nodes, projectId]);
useEffect(() => {
if (!isAuthenticated) {
@@ -3524,16 +3531,16 @@ function CanvasPage({
return (
<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
className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${shouldShowEmptyProjectState ? " is-empty-projects" : ""}`}
className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${(shouldShowEmptyProjectState || isWaitingForProjects) ? " is-empty-projects" : ""}`}
ref={canvasRef}
onAuxClick={shouldShowEmptyProjectState ? undefined : handleCanvasAuxClick}
onContextMenu={shouldShowEmptyProjectState ? (event) => event.preventDefault() : handleCanvasContextMenu}
onMouseDownCapture={shouldShowEmptyProjectState ? undefined : handleCanvasMouseDown}
onDoubleClick={shouldShowEmptyProjectState ? undefined : handleCanvasDoubleClick}
onMouseMove={shouldShowEmptyProjectState ? undefined : handleCanvasMouseMove}
onWheel={shouldShowEmptyProjectState ? undefined : handleCanvasWheel}
onAuxClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasAuxClick}
onContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? (event) => event.preventDefault() : handleCanvasContextMenu}
onMouseDownCapture={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseDown}
onDoubleClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDoubleClick}
onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove}
onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel}
style={{
"--canvas-bg-size": `${24 * canvasViewport.zoom}px`,
"--canvas-bg-x": `${canvasViewport.x}px`,
@@ -3555,7 +3562,7 @@ function CanvasPage({
className="studio-canvas-hidden-input"
onChange={(event) => handleImageFileSelected(event, pendingImagePosition)}
/>
{!shouldShowEmptyProjectState ? (
{(!shouldShowEmptyProjectState || isWaitingForProjects) ? (
<div className="studio-canvas-project-bar" onMouseDown={(event) => event.stopPropagation()}>
<div className="studio-canvas-project-bar__identity">
{projectNameEditing ? (
@@ -3650,7 +3657,7 @@ function CanvasPage({
</button>
</div>
) : null}
{!shouldShowEmptyProjectState && recentProjectsOpen ? (
{(!shouldShowEmptyProjectState || isWaitingForProjects) && recentProjectsOpen ? (
<aside
id="studio-canvas-recent-drawer"
className="studio-canvas-recent-drawer"
@@ -3718,8 +3725,8 @@ function CanvasPage({
zoomOnPinch={false}
zoomOnScroll={false}
proOptions={{ hideAttribution: true }}
onPaneClick={shouldShowEmptyProjectState ? undefined : handlePaneClick}
onPaneContextMenu={shouldShowEmptyProjectState ? undefined : handlePaneContextMenu}
onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick}
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
>
<Background gap={24} color="transparent" className="studio-canvas__background" />
</ReactFlow>
@@ -3731,7 +3738,7 @@ function CanvasPage({
<button type="button" title="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" onClick={fitCanvasView}></button>
</div>
{shouldShowEmptyProjectState ? (
{(shouldShowEmptyProjectState || isWaitingForProjects) ? (
<div
className="studio-canvas-empty-projects"
role="status"
@@ -3742,21 +3749,30 @@ function CanvasPage({
event.stopPropagation();
}}
>
<strong></strong>
<button
type="button"
className="studio-canvas-empty-projects__button"
onClick={(event) => {
event.stopPropagation();
if (onStartCreate) {
onStartCreate();
return;
}
onOpenLogin();
}}
>
</button>
{isWaitingForProjects ? (
<>
<div className="studio-canvas-loading-spinner" />
<strong></strong>
</>
) : (
<>
<strong></strong>
<button
type="button"
className="studio-canvas-empty-projects__button"
onClick={(event) => {
event.stopPropagation();
if (onStartCreate) {
onStartCreate();
return;
}
onOpenLogin();
}}
>
</button>
</>
)}
</div>
) : null}
{selectionRect ? (
@@ -22,6 +22,7 @@ import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
import { CheckCircleOutlined, InfoCircleOutlined } from "@ant-design/icons";
@@ -85,12 +86,32 @@ function CharacterMixPage({
};
}, [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(() => {
return () => {
abortRef.current = true;
if (taskIdRef.current) {
aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {});
}
};
}, []);
@@ -100,6 +121,7 @@ function CharacterMixPage({
aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {});
taskIdRef.current = null;
}
clearToolTaskState("charactermix");
}, []);
const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => {
@@ -153,10 +175,16 @@ function CharacterMixPage({
muted: !watermark,
});
taskIdRef.current = taskId;
saveToolTaskState("charactermix", { taskId, status: "running", progress: 0 });
const result = await pollTaskUntilDone(taskId);
setResultUrl(result);
setNotice(result ? "角色迁移完成" : "已取消");
if (result) {
saveToolTaskState("charactermix", { taskId, resultUrl: result, status: "完成", progress: 100 });
} else {
clearToolTaskState("charactermix");
}
} catch (error) {
setNotice(error instanceof Error ? error.message : "任务创建失败,请稍后重试。");
} finally {
+26 -37
View File
@@ -7,8 +7,10 @@ import {
PictureOutlined,
PlusOutlined,
RightOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useDebounce } from "../../hooks/useDebounce";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
import WorkspacePageShell from "../../components/WorkspacePageShell";
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) {
const [serverCases, setServerCases] = useState<ServerCommunityCase[]>([]);
const [serverNotice, setServerNotice] = useState<string | null>(null);
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
const [favoriteIds, setFavoriteIds] = useState<string[]>([]);
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 nextActive = !(item.isFavorited || favoriteIds.includes(cardId));
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 (
<WorkspacePageShell title="社区" fullWidth className="community-page page-motion">
@@ -421,6 +401,15 @@ function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject
<div>
<h2></h2>
</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}
</div>
{liveCases.length ? (
@@ -507,8 +496,8 @@ function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject
) : (
<EmptyState
icon={<ImportOutlined style={{ fontSize: 48 }} />}
title="社区暂无模板"
description="管理员审核通过后,画布社区案例会显示在这里。"
title={debouncedQuery ? "无匹配结果" : "社区暂无模板"}
description={debouncedQuery ? "尝试其他关键词,或清除搜索查看全部案例" : "管理员审核通过后,画布社区案例会显示在这里。"}
/>
)}
</section>
@@ -21,6 +21,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { getServerBaseUrl } from "../../api/serverConnection";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import StudioToolLayout from "../../components/StudioToolLayout";
@@ -93,6 +94,7 @@ function DigitalHumanPage({
const cancelRef = useRef(false);
const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
useEffect(() => {
return () => {
@@ -106,13 +108,24 @@ function DigitalHumanPage({
};
}, [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(() => {
return () => {
pollRunRef.current += 1;
cancelRef.current = true;
if (activeTaskIdRef.current) {
aiGenerationClient.cancelTask(activeTaskIdRef.current).catch(() => {});
}
};
}, []);
@@ -186,6 +199,7 @@ function DigitalHumanPage({
const runId = ++pollRunRef.current;
setActiveTaskId(taskId);
setTaskProgress(0);
saveToolTaskState("digital-human", { taskId, status: "running", progress: 0 });
pushDebugEntry("开始订阅", `开始接收任务 ${taskId} 的生成结果。`);
const resultUrl = await waitForTask(taskId, {
@@ -204,6 +218,7 @@ function DigitalHumanPage({
if (e.status === "completed" && e.resultUrl) {
setResultVideoUrl(e.resultUrl);
setNotice(`任务完成,结果已接收:${taskId}`);
clearToolTaskState("digital-human");
pushDebugEntry("结果已接收", summarizeUrl(e.resultUrl), "success", { taskId, resultUrl: e.resultUrl });
}
},
+280 -38
View File
@@ -20,6 +20,8 @@ const ecommerceSlide5 = `${OSS_MUBAN}/slide-5.png`;
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { ServerRequestError } from "../../api/serverConnection";
import { waitForTask } from "../../api/taskSubscription";
import {
analyzeProductImages,
buildProductSummary,
@@ -771,6 +773,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [productSetRequirement, setProductSetRequirement] = useState("");
const [productSetOutput, setProductSetOutput] = useState<ProductSetOutputKey>("video");
const [productSetStatus, setProductSetStatus] = useState<ProductSetStatus>("idle");
const [productSetResultImages, setProductSetResultImages] = useState<string[]>([]);
const [isSetUploadDragging, setIsSetUploadDragging] = useState(false);
const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<{ src: string; label: string } | null>(null);
const [showHostingModal, setShowHostingModal] = useState(false);
@@ -819,6 +822,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [ratio, setRatio] = useState(getPlatformDefaultRatio(platformOptions[0]));
const [status, setStatus] = useState<ProductCloneStatus>("idle");
const [results, setResults] = useState<CloneResult[]>([]);
const imageAbortRef = useRef({ current: false });
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
const [modelSource, setModelSource] = useState<TryOnModelSource>("ai");
const [modelGender, setModelGender] = useState(tryOnModelOptions.gender[0]);
@@ -840,6 +844,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [detailRequirement, setDetailRequirement] = useState("");
const [selectedDetailModules, setSelectedDetailModules] = useState<string[]>(defaultDetailModuleIds);
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null);
const productSetRatioOptions = getPlatformRatioOptions(productSetPlatform, productSetOutput);
const hotUploadedRatioOption = cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null;
const baseCloneRatioOptions = getPlatformRatioOptions(platform, cloneOutput);
@@ -1339,6 +1344,180 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
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 handleAdVideoPlan = async () => {
@@ -1540,32 +1719,46 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const handleGenerate = () => {
if (!canGenerate) return;
setStatus("generating");
window.setTimeout(() => {
const stamp = Date.now();
setResults(
sampleResults.map((src, index) => ({
id: `clone-result-${stamp}-${index}`,
src,
label: index === 0 ? "高度复刻" : index === 1 ? "参考风格" : "平台适配",
})),
imageAbortRef.current = { current: false };
if (cloneOutput === "set") {
void generateSetImages(
productImages, cloneSetCounts, requirement,
platform, ratio, language, market,
(s) => setStatus(s as ProductCloneStatus),
(urls) => setProductSetResultImages(urls),
);
setStatus("done");
}, 900);
} else {
void generateEcommerceImage(
cloneOutput, productImages, requirement,
platform, ratio, language, market,
(s) => setStatus(s as ProductCloneStatus), setResults,
);
}
};
const handleGenerateModel = () => {
imageAbortRef.current = { current: false };
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 = () => {
if (!canGenerateTryOn) return;
setTryOnStatus("generating");
window.setTimeout(() => {
setTryOnResultImages([tryOnAssets.tryA, tryOnAssets.tryB, tryOnAssets.hatResultA]);
setTryOnStatus("done");
}, 900);
imageAbortRef.current = { current: false };
void generateEcommerceImage(
"model", garmentImages, requirement,
platform, ratio, language, market,
(s) => setTryOnStatus(s as TryOnStatus),
(res) => setTryOnResultImages(res.map((r) => r.src).filter(Boolean)),
);
};
const toggleScene = (scene: string) => {
@@ -1582,8 +1775,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const handleSetGenerate = () => {
if (!canGenerateSet) return;
setProductSetStatus("generating");
window.setTimeout(() => setProductSetStatus("done"), 900);
imageAbortRef.current = { current: false };
void generateSetImages(
setImages, cloneSetCounts, productSetRequirement,
productSetPlatform, productSetRatio, productSetLanguage, productSetMarket,
(s) => setProductSetStatus(s as ProductSetStatus),
(urls) => setProductSetResultImages(urls),
);
};
const openProductSetPreview = (card: { src: string; label: string }) => {
@@ -1598,8 +1796,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const handleDetailGenerate = () => {
if (!canGenerateDetail) return;
setDetailStatus("generating");
window.setTimeout(() => setDetailStatus("done"), 900);
imageAbortRef.current = { current: false };
void generateEcommerceImage(
"detail", detailProductImages, detailRequirement,
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
(s) => setDetailStatus(s as DetailStatus),
(res) => setDetailResultUrl(res[0]?.src ?? null),
);
};
const resetTask = () => {
@@ -1667,10 +1870,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
detailProductImages.length === 0 ? "请上传产品图" : detailStatus === "generating" ? "生成中..." : "生成A+详情页";
const clonePrimaryLabel =
productImages.length === 0 ? "请先上传商品原图" : status === "generating" ? "生成中..." : `生成${selectedCloneOutput.label}`;
const clonePreviewCards = productSetPreviewCards.map((card, index) => ({
...card,
src: results[index]?.src ?? card.src,
}));
const setPreviewCards: CloneResult[] = [];
let setIndex = 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++) {
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<{
key: CloneBasicSelectKey;
label: string;
@@ -2589,14 +2817,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<button
type="button"
className="product-set-main-card"
onClick={() => openProductSetPreview(productSetPreviewCards[0])}
onClick={() => openProductSetPreview(setPreviewCards[0] ?? productSetPreviewCards[0])}
>
<img src={productSetPreviewCards[0].src} alt="01 主图" />
<span>{productSetPreviewCards[0].label}</span>
<img src={setImages[0]?.src ?? (setPreviewCards[0]?.src ?? productSetPreviewCards[0].src)} alt="商品原图" />
<span></span>
</button>
<div className="product-set-flow-arrow" aria-hidden="true" />
<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)}>
<img src={card.src} alt={card.label} />
<span>{card.label}</span>
@@ -2645,22 +2873,36 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<span>
AI <b></b>
</span>
<div className="clone-ai-preview-summary" aria-label="当前生成配置">
<span>{selectedCloneOutput.label}</span>
<span>{platform}</span>
<span>{market}</span>
<span>{language}</span>
<span>{formatRatioDisplayValue(ratio)}</span>
</div>
</header>
{status === "done" ? (
<section className="clone-ai-preview-showcase" aria-label="生成结果">
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(clonePreviewCards[0])}>
<img src={productImages[0]?.src ?? clonePreviewCards[0].src} alt="上传商品原图" />
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
<img src={productImages[0]?.src ?? (cloneOutput === "set" ? clonePreviewCards[0].src : results[0]?.src ?? "")} alt="上传商品原图" />
<span></span>
</button>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-result-grid result-reveal">
{clonePreviewCards.map((card) => (
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
<img src={card.src} alt={card.label} />
<span>{card.label}</span>
{cloneOutput === "set" ? (
clonePreviewCards.map((card) => (
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
<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>
))}
) : null}
</div>
</section>
) : (
@@ -2726,7 +2968,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</div>
<div className="product-detail-flow-arrow" aria-hidden="true" />
<div className="product-detail-long-result">
<img src={detailAssets.longPage} alt="生成电商长图" />
<img src={detailResultUrl ?? detailAssets.longPage} alt="生成电商长图" />
<span>{detailStatus === "done" ? "已生成电商长图" : "生成电商长图"}</span>
</div>
<div className="product-detail-grid-result">
@@ -2851,7 +3093,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"}
durationSeconds={cloneVideoDuration}
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>
) : 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 {
CopyOutlined,
DownloadOutlined,
@@ -9,17 +9,24 @@ import {
SendOutlined,
StopOutlined,
} from "@ant-design/icons";
import { runVideoPlan, renderScene, buildSceneTasks } from "./ecommerceVideoService";
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks } from "./ecommerceVideoService";
import {
PLAN_STEP_LABELS,
PLAN_STEPS_DISPLAY,
type EcommerceVideoStage,
type EcommerceVideoSceneTask,
type EcommerceVideoPlanResult,
type PlanStep,
} from "./ecommerceVideoTypes";
import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
import { ServerRequestError } from "../../api/serverConnection";
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
import { useAppStore } from "../../stores";
import {
saveEcommerceVideoState,
loadEcommerceVideoState,
clearEcommerceVideoState,
} from "./ecommerceVideoKeepalive";
interface EcommerceVideoWorkspaceProps {
isAuthenticated: boolean;
@@ -55,12 +62,120 @@ export default function EcommerceVideoWorkspace({
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]);
const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]);
const [sourceImageUrls, setSourceImageUrls] = useState<string[]>([]);
const [currentStep, setCurrentStep] = useState<PlanStep | null>(null);
const [error, setError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false });
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) => {
setActionNotice(msg);
@@ -70,159 +185,229 @@ export default function EcommerceVideoWorkspace({
const handleDownload = async (url: string) => {
try {
await saveToolResultToLocal({
url,
name: `ecommerce-video-${Date.now()}`,
type: "video",
isVideo: true,
tags: ["电商", "短视频", "生成视频"],
url, name: `ecommerce-video-${Date.now()}`, type: "video",
isVideo: true, tags: ["电商", "短视频", "生成视频"],
});
showNotice("下载完成");
} catch {
const a = document.createElement("a");
a.href = url;
a.download = "ecommerce-video.mp4";
a.click();
const a = document.createElement("a"); a.href = url; a.download = "ecommerce-video.mp4"; a.click();
}
};
const handleSaveAsset = async (url: string) => {
try {
const result = await addToolResultToAssetLibrary({
url,
name: `电商短视频-${Date.now()}.mp4`,
description: "电商广告视频生成结果",
type: "video",
isVideo: true,
tags: ["电商", "短视频", "广告视频"],
url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频生成结果",
type: "video", isVideo: true, tags: ["电商", "短视频", "广告视频"],
metadata: { source: "ecommerce-video", platform },
});
showNotice(result === "server" ? "已保存到资产库" : "已保存到本地资产库");
} catch {
showNotice("保存失败");
} catch { 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) => {
try {
await addToolResultToAssetLibrary({
url,
name: `电商短视频-${Date.now()}.mp4`,
description: "电商广告视频 - 导入画布",
type: "video",
isVideo: true,
tags: ["电商", "短视频", "画布导入"],
url, name: `电商短视频-${Date.now()}.mp4`, description: "电商广告视频 - 导入画布",
type: "video", isVideo: true, tags: ["电商", "短视频", "画布导入"],
metadata: { source: "ecommerce-video", platform },
});
setView("canvas");
showNotice("已保存资产并跳转画布");
} catch {
showNotice("导入失败");
}
} catch { showNotice("导入失败"); }
};
const buildConfig = useCallback((): AdVideoUserConfig => ({
platform,
aspectRatio,
durationSeconds,
style: "痛点解决",
language: "中文",
market: "中国",
needVoiceover: true,
needSubtitle: true,
conversionFocus: "conversion",
platform, aspectRatio, durationSeconds,
style: "痛点解决", language: "中文", market: "中国",
needVoiceover: true, needSubtitle: true, conversionFocus: "conversion",
}), [platform, aspectRatio, durationSeconds]);
// ── Phase 1: Planning ──────────────────────────────────────
const handlePlan = async () => {
if (!isAuthenticated) { onRequestLogin?.(); return; }
if (!productImageDataUrls.length && !requirement.trim()) {
setError("请先上传产品图片或填写商品说明");
return;
setError("请先上传产品图片或填写商品说明"); return;
}
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setStage("planning");
setError(null);
setCompletedSteps([]);
setCurrentStep(null);
setPlanResult(null);
setScenes([]);
setStage("planning"); setError(null);
setCompletedSteps([]); setCurrentStep(null);
setPlanResult(null); setScenes([]); setSourceImageUrls([]);
try {
const result = await runVideoPlan(
productImageDataUrls, requirement, buildConfig(),
{
onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]),
onImagesUploaded: (urls) => { setSourceImageUrls(urls); saveEcommerceVideoState({ stage: "planning", completedSteps: ["upload"], planResult: null, scenes: [], sourceImageUrls: urls }); },
signal: controller.signal,
},
);
const builtScenes = buildSceneTasks(result);
setPlanResult(result);
setScenes(buildSceneTasks(result));
setScenes(builtScenes);
setStage("planned");
// Persist immediately — component may be unmounted by the time React re-renders
saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, scenes: builtScenes, sourceImageUrls: result.imageUrls });
} catch (err) {
if ((err as Error).name === "AbortError") return;
setError(err instanceof Error ? err.message : "策划失败");
setStage("idle");
} finally {
setCurrentStep(null);
}
} finally { setCurrentStep(null); }
};
const handleRender = async () => {
if (!planResult || !scenes.length) return;
const imageUrl = productImageDataUrls[0] || "";
setStage("rendering");
setError(null);
renderAbortRef.current = { current: false };
const quality = mapResolutionToQuality(resolution);
// ── Phase 2: Image generation per scene ──────────────────────
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;
setScenes((prev) => prev.map((s) =>
s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
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, aspectRatio, resolution: quality },
await renderSceneImage(
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
{
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)),
onSceneImageSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)),
onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneImageCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)),
onSceneImageFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)),
},
renderAbortRef.current,
);
} catch (err) {
setScenes((prev) => prev.map((s) =>
s.sceneId === scene.sceneId ? { ...s, status: "failed", error: err instanceof Error ? err.message : "生成失败" } : s));
const message = err instanceof Error ? err.message : "图片生成失败";
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "idle", error: message } : s));
}
}
setScenes((current) => {
const hasFailed = current.some((s) => s.status === "failed");
const allDone = current.every((s) => s.status === "completed" || s.status === "failed");
if (allDone) setStage(hasFailed ? "partial_failed" : "completed");
return current;
});
const allHaveImages = currentScenes.every((s) => s.imageUrl);
const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const;
setStage(finalStage);
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
};
const handleCancel = () => {
abortControllerRef.current?.abort();
renderAbortRef.current.current = true;
setStage("cancelled");
// ── Phase 3: Video rendering from generated images ──────────
const handleRenderVideos = async () => {
if (!scenes.length) return;
const firstImage = scenes[0]?.imageUrl;
if (!firstImage) { setError("请先生成分镜图片"); return; }
setStage("rendering"); setError(null);
renderAbortRef.current = { current: false };
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 imagedScenes = scenes.filter((s) => s.imageUrl);
const primaryVideo = completedScenes[0]?.resultUrl;
const canRender = planResult?.compliance.allow_video_generation && stage === "planned";
const sourceImage = productImageDataUrls[0] || "";
const flowHasStarted = stage !== "idle" || completedSteps.length > 0 || scenes.length > 0;
const sourceImage = sourceImageUrls[0] || planResult?.imageUrls[0] || productImageDataUrls[0] || "";
const flowHasStarted = stage !== "idle" || completedSteps.length > 0;
const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`;
const planActionLabel = stage === "planning"
? "策划中"
: (stage === "planned" || stage === "completed" || stage === "partial_failed") ? "重新策划" : "一键策划";
const renderActionLabel = stage === "rendering" ? "生成中" : "确认生成";
const hasImaging = stage === "imaging" || stage === "imaged" || stage === "rendering" || stage === "completed" || stage === "partial_failed";
const hasRendering = stage === "rendering" || stage === "completed" || stage === "partial_failed";
const visiblePlanSteps = PLAN_STEPS_DISPLAY.filter((s) => completedSteps.includes(s));
return (
<div className="ecom-video-workspace" data-stage={stage}>
{/* ── Flow bar ──────────────────────────────────── */}
<header className="ecom-video-flowbar">
<div className="ecom-video-flowbar__title" aria-label={`短视频分镜流,${flowMeta}`} title={flowMeta}>
<span className={`ecom-video-flowbar__pulse${flowHasStarted ? " is-active" : ""}`} aria-hidden="true" />
@@ -233,111 +418,178 @@ export default function EcommerceVideoWorkspace({
{ALL_STEPS.map((step) => {
const isDone = completedSteps.includes(step);
const isActive = currentStep === step;
const cls = isDone ? "is-done" : isActive ? "is-active" : "";
return (
<span
key={step}
className={`ecom-video-step-dot ${cls}`}
title={PLAN_STEP_LABELS[step]}
aria-label={PLAN_STEP_LABELS[step]}
/>
);
return <span key={step} className={`ecom-video-step-dot ${isDone ? "is-done" : isActive ? "is-active" : ""}`} title={PLAN_STEP_LABELS[step]} />;
})}
</div>
<div className="ecom-video-flowbar__actions">
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
<button
type="button"
className="ecom-video-flow-action"
disabled={stage === "planning" || stage === "rendering"}
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 />}
{stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
<button type="button" className="ecom-video-flow-action"
onClick={() => void handlePlan()} title="一键策划">
<PlayCircleOutlined />
</button>
) : 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" ? (
<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 />
</button>
) : null}
</div>
</header>
{/* ── Flow canvas ──────────────────────────────────── */}
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
{completedScenes.length === 0 && !sourceImage ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "#697486", fontSize: 13 }}>
{!sourceImage ? (
<div className="ecom-video-empty">
<span>"一键策划"</span>
</div>
) : (
<div className="ecom-video-flow-map">
{sourceImage ? (
<article className="ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label="商品图节点">
<div className="ecom-video-flow-node__media">
<img src={sourceImage} alt="商品图" />
</div>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
) : null}
{/* Source image node */}
<article className="ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label="商品图节点">
<div className="ecom-video-flow-node__media">
<img src={sourceImage} alt="商品图" />
</div>
<span className="ecom-video-flow-node__label"></span>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</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>
) : null}
<div className="ecom-video-scene-strip" aria-label="已完成分镜节点">
{completedScenes.map((scene, index) => (
<Fragment key={scene.sceneId}>
<article
className="ecom-video-flow-node ecom-video-flow-node--scene is-completed"
aria-label={`镜头 ${scene.sceneId},完成`}
title={`镜头 ${scene.sceneId}`}
>
<div className="ecom-video-flow-node__media">
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
</div>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{index < completedScenes.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
))}
</div>
{/* Plan text nodes — side by side */}
{visiblePlanSteps.length > 0 ? (
<div className="ecom-video-scene-strip ecom-video-scene-strip--text" aria-label="策划节点">
{visiblePlanSteps.map((step, idx) => (
<Fragment key={step}>
<article className={`ecom-video-flow-node ecom-video-flow-node--text is-completed${currentStep === step ? " is-pulsing" : ""}`}
aria-label={PLAN_STEP_LABELS[step]} title={PLAN_STEP_LABELS[step]}>
<span className="ecom-video-flow-node__text-icon">
{currentStep === step ? <LoadingOutlined /> : "✓"}
</span>
<span className="ecom-video-flow-node__label">{PLAN_STEP_LABELS[step]}</span>
</article>
{idx < visiblePlanSteps.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
))}
</div>
) : null}
{completedScenes.length > 0 && primaryVideo ? (
{/* Connector: plan → images */}
{hasImaging ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
{primaryVideo ? (
<article className="ecom-video-flow-node ecom-video-flow-node--final is-completed" aria-label="成片节点,已完成">
<div className="ecom-video-flow-node__media">
<video src={primaryVideo} muted playsInline loop autoPlay />
</div>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{/* Storyboard image nodes — side by side per scene */}
{hasImaging ? (
<div className="ecom-video-scene-strip" aria-label="分镜图片节点">
{scenes.map((scene, idx) => {
const imgReady = !!scene.imageUrl;
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
const cls = imgReady ? "is-completed" : imgRunning ? "is-active" : "";
return (
<Fragment key={`img-${scene.sceneId}`}>
<article className={`ecom-video-flow-node ecom-video-flow-node--image ${cls}`}
aria-label={`分镜 ${scene.sceneId}`} title={`分镜 ${scene.sceneId}`}>
<div className="ecom-video-flow-node__media">
{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}
</div>
)}
{/* ── Delivery dock ────────────────────────────── */}
{primaryVideo ? (
<div className="ecom-video-flow-dock" aria-label="视频交付操作">
<button type="button" aria-label="下载当前视频" title="下载当前视频" onClick={() => void handleDownload(primaryVideo)}><DownloadOutlined /></button>
<button type="button" aria-label="保存到资产库" title="保存到资产库" onClick={() => void handleSaveAsset(primaryVideo)}><FolderAddOutlined /></button>
<button type="button" aria-label="导入画布" title="导入画布" onClick={() => void handleImportToCanvas(primaryVideo)}><SendOutlined /></button>
<button type="button" aria-label="复制视频链接" title="复制视频链接" onClick={() => void navigator.clipboard.writeText(primaryVideo)}><CopyOutlined /></button>
<button type="button" onClick={() => void handleDownloadAll()} title={`下载全部 ${completedScenes.length} 个视频`}><DownloadOutlined /></button>
<button type="button" onClick={() => void handleSaveAllAssets()} title={`保存全部 ${completedScenes.length} 个视频到资产库`}><FolderAddOutlined /></button>
{primaryVideo ? <button type="button" onClick={() => void handleImportToCanvas(primaryVideo)} title="导入画布"><SendOutlined /></button> : null}
{primaryVideo ? <button type="button" onClick={() => void navigator.clipboard.writeText(primaryVideo)} title="复制链接"><CopyOutlined /></button> : null}
</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 {
onStepStart: (step: PlanStep) => void;
onStepDone: (step: PlanStep) => void;
onImagesUploaded?: (urls: string[]) => void;
signal?: AbortSignal;
}
@@ -46,7 +47,9 @@ export async function runVideoPlan(
// skip images that fail to upload
}
}
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
onStepDone("upload");
callbacks.onImagesUploaded?.(imageUrls);
onStepStart("analyze");
const imageDesc = await analyzeProductImages(imageUrls, signal);
@@ -77,7 +80,46 @@ export async function runVideoPlan(
const compliance = await checkCompliance(summary, selling, storyboard, signal);
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 {
@@ -114,7 +156,7 @@ export async function renderScene(
duration: input.durationSeconds,
quality: input.resolution,
resolution: input.resolution,
imageUrl: input.imageUrl,
frameMode: "start-end",
referenceUrls: [input.imageUrl],
hasReferenceVideo: false,
});
@@ -137,12 +179,12 @@ export function buildSceneTasks(
plan: EcommerceVideoPlanResult,
): EcommerceVideoSceneTask[] {
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 {
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,
status: "idle",
status: "idle" as const,
progress: 0,
};
});
@@ -12,6 +12,8 @@ export type EcommerceVideoStage =
| "uploading"
| "planning"
| "planned"
| "imaging"
| "imaged"
| "rendering"
| "partial_failed"
| "completed"
@@ -22,15 +24,18 @@ export type SceneTaskStatus = "idle" | "pending" | "running" | "completed" | "fa
export interface EcommerceVideoSceneTask {
sceneId: number;
taskId?: string;
imageTaskId?: string;
prompt: string;
durationSeconds: number;
status: SceneTaskStatus;
progress: number;
resultUrl?: string | null;
imageUrl?: string | null;
error?: string | null;
}
export interface EcommerceVideoPlanResult {
imageUrls: string[];
summary: ProductSummary;
selling: SellingPointResult;
creatives: CreativeOption[];
@@ -67,3 +72,8 @@ export const PLAN_STEP_LABELS: Record<PlanStep, string> = {
prompts: "生成镜头提示词",
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 ScriptReviewShowcase from "./ScriptReviewShowcase";
import ModelGenerationShowcase from "./ModelGenerationShowcase";
import ecommerceTemplate1 from "../../assets/home-features/home-ecommerce-template-1.png";
import ecommerceTemplate2 from "../../assets/home-features/home-ecommerce-template-2.png";
import ecommerceTemplate3 from "../../assets/home-features/home-ecommerce-template-3.png";
const ecommerceTemplate1 = "https://www.omniai.net.cn/static/home-ecommerce-template-1.png";
const ecommerceTemplate2 = "https://www.omniai.net.cn/static/home-ecommerce-template-2.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>) {
const { ref, isVisible } = useScrollEntrance<HTMLElement>();
@@ -45,7 +45,7 @@ interface HomePageProps {
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 = [
{ 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 [activeSlideIndex, setActiveSlideIndex] = useState(0);
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 首页">
<div className="omni-home__copy">
<h1>OmniAI </h1>
<p>绿</p>
<p>AIGC与电商</p>
</div>
<div className={`omni-home__carousel${carouselIsResetting ? " is-resetting" : ""}`} aria-label="创作案例轮播">
@@ -352,7 +352,7 @@ function HomePage({ onOpenGenerate, onOpenEcommerce, onOpenScriptReview, onOpenT
<PlusOutlined />
<span></span>
</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 />
<span></span>
</button>
+1 -1
View File
@@ -15,7 +15,7 @@ const prefersReducedMotion = typeof window !== "undefined"
export default function WelcomeSplash({ onEnter }: WelcomeSplashProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef(0);
const [showWelcome, setShowWelcome] = useState(false);
const [showWelcome, setShowWelcome] = useState(true);
const [exiting, setExiting] = useState(false);
const handleEnter = useCallback(() => {
@@ -27,6 +27,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { translateTaskError } from "../../utils/translateTaskError";
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
import { useCanvasDrawing } from "./useCanvasDrawing";
@@ -139,13 +140,39 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
const [generationError, setGenerationError] = useState<string | null>(null);
const abortRef = useRef(false);
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(() => {
return () => {
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(() => {});
taskIdRef.current = null;
}
clearToolTaskState("imagewb");
setGenerating(false);
setGenerationProgress(0);
setStatus("已取消");
@@ -305,6 +333,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
referenceUrls: refUrls,
});
taskIdRef.current = taskId;
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
const tempUrl = await pollTaskUntilDone(taskId);
if (tempUrl) {
@@ -385,6 +414,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
referenceUrls: refUrls,
});
taskIdRef.current = taskId;
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
const tempUrl = await pollTaskUntilDone(taskId);
if (tempUrl) {
@@ -530,6 +560,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
referenceUrls: refUrls,
});
taskIdRef.current = taskId;
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
const tempUrl = await pollTaskUntilDone(taskId);
if (tempUrl) {
@@ -544,6 +575,10 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
}
setResultImages(results);
clearToolTaskState("imagewb");
if (results.length) {
saveToolTaskState("imagewb", { taskId: taskIdRef.current || "", resultUrl: results[0], status: "完成", progress: 100 });
}
setStatus(results.length ? `生成完成,共 ${results.length}` : "生成已取消");
} catch (err) {
const msg = err instanceof Error ? err.message : "生成失败";
+1 -10
View File
@@ -7,10 +7,7 @@ import {
DeleteOutlined,
EditOutlined,
HighlightOutlined,
PictureOutlined,
ShoppingOutlined,
SwapOutlined,
TableOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
@@ -23,7 +20,7 @@ interface MorePageProps {
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
}
type ToolCategory = "image" | "video" | "template";
type ToolCategory = "image" | "video";
type FilterKey = "all" | ToolCategory | "upcoming";
interface MoreTool {
@@ -49,9 +46,6 @@ const tools: MoreTool[] = [
{ 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: "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 {
@@ -89,20 +83,17 @@ const featuredTools: FeaturedTool[] = [
const categoryLabels: Record<ToolCategory, string> = {
image: "图像创作",
video: "视频生成",
template: "模板与素材",
};
const categoryIcons: Record<ToolCategory, ReactNode> = {
image: <EditOutlined />,
video: <VideoCameraOutlined />,
template: <ShoppingOutlined />,
};
const filters: { key: FilterKey; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "image", label: "图像" },
{ key: "video", label: "视频" },
{ key: "template", label: "模板" },
{ key: "upcoming", label: "即将上线" },
];
+192 -118
View File
@@ -1,7 +1,10 @@
import {
CameraOutlined,
CheckOutlined,
CheckCircleFilled,
CloseOutlined,
DeleteOutlined,
EditOutlined,
LockOutlined,
MailOutlined,
MobileOutlined,
@@ -10,7 +13,6 @@ import {
SafetyOutlined,
ShareAltOutlined,
UserOutlined,
WechatOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
@@ -37,7 +39,7 @@ interface ProfilePageProps {
onDeleteProject?: (project: WebProjectSummary) => void;
}
type AuthTab = "password" | "email" | "phone" | "wechat";
type AuthTab = "password" | "email" | "phone";
type ProfilePanel = "works" | "projects" | "assets" | "community";
type AccountPanel = "credits" | "tasks";
@@ -135,6 +137,48 @@ function mapAssetToSavedItem(asset: Awaited<ReturnType<typeof assetClient.list>>
};
}
function formatProfileDate(value: string | null | undefined): string {
if (!value) return "刚刚";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function formatTaskType(type: WebGenerationPreviewTask["type"]): string {
const labels: Record<WebGenerationPreviewTask["type"], string> = {
image: "图像",
video: "视频",
agent: "智能体",
"digital-human": "数字人",
"character-mix": "角色融合",
};
return labels[type] || type;
}
function formatTaskStatus(status: WebGenerationPreviewTask["status"]): string {
const labels: Record<WebGenerationPreviewTask["status"], string> = {
queued: "排队中",
running: "生成中",
completed: "已完成",
failed: "失败",
};
return labels[status] || status;
}
function formatAssetStatus(status: string | undefined): string {
const normalized = String(status || "").toLowerCase();
if (normalized === "completed" || normalized === "ready" || normalized === "success") return "可用";
if (normalized === "running" || normalized === "processing") return "处理中";
if (normalized === "failed" || normalized === "error") return "失败";
return status || "资产";
}
function ProfilePage({
session,
usage,
@@ -170,8 +214,6 @@ function ProfilePage({
const [isSubmitting, setIsSubmitting] = useState(false);
const [smsCooldown, setSmsCooldown] = useState(0);
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 [accountPanel, setAccountPanel] = useState<AccountPanel>("credits");
@@ -182,6 +224,9 @@ function ProfilePage({
const [profileNotice, setProfileNotice] = useState<string | null>(null);
const [localAvatarUrl, setLocalAvatarUrl] = useState(() => session?.user.avatarUrl || readLocalProfileValue(userId, "avatar"));
const [profileBio, setProfileBio] = useState(() => session?.user.bio || readLocalProfileValue(userId, "bio"));
const [isBioEditing, setIsBioEditing] = useState(false);
const [bioEditBackup, setBioEditBackup] = useState("");
const [bioStatusNotice, setBioStatusNotice] = useState<string | null>(null);
const [bannerUrl, setBannerUrl] = useState(() => session?.user.backgroundUrl || readLocalProfileValue(userId, "background"));
const completedTasks = tasks.filter((task) => task.status === "completed");
@@ -190,6 +235,9 @@ function ProfilePage({
const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分";
const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null;
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(() => {
setLocalAvatarUrl(session?.user.avatarUrl || readLocalProfileValue(userId, "avatar"));
@@ -248,55 +296,6 @@ function ProfilePage({
return () => window.clearInterval(timer);
}, [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 () => {
if (smsCooldown > 0 || !phone.trim() || isSendingSms) return;
if (mode === "register" && !betaCode.trim()) {
@@ -359,7 +358,7 @@ function ProfilePage({
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmitting || authTab === "wechat") return;
if (isSubmitting) return;
const errors: Record<string, string> = {};
if (mode === "register") {
@@ -497,8 +496,29 @@ function ProfilePage({
void syncProfilePatch({ bio: nextBio || null });
};
const startBioEdit = () => {
setBioEditBackup(profileBio);
setBioStatusNotice(null);
setIsBioEditing(true);
};
const confirmBioEdit = () => {
handleBioBlur();
setIsBioEditing(false);
setBioStatusNotice("个性签名已保存");
};
const cancelBioEdit = () => {
setProfileBio(bioEditBackup);
setIsBioEditing(false);
setBioStatusNotice(null);
};
const renderEmptyState = (text: string, actionLabel: string, action: () => void) => (
<div className="profile-page__empty-state">
<span className="profile-page__empty-mark" aria-hidden="true">
<PlusOutlined />
</span>
<p className="profile-page__empty-text">{text}</p>
<button type="button" className="profile-page__empty-btn" onClick={action}>
<PlusOutlined />
@@ -515,12 +535,12 @@ function ProfilePage({
<article key={task.id} className="profile-page__list-card">
<div className="profile-page__list-card-head">
<strong>{task.title}</strong>
<span>{task.type}</span>
<span>{formatTaskType(task.type)}</span>
</div>
<p>{task.prompt}</p>
<div className="profile-page__list-card-meta">
<span>{task.status}</span>
<span>{task.createdAt}</span>
<span>{formatTaskStatus(task.status)}</span>
<span>{formatProfileDate(task.createdAt)}</span>
</div>
</article>
))}
@@ -537,7 +557,7 @@ function ProfilePage({
<article key={project.id} className="profile-page__list-card">
<div className="profile-page__list-card-head">
<strong>{project.name}</strong>
<span>{project.updatedAt}</span>
<span>{formatProfileDate(project.updatedAt)}</span>
{onDeleteProject ? (
<button
type="button"
@@ -569,12 +589,12 @@ function ProfilePage({
<article key={asset.id} className="profile-page__list-card">
<div className="profile-page__list-card-head">
<strong>{asset.name}</strong>
<span>{asset.status}</span>
<span>{formatAssetStatus(asset.status)}</span>
</div>
<p>{asset.description}</p>
<div className="profile-page__list-card-meta">
<span>{asset.type}</span>
<span>{asset.updatedAt}</span>
<span>{formatProfileDate(asset.updatedAt)}</span>
</div>
</article>
))}
@@ -637,15 +657,39 @@ function ProfilePage({
</span>
</div>
<strong className="profile-page__username">{displayName}</strong>
<textarea
className="profile-page__bio"
value={profileBio}
onChange={(event) => setProfileBio(event.target.value)}
onBlur={handleBioBlur}
placeholder={displayedBio}
rows={2}
maxLength={80}
/>
{isBioEditing ? (
<div className="profile-page__bio-editor">
<textarea
className="profile-page__bio"
value={profileBio}
onChange={(event) => setProfileBio(event.target.value)}
placeholder="填写一句个人签名"
rows={2}
maxLength={80}
autoFocus
/>
<div className="profile-page__bio-actions">
<button type="button" className="profile-page__bio-action profile-page__bio-action--save" onClick={confirmBioEdit}>
<CheckOutlined />
</button>
<button type="button" className="profile-page__bio-action" onClick={cancelBioEdit}>
<CloseOutlined />
</button>
</div>
</div>
) : (
<button
type="button"
className={`profile-page__bio-display${profileBio.trim() ? "" : " is-empty"}`}
onClick={startBioEdit}
>
<span>{displayedBio}</span>
<EditOutlined className="profile-page__bio-edit-icon" />
</button>
)}
{bioStatusNotice ? <span className="profile-page__bio-status">{bioStatusNotice}</span> : null}
{profileNotice ? <span className="profile-page__sync-notice">{profileNotice}</span> : null}
</div>
@@ -664,18 +708,21 @@ function ProfilePage({
</div>
</div>
<button type="button" className="profile-page__share-btn">
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
<ShareAltOutlined />
{packageLabel}
</button>
<button type="button" className="profile-page__share-btn" onClick={onOpenWorkbench}>
<button type="button" className="profile-page__share-btn profile-page__share-btn--primary" onClick={onOpenWorkbench}>
<PlusOutlined />
</button>
<button type="button" className="profile-page__share-btn" onClick={onOpenCommunity}>
<button type="button" className="profile-page__share-btn profile-page__share-btn--secondary" onClick={onOpenCommunity}>
<ShareAltOutlined />
</button>
<button type="button" className="profile-page__share-btn" onClick={onLogout}>
<button type="button" className="profile-page__share-btn profile-page__share-btn--danger" onClick={onLogout}>
<LockOutlined />
退
</button>
</aside>
@@ -731,13 +778,25 @@ function ProfilePage({
<div className="profile-page__upload-card profile-page__upload-card--meta">
{accountPanel === "credits" ? (
<>
<span>{displayName}</span>
<span>{(usage.balanceCents / 100).toFixed(2)}</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{displayName}</strong>
</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
</span>
</>
) : (
<>
<span>{tasks.length}</span>
<span>{completedTasks.length}</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{tasks.length}</strong>
</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{completedTasks.length}</strong>
</span>
</>
)}
</div>
@@ -756,12 +815,30 @@ function ProfilePage({
<source src={AUTH_SHOWCASE_VIDEO_URL} type="video/mp4" />
</video>
<div className="auth-page__video-overlay">
<h1 className="auth-page__brand">OmniAI</h1>
<p className="auth-page__tagline"></p>
<div className="auth-page__features">
<span>AI </span>
<span>AI </span>
<span>AI </span>
<div className="auth-page__showcase-content">
<div className="auth-page__brand-row">
<h1 className="auth-page__brand">OmniAI</h1>
</div>
<p className="auth-page__tagline"></p>
<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>
@@ -773,6 +850,7 @@ function ProfilePage({
<span className="auth-page__logo">
<img src={AUTH_LOGO_URL} alt="OmniAI" />
</span>
<span className="auth-page__form-kicker">{mode === "login" ? "账户登录" : "新用户注册"}</span>
<h2 className="auth-page__title">{mode === "login" ? "欢迎回来" : "创建账号"}</h2>
<p className="auth-page__subtitle">
{mode === "login" ? "登录后继续你的 AI 创作之旅" : "注册即可免费体验全部功能"}
@@ -812,10 +890,8 @@ function ProfilePage({
<MailOutlined />
</button>
<button type="button" className={authTab === "phone" ? "is-active" : ""} onClick={() => { setAuthTab("phone"); setFieldErrors({}); }}>
<MobileOutlined />
</button>
<button type="button" className={authTab === "wechat" ? "is-active" : ""} onClick={() => { setAuthTab("wechat"); setFieldErrors({}); }}>
<WechatOutlined />
<MobileOutlined />
<span className="auth-page__tab-label-short"></span>
</button>
</div>
@@ -827,7 +903,7 @@ function ProfilePage({
) : null}
<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" : ""}`}>
<span>
<SafetyOutlined /> /
@@ -871,6 +947,11 @@ function ProfilePage({
autoComplete={mode === "login" ? "current-password" : "new-password"}
/>
{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>
{mode === "login" ? (
<div className="auth-page__forgot">
@@ -908,6 +989,11 @@ function ProfilePage({
autoComplete="email"
/>
{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 className={`auth-page__field${fieldErrors.password ? " auth-page__field--error" : ""}`}>
<span>
@@ -922,6 +1008,11 @@ function ProfilePage({
autoComplete={mode === "login" ? "current-password" : "new-password"}
/>
{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>
</>
) : null}
@@ -937,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" />
</div>
{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 className={`auth-page__field${fieldErrors.smsCode ? " auth-page__field--error" : ""}`}>
<span>
@@ -962,40 +1058,21 @@ function ProfilePage({
</span>
<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}
{passwordLooksReady && !fieldErrors.password ? (
<span className="auth-page__field-hint">
<CheckCircleFilled />
</span>
) : null}
</label>
) : 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}
{authTab !== "wechat" ? (
<button type="submit" className="auth-page__submit" disabled={isSubmitting}>
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
</button>
) : null}
<button type="submit" className="auth-page__submit" disabled={isSubmitting}>
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
</button>
<div className="auth-page__agreement">
<span>
@@ -1009,9 +1086,6 @@ function ProfilePage({
</div>
<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")}>
<MobileOutlined />
</button>
@@ -18,6 +18,7 @@ import {
import { useCallback, useEffect, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { translateTaskError } from "../../utils/translateTaskError";
import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection";
import { summarizeUrl, formatFileSize, fileToDataUrl, wait } from "../../utils/toolPageUtils";
@@ -88,6 +89,7 @@ function ResolutionUpscalePage({
const [isSavingAsset, setIsSavingAsset] = useState(false);
const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
useEffect(() => {
return () => {
@@ -101,13 +103,25 @@ function ResolutionUpscalePage({
};
}, [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(() => {
return () => {
// Stop polling but keep server task alive — keep-alive will resume on remount
pollRunRef.current += 1;
cancelRef.current = true;
if (activeTaskIdRef.current) {
aiGenerationClient.cancelTask(activeTaskIdRef.current).catch(() => {});
}
};
}, []);
@@ -182,6 +196,7 @@ function ResolutionUpscalePage({
const runId = ++pollRunRef.current;
setActiveTaskId(taskId);
setTaskProgress(5);
saveToolTaskState("upscale", { taskId, sourceName, sourceUrl, status: `任务 ${taskId}`, progress: 5 });
await waitForTask(taskId, {
abortRef: cancelRef,
@@ -195,6 +210,8 @@ function ResolutionUpscalePage({
setVideoViewMode("result");
setStatus(`${taskMode === "image" ? "图片" : "视频"}超分完成:${summarizeUrl(e.resultUrl)}`);
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);
setStatus("已取消");
clearToolTaskState("upscale");
}, [activeTaskId]);
const handleDownload = async () => {
@@ -475,7 +475,17 @@ function ScriptTokensPage() {
</div>
<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-illustration" aria-label="上传剧本并开始评测">
<div
@@ -501,7 +511,10 @@ function ScriptTokensPage() {
{evalError && (
<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>
@@ -523,7 +536,7 @@ function ScriptTokensPage() {
<div className="script-eval-report__score-line">
<span style={{ width: `${animatedScore}%` }} />
</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 className="script-eval-report__summary">
@@ -84,10 +84,10 @@ function formatDayLabel(value: string): string {
type TrendPoint = { date: string; usedCents: number; taskCount: number };
function UsageTrendChart({ data }: { data: TrendPoint[] }) {
const W = 480;
const H = 120;
const padX = 28;
const padY = 18;
const W = 680;
const H = 200;
const padX = 32;
const padY = 24;
const maxCents = Math.max(1, ...data.map((d) => d.usedCents));
const stepX = data.length > 1 ? (W - padX * 2) / (data.length - 1) : 0;
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 { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection";
import { summarizeUrl, formatFileSize, fileToDataUrl } from "../../utils/toolPageUtils";
import TaskStatusBar from "../../components/TaskStatusBar";
@@ -74,14 +75,26 @@ function SubtitleRemovalPage({
const [isSavingAsset, setIsSavingAsset] = useState(false);
const activeTaskIdRef = useRef(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(() => {
return () => {
pollRunRef.current += 1;
cancelRef.current = true;
if (activeTaskIdRef.current) {
aiGenerationClient.cancelTask(activeTaskIdRef.current).catch(() => {});
}
};
}, []);
@@ -158,6 +171,7 @@ function SubtitleRemovalPage({
const runId = ++pollRunRef.current;
setActiveTaskId(taskId);
setTaskProgress(5);
saveToolTaskState("subtitle", { taskId, sourceName, sourceUrl, status: `任务 ${taskId}`, progress: 5 });
await waitForTask(taskId, {
abortRef: cancelRef,
@@ -170,6 +184,8 @@ function SubtitleRemovalPage({
setResultUrl(e.resultUrl);
setStatus("字幕去除完成");
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);
setStatus("已取消");
clearToolTaskState("subtitle");
}, [activeTaskId]);
const handleDownload = async () => {
@@ -15,6 +15,7 @@ import {
import { useCallback, useEffect, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection";
import { summarizeUrl, formatFileSize, fileToDataUrl } from "../../utils/toolPageUtils";
import TaskStatusBar from "../../components/TaskStatusBar";
@@ -50,6 +51,7 @@ function WatermarkRemovalPage({
const [isDownloading, setIsDownloading] = useState(false);
const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
useEffect(() => {
return () => {
@@ -57,13 +59,24 @@ function WatermarkRemovalPage({
};
}, [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(() => {
return () => {
pollRunRef.current += 1;
cancelRef.current = true;
if (activeTaskIdRef.current) {
aiGenerationClient.cancelTask(activeTaskIdRef.current).catch(() => {});
}
};
}, []);
@@ -140,6 +153,7 @@ function WatermarkRemovalPage({
const runId = ++pollRunRef.current;
setActiveTaskId(taskId);
setTaskProgress(5);
saveToolTaskState("watermark", { taskId, sourceName, sourceUrl, status: `任务 ${taskId}`, progress: 5 });
await waitForTask(taskId, {
abortRef: cancelRef,
@@ -152,6 +166,8 @@ function WatermarkRemovalPage({
setResultPreview(e.resultUrl);
setStatus("去水印完成");
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);
setStatus("已取消");
clearToolTaskState("watermark");
}, [activeTaskId]);
const handleDownload = async () => {
+51 -9
View File
@@ -236,6 +236,7 @@ function WorkbenchPage({
const keepaliveTasksRef = useRef<Record<string, WorkbenchKeepaliveTask>>(readStoredKeepaliveTasks());
const taskAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
const lastScrollTopRef = useRef(0);
const scrollActionsHideTimerRef = useRef<number | null>(null);
const shouldFollowNewMessagesRef = useRef(true);
const pendingScrollToLatestRef = useRef(true);
const renderedMessageIdsRef = useRef<string[]>([]);
@@ -273,6 +274,8 @@ function WorkbenchPage({
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
const [mentionActiveIndex, setMentionActiveIndex] = useState(0);
const [composerHidden, setComposerHidden] = useState(false);
const [scrollActionsVisible, setScrollActionsVisible] = useState(false);
const [scrollActionDirection, setScrollActionDirection] = useState<"top" | "bottom" | null>(null);
const [workspaceStarted, setWorkspaceStarted] = useState(false);
useEffect(() => {
@@ -280,6 +283,7 @@ function WorkbenchPage({
}, []);
useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
assetClient
.list()
@@ -440,6 +444,27 @@ function WorkbenchPage({
"--accent-glow": `0 0 24px rgba(${accentRgb}, 0.22)`,
} 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 scroll = () => {
const surface = messagesSurfaceRef.current;
@@ -450,6 +475,7 @@ function WorkbenchPage({
setComposerHidden(false);
shouldFollowNewMessagesRef.current = true;
revealScrollActionsTemporarily("bottom");
surface.scrollTo({ top: surface.scrollHeight, behavior });
lastScrollTopRef.current = surface.scrollTop;
};
@@ -458,7 +484,7 @@ function WorkbenchPage({
scroll();
window.setTimeout(scroll, 80);
});
}, []);
}, [revealScrollActionsTemporarily]);
const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>(
() => [
@@ -948,6 +974,11 @@ function WorkbenchPage({
await patchConversationMessage(task.conversationId, task.assistantMessageId, completedPatch);
removeKeepaliveTask(task.taskId);
onRefreshUsage?.();
if (status.status === "completed") {
import("../../utils/generationNotifier").then((m) =>
m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"),
);
}
try {
if (status.resultUrl) {
const persistedResult = await persistWorkbenchResultAsset({
@@ -991,6 +1022,11 @@ function WorkbenchPage({
});
removeKeepaliveTask(task.taskId);
onRefreshUsage?.();
if (status.status === "completed") {
import("../../utils/generationNotifier").then((m) =>
m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"),
);
}
return;
}
@@ -1362,6 +1398,9 @@ function WorkbenchPage({
const delta = top - lastScrollTopRef.current;
const atTop = top <= 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;
if (atTop || atBottom) {
setComposerHidden(false);
@@ -1373,7 +1412,7 @@ function WorkbenchPage({
surface.addEventListener("scroll", handleScroll, { passive: true });
return () => surface.removeEventListener("scroll", handleScroll);
}, [hasActivatedWorkspace]);
}, [hasActivatedWorkspace, revealScrollActionsTemporarily]);
const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => {
const surface = messagesSurfaceRef.current;
@@ -1381,8 +1420,9 @@ function WorkbenchPage({
const top = direction === "top" ? 0 : surface.scrollHeight;
setComposerHidden(false);
revealScrollActionsTemporarily(direction);
surface.scrollTo({ top, behavior: "smooth" });
}, []);
}, [revealScrollActionsTemporarily]);
const closeToolbarMenus = () => setToolbarMenuId(null);
const toggleToolbarMenu = (menuId: Exclude<ToolbarMenuId, null>) => {
@@ -2104,7 +2144,7 @@ function WorkbenchPage({
return;
}
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
setProjectError("当前任务数已达上限3个),请等待任务完成后再试");
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
return;
}
if (!isAuthenticated) {
@@ -2226,7 +2266,7 @@ function WorkbenchPage({
return;
}
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
setProjectError("当前任务数已达上限3个),请等待任务完成后再试");
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
return;
}
if (!isAuthenticated) {
@@ -2429,7 +2469,6 @@ function WorkbenchPage({
projects={conversationRecords}
activeId={activeConversationId ? String(activeConversationId) : null}
collapsed={sidebarCollapsed}
filterMode={activeMode}
loading={false}
error={projectError}
onToggle={() => setSidebarCollapsed((v) => (hasSidebarRecords ? !v : true))}
@@ -3012,10 +3051,13 @@ function WorkbenchPage({
{renderComposerToolbar(false, isGenerating)}
</div>
</section>
<div className="wb-chat-scroll-actions" aria-label="聊天滚动">
<div
className={`wb-chat-scroll-actions${scrollActionsVisible ? " is-visible" : ""}${scrollActionDirection ? ` is-${scrollActionDirection}` : ""}`}
aria-label="聊天滚动"
>
<button
type="button"
className="wb-chat-scroll-actions__button"
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--top"
title="返回聊天顶部"
aria-label="返回聊天顶部"
onClick={() => scrollMessagesSurface("top")}
@@ -3024,7 +3066,7 @@ function WorkbenchPage({
</button>
<button
type="button"
className="wb-chat-scroll-actions__button"
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--bottom"
title="到达聊天底部"
aria-label="到达聊天底部"
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 type { WebProjectSummary, WebCanvasWorkflow } from '../types';
import { createBlankWorkflow } from '../data/workflows';
interface ProjectState {
projects: WebProjectSummary[];
projectsLoaded: boolean;
canvasWorkflow: WebCanvasWorkflow | null;
canvasWorkflow: WebCanvasWorkflow;
currentCanvasProjectId: string | null;
pendingDeleteProject: WebProjectSummary | null;
deleteProjectSubmitting: boolean;
@@ -13,7 +14,7 @@ interface ProjectState {
interface ProjectActions {
setProjects: (projects: WebProjectSummary[]) => void;
setProjectsLoaded: (loaded: boolean) => void;
setCanvasWorkflow: (workflow: WebCanvasWorkflow | null) => void;
setCanvasWorkflow: (workflow: WebCanvasWorkflow) => void;
setCurrentCanvasProjectId: (id: string | null) => void;
openDeleteProject: (project: WebProjectSummary) => void;
closeDeleteProject: () => void;
@@ -24,7 +25,7 @@ interface ProjectActions {
const initialState: ProjectState = {
projects: [],
projectsLoaded: false,
canvasWorkflow: null,
canvasWorkflow: createBlankWorkflow(),
currentCanvasProjectId: null,
pendingDeleteProject: null,
deleteProjectSubmitting: false,
+13 -27
View File
@@ -16,43 +16,29 @@
}
}
/* Directional page transitions */
.page-motion--enter.is-forward {
animation: page-slide-in-forward 200ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both;
}
.page-motion--enter.is-backward {
animation: page-slide-in-backward 200ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both;
/* Exit: fade + directional slide */
.page-motion--exit {
animation: page-out 180ms ease forwards;
pointer-events: none;
}
.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 {
animation: page-slide-out-backward 180ms ease both;
animation: page-slide-out-backward 180ms ease forwards;
pointer-events: none;
}
@keyframes page-slide-in-forward {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
/* Cancel child's own entrance animation during exit */
.page-motion--exit .page-motion {
animation: none !important;
}
@keyframes page-slide-in-backward {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
@keyframes page-out {
to { opacity: 0; transform: translateY(-6px); }
}
@keyframes page-slide-out-forward {
+1 -12
View File
@@ -65,18 +65,7 @@
min-height: 0;
}
.page-motion--exit {
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-motion--exit moved to page-transition.css */
.page-loading-center {
display: flex;
+13
View File
@@ -592,6 +592,19 @@
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) {
.studio-canvas-project-bar {
right: 10px;
+201
View File
@@ -122,6 +122,16 @@
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 {
display: inline-grid;
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) {
.ecom-video-flowbar {
grid-template-columns: auto 1fr;
+774
View File
@@ -7933,3 +7933,777 @@
.clone-ai-adwizard__risk.is-medium { color: #faad14; }
.clone-ai-adwizard__risk.is-high { color: #ff4d4f; }
.clone-ai-adwizard__issues { margin: 0; padding-left: 16px; font-size: 12px; display: flex; flex-direction: column; gap: 4px; }
/* Ecommerce generation page SaaS polish: visual-only refinement for the product creation workspace. */
.product-clone-page {
--ecm-page: #0e1012;
--ecm-panel: rgba(20, 23, 25, 0.96);
--ecm-panel-strong: rgba(24, 28, 30, 0.98);
--ecm-inset: rgba(255, 255, 255, 0.035);
--ecm-inset-hover: rgba(255, 255, 255, 0.06);
--ecm-line: rgba(255, 255, 255, 0.095);
--ecm-line-strong: rgba(255, 255, 255, 0.14);
--ecm-text: #eef4f0;
--ecm-muted: rgba(232, 240, 235, 0.62);
--ecm-soft: rgba(232, 240, 235, 0.42);
--ecm-accent: var(--accent, #00ff88);
--ecm-accent-rgb: var(--accent-rgb, 0, 255, 136);
--ecm-radius-sm: 10px;
--ecm-radius-md: 14px;
--ecm-radius-lg: 18px;
--ecm-shadow-soft: 0 14px 38px rgba(0, 0, 0, 0.2);
--ecm-shadow-panel: 0 18px 54px rgba(0, 0, 0, 0.28);
background:
radial-gradient(circle at 26% 0%, rgba(var(--ecm-accent-rgb), 0.055), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.022), transparent 150px),
var(--ecm-page);
color: var(--ecm-text);
font-family: var(--font-sans, Inter, "PingFang SC", "Microsoft YaHei", Arial, sans-serif);
}
.product-clone-page > .product-clone-shell {
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.018), transparent 22%, transparent 76%, rgba(255, 255, 255, 0.014)),
transparent;
}
.product-clone-page :is(button, select, textarea, input) {
font-family: inherit;
letter-spacing: 0;
}
.product-clone-page :is(button, select, textarea):focus-visible {
outline: 2px solid rgba(var(--ecm-accent-rgb), 0.48);
outline-offset: 2px;
}
.product-clone-page :is(button, select, textarea):disabled {
box-shadow: none;
}
.product-clone-page[data-tool="clone"] {
--clone-settings-panel-width: clamp(420px, 36vw, 540px);
background:
radial-gradient(circle at 72% 12%, rgba(var(--ecm-accent-rgb), 0.045), transparent 31%),
linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 180px),
var(--ecm-page);
}
.product-clone-page[data-tool="clone"] > .product-clone-shell,
.product-clone-page[data-tool="clone"] .clone-ai-preview {
background: transparent;
}
.product-clone-page[data-tool="clone"] .product-clone-panel {
border-right-color: var(--ecm-line);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 150px),
var(--ecm-panel);
}
.product-clone-page[data-tool="clone"] .clone-ai-panel {
gap: 12px;
padding: 18px;
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.035);
}
.product-clone-page[data-tool="clone"] .clone-ai-logo {
position: sticky;
top: 0;
z-index: 3;
margin: -18px -18px 2px;
padding: 16px 18px 14px;
border-bottom-color: var(--ecm-line);
background:
linear-gradient(180deg, rgba(21, 24, 26, 0.98), rgba(21, 24, 26, 0.9));
backdrop-filter: blur(16px);
}
.product-clone-page[data-tool="clone"] .clone-ai-logo__mark {
border-radius: 9px;
box-shadow: 0 0 0 1px rgba(var(--ecm-accent-rgb), 0.18), 0 10px 24px rgba(var(--ecm-accent-rgb), 0.14);
}
.product-clone-page[data-tool="clone"] .clone-ai-logo strong {
font-size: 15px;
line-height: 1.2;
}
.product-clone-page[data-tool="clone"] .clone-ai-logo em {
border-color: var(--ecm-line);
background: var(--ecm-inset);
color: var(--ecm-muted);
}
.product-clone-page[data-tool="clone"] :is(
.clone-ai-card,
.clone-ai-platform-spec,
.clone-ai-count-panel,
.clone-ai-replicate-panel,
.clone-ai-module-panel,
.clone-ai-model-panel,
.clone-ai-video-panel
) {
border-color: var(--ecm-line);
border-radius: var(--ecm-radius-md);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 58%),
var(--ecm-panel-strong);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
.product-clone-page[data-tool="clone"] .clone-ai-card {
padding: 13px;
}
.product-clone-page[data-tool="clone"] .clone-ai-card h2 {
margin-bottom: 10px;
color: var(--ecm-muted);
font-size: 11px;
line-height: 1.2;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone {
min-height: 140px;
border-color: rgba(255, 255, 255, 0.16);
border-radius: var(--ecm-radius-md);
background:
radial-gradient(circle at 50% 0%, rgba(var(--ecm-accent-rgb), 0.09), transparent 58%),
var(--ecm-inset);
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone:hover,
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone.is-dragging {
border-color: rgba(var(--ecm-accent-rgb), 0.55);
background:
radial-gradient(circle at 50% 0%, rgba(var(--ecm-accent-rgb), 0.14), transparent 60%),
rgba(var(--ecm-accent-rgb), 0.055);
transform: translateY(-1px);
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-icon {
background: rgba(var(--ecm-accent-rgb), 0.09);
color: var(--ecm-accent);
}
.product-clone-page[data-tool="clone"] :is(
.clone-ai-tag-group button,
.clone-ai-basic-select > button,
.clone-ai-basic-select__menu,
.clone-ai-basic-select__menu button,
.clone-ai-replicate-tabs button,
.clone-ai-replicate-upload,
.clone-ai-replicate-levels button,
.clone-ai-count-row,
.clone-ai-count-stepper button,
.clone-ai-module-list button,
.clone-ai-model-tabs button,
.clone-ai-model-scene-grid button,
.clone-ai-model-select,
.clone-ai-model-select > button,
.clone-ai-model-select__menu,
.clone-ai-model-select__menu button,
.clone-ai-video-options button,
.clone-ai-video-smart
) {
border-color: var(--ecm-line);
background: var(--ecm-inset);
color: var(--ecm-muted);
}
.product-clone-page[data-tool="clone"] :is(
.clone-ai-tag-group button:hover,
.clone-ai-basic-select > button:hover,
.clone-ai-basic-select > button.is-open,
.clone-ai-basic-select__menu button:hover,
.clone-ai-replicate-tabs button:hover,
.clone-ai-replicate-upload:hover,
.clone-ai-replicate-levels button:hover,
.clone-ai-count-stepper button:hover:not(:disabled),
.clone-ai-module-list button:hover,
.clone-ai-model-tabs button:hover,
.clone-ai-model-scene-grid button:hover,
.clone-ai-model-select > button:hover,
.clone-ai-model-select > button.is-open,
.clone-ai-model-select__menu button:hover,
.clone-ai-video-options button:hover,
.clone-ai-video-smart:hover
) {
border-color: var(--ecm-line-strong);
background: var(--ecm-inset-hover);
color: var(--ecm-text);
}
.product-clone-page[data-tool="clone"] :is(
.clone-ai-tag-group button.is-active,
.clone-ai-basic-select__menu button.is-active,
.clone-ai-replicate-tabs button.is-active,
.clone-ai-replicate-levels button.is-active,
.clone-ai-module-list button.is-active,
.clone-ai-model-tabs button.is-active,
.clone-ai-model-scene-grid button.is-active,
.clone-ai-model-select__menu button.is-active,
.clone-ai-video-options button.is-active,
.clone-ai-video-smart.is-on
) {
border-color: rgba(var(--ecm-accent-rgb), 0.48);
background:
linear-gradient(180deg, rgba(var(--ecm-accent-rgb), 0.16), rgba(var(--ecm-accent-rgb), 0.07));
color: var(--ecm-accent);
}
.product-clone-page[data-tool="clone"] :is(.clone-ai-generate, .clone-ai-send-button, .clone-ai-upload-zone strong) {
background: var(--ecm-accent);
color: var(--dg-button-text, #061014);
box-shadow: 0 10px 28px rgba(var(--ecm-accent-rgb), 0.18);
}
.product-clone-page[data-tool="clone"] :is(.clone-ai-generate:hover:not(:disabled), .clone-ai-send-button:hover:not(:disabled)) {
filter: brightness(1.03);
transform: translateY(-1px);
}
.product-clone-page[data-tool="clone"] :is(.clone-ai-generate:disabled, .clone-ai-send-button:disabled) {
border-color: var(--ecm-line);
background: var(--ecm-inset);
color: var(--ecm-soft);
filter: none;
}
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle {
border-color: var(--ecm-line-strong);
background: rgba(20, 23, 25, 0.86);
color: var(--ecm-muted);
box-shadow: var(--ecm-shadow-soft);
backdrop-filter: blur(14px);
}
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle:hover,
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle:focus-visible {
border-color: rgba(var(--ecm-accent-rgb), 0.5);
background: rgba(var(--ecm-accent-rgb), 0.09);
color: var(--ecm-accent);
}
.product-clone-page[data-tool="clone"] .clone-ai-preview {
align-content: center;
gap: 20px;
padding: 90px clamp(22px, 4vw, 46px) 134px;
background:
radial-gradient(circle at 50% 42%, rgba(var(--ecm-accent-rgb), 0.035), transparent 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.014), transparent 160px);
}
.product-clone-page[data-tool="clone"] .clone-ai-preview-header {
top: 28px;
right: clamp(22px, 4vw, 46px);
left: clamp(22px, 4vw, 46px);
max-width: none;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
}
.product-clone-page[data-tool="clone"] .clone-ai-preview-header strong {
font-size: clamp(18px, 1.6vw, 22px);
}
.product-clone-page[data-tool="clone"] .clone-ai-preview-header span {
max-width: 620px;
color: var(--ecm-muted);
font-size: 13px;
line-height: 1.55;
}
.product-clone-page[data-tool="clone"] .clone-ai-empty-state {
width: min(100%, 580px);
min-height: 260px;
padding: 28px;
border: 1px solid rgba(255, 255, 255, 0.055);
border-radius: 22px;
background: rgba(255, 255, 255, 0.014);
}
.product-clone-page[data-tool="clone"] .clone-ai-empty-state .anticon {
width: 84px;
height: 84px;
border-color: var(--ecm-line);
background:
radial-gradient(circle at 50% 16%, rgba(var(--ecm-accent-rgb), 0.12), transparent 62%),
var(--ecm-panel-strong);
color: rgba(var(--ecm-accent-rgb), 0.46);
}
.product-clone-page[data-tool="clone"] .clone-ai-preview-showcase {
gap: 18px;
}
.product-clone-page[data-tool="clone"] :is(.clone-ai-main-result, .clone-ai-result-grid button) {
border-color: var(--ecm-line);
background: var(--ecm-panel-strong);
box-shadow: var(--ecm-shadow-soft);
}
.product-clone-page[data-tool="clone"] :is(.clone-ai-main-result:hover, .clone-ai-result-grid button:hover) {
border-color: rgba(var(--ecm-accent-rgb), 0.38);
box-shadow: var(--ecm-shadow-panel);
}
.product-clone-page[data-tool="clone"] .clone-ai-bottom-input {
right: clamp(18px, 4vw, 46px);
bottom: 20px;
left: clamp(18px, 4vw, 46px);
}
.product-clone-page[data-tool="clone"] .clone-ai-input-wrapper {
border-color: var(--ecm-line);
border-radius: 18px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.018)),
rgba(20, 24, 23, 0.92);
box-shadow: var(--ecm-shadow-panel);
backdrop-filter: blur(18px);
}
.product-clone-page[data-tool="clone"] .clone-ai-input-wrapper:focus-within {
border-color: rgba(var(--ecm-accent-rgb), 0.42);
box-shadow:
0 20px 58px rgba(0, 0, 0, 0.34),
0 0 0 1px rgba(var(--ecm-accent-rgb), 0.08);
}
.product-clone-page[data-tool="clone"] .clone-ai-input-wrapper textarea {
color: var(--ecm-text);
}
.product-clone-page[data-tool="clone"] .clone-ai-char-count {
color: var(--ecm-soft);
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) {
background: var(--ecm-page);
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) .product-clone-panel,
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) .product-clone-rail {
border-color: var(--ecm-line);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 160px),
var(--ecm-panel);
}
.product-clone-page:is([data-tool="detail"], [data-tool="wear"]) .product-clone-rail button {
border-radius: var(--ecm-radius-sm);
color: var(--ecm-muted);
}
.product-clone-page:is([data-tool="detail"], [data-tool="wear"]) .product-clone-rail button:hover,
.product-clone-page:is([data-tool="detail"], [data-tool="wear"]) .product-clone-rail button.is-active {
background: rgba(var(--ecm-accent-rgb), 0.1);
color: var(--ecm-accent);
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
.product-clone-field,
.product-set-upload-section,
.product-set-settings-section,
.product-set-detail-section
) {
border-color: var(--ecm-line);
border-radius: var(--ecm-radius-lg);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.034), transparent 54%),
var(--ecm-panel-strong);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
.product-clone-field h2,
.product-set-upload-section h2,
.product-set-settings-section h2,
.product-set-detail-section h2
) {
color: var(--ecm-text);
font-size: 15px;
font-weight: 820;
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
select,
textarea,
.product-clone-upload-zone,
.product-set-upload,
.product-set-output-grid button,
.product-set-structure-grid button,
.product-detail-module-grid button,
.product-clone-scene-grid button,
.product-clone-ratio-row button,
.product-clone-segment button,
.product-clone-model-button,
.product-clone-switch-row,
.product-set-style-toggle
) {
border-color: var(--ecm-line);
border-radius: var(--ecm-radius-sm);
background: var(--ecm-inset);
color: var(--ecm-muted);
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
select:hover,
textarea:hover,
.product-clone-upload-zone:hover,
.product-set-upload:hover,
.product-set-output-grid button:hover,
.product-set-structure-grid button:hover,
.product-detail-module-grid button:hover,
.product-clone-scene-grid button:hover,
.product-clone-ratio-row button:hover,
.product-clone-segment button:hover,
.product-clone-model-button:hover,
.product-clone-switch-row:hover,
.product-set-style-toggle:hover
) {
border-color: var(--ecm-line-strong);
background: var(--ecm-inset-hover);
color: var(--ecm-text);
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
.product-set-output-grid button.is-active,
.product-set-structure-grid button.is-active,
.product-detail-module-grid button.is-active,
.product-clone-scene-grid button.is-active,
.product-clone-ratio-row button.is-active,
.product-clone-segment button.is-active,
.product-set-style-toggle.is-active
) {
border-color: rgba(var(--ecm-accent-rgb), 0.48);
background: rgba(var(--ecm-accent-rgb), 0.12);
color: var(--ecm-accent);
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(.product-clone-primary, .product-set-floating-submit) {
border: 0;
background: var(--ecm-accent);
color: var(--dg-button-text, #061014);
box-shadow: 0 12px 30px rgba(var(--ecm-accent-rgb), 0.18);
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(.product-clone-primary, .product-set-floating-submit):disabled {
background: var(--ecm-inset);
color: var(--ecm-soft);
box-shadow: none;
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) .product-clone-preview {
background:
radial-gradient(circle at 50% 40%, rgba(var(--ecm-accent-rgb), 0.032), transparent 40%),
transparent;
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
.product-set-empty-preview,
.product-clone-empty-panel
) {
border-color: rgba(255, 255, 255, 0.055);
border-radius: 22px;
background: rgba(255, 255, 255, 0.014);
}
.product-clone-page[data-tool="set"] .product-set-floating-detail {
border-color: var(--ecm-line);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.018)),
rgba(20, 24, 23, 0.92);
box-shadow: var(--ecm-shadow-panel);
backdrop-filter: blur(18px);
}
.ecommerce-progress-bar {
border-color: rgba(var(--ecm-accent-rgb), 0.22);
background: rgba(var(--ecm-accent-rgb), 0.07);
}
.ecommerce-progress-bar__fill {
box-shadow: 0 0 18px rgba(var(--ecm-accent-rgb), 0.36);
}
@media (max-width: 1180px) {
.product-clone-page[data-tool="clone"] {
--clone-settings-panel-width: clamp(390px, 45vw, 440px);
}
.product-clone-page[data-tool="clone"] .clone-ai-preview {
padding-right: 28px;
padding-left: 28px;
}
}
@media (max-width: 860px) {
.product-clone-page[data-tool="clone"] {
height: auto;
min-height: 100%;
overflow: auto;
}
.product-clone-page[data-tool="clone"] > .product-clone-shell,
.product-clone-page[data-tool="clone"].is-settings-collapsed > .product-clone-shell {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(620px, 1fr);
height: auto;
min-height: 100%;
}
.product-clone-page[data-tool="clone"].is-settings-collapsed > .product-clone-shell {
grid-template-rows: 0 minmax(620px, 1fr);
}
.product-clone-page[data-tool="clone"] .product-clone-panel {
border-right: 0;
border-bottom: 1px solid var(--ecm-line);
}
.product-clone-page[data-tool="clone"] .clone-ai-panel {
width: 100%;
max-width: none;
max-height: none;
}
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle,
.product-clone-page[data-tool="clone"].is-settings-collapsed .clone-ai-settings-toggle {
top: 14px;
right: 14px;
left: auto;
transform: none;
}
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle:active {
transform: scale(0.94);
}
.product-clone-page[data-tool="clone"] .clone-ai-preview {
min-height: 620px;
padding: 92px 18px 134px;
}
.product-clone-page[data-tool="clone"] .clone-ai-preview-header {
top: 24px;
right: 18px;
left: 18px;
}
.product-clone-page[data-tool="clone"] .clone-ai-preview-showcase {
grid-template-columns: 1fr;
width: min(100%, 520px);
}
.product-clone-page[data-tool="clone"] .clone-ai-flow-arrow {
display: none;
}
.product-clone-page[data-tool="clone"] .clone-ai-main-result {
height: 260px;
}
}
@media (max-width: 620px) {
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) .product-clone-panel__scroll,
.product-clone-page[data-tool="clone"] .clone-ai-panel {
padding: 14px;
}
.product-clone-page[data-tool="clone"] .clone-ai-logo {
margin: -14px -14px 0;
padding: 14px 54px 12px 14px;
}
.product-clone-page[data-tool="clone"] .clone-ai-card,
.product-clone-page[data-tool="clone"] :is(.clone-ai-platform-spec, .clone-ai-count-panel, .clone-ai-replicate-panel, .clone-ai-module-panel, .clone-ai-model-panel, .clone-ai-video-panel) {
padding: 11px;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone {
min-height: 120px;
}
.product-clone-page[data-tool="clone"] .clone-ai-tag-group,
.product-clone-page[data-tool="clone"] .clone-ai-basic-select-grid,
.product-clone-page[data-tool="clone"] .clone-ai-model-select-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.product-clone-page[data-tool="clone"] .clone-ai-preview {
min-height: 560px;
padding: 86px 12px 128px;
}
.product-clone-page[data-tool="clone"] .clone-ai-empty-state {
min-height: 220px;
padding: 22px 16px;
}
.product-clone-page[data-tool="clone"] .clone-ai-bottom-input {
right: 10px;
bottom: 12px;
left: 10px;
}
.product-clone-page[data-tool="clone"] .clone-ai-input-wrapper {
grid-template-columns: minmax(0, 1fr) 36px;
gap: 8px;
padding: 10px;
border-radius: 16px;
}
.product-clone-page[data-tool="clone"] .clone-ai-send-button {
width: 36px;
height: 36px;
}
}
@media (max-width: 900px) {
.product-clone-page {
box-sizing: border-box;
padding-top: 58px;
}
.product-clone-page > .product-clone-shell {
min-height: calc(100% - 58px);
}
}
@media (max-width: 480px) {
.product-clone-page {
padding-top: 56px;
}
.product-clone-page > .product-clone-shell {
min-height: calc(100% - 56px);
}
}
/* Ecommerce refinement pass: make the preview state more informative and selected controls quieter. */
.product-clone-page[data-tool="clone"] .clone-ai-preview {
padding-top: 138px;
}
.product-clone-page[data-tool="clone"] .clone-ai-preview-header {
gap: 9px;
}
.product-clone-page[data-tool="clone"] .clone-ai-preview-summary {
display: flex;
flex-wrap: wrap;
gap: 7px;
max-width: min(100%, 720px);
}
.product-clone-page[data-tool="clone"] .clone-ai-preview-summary span {
display: inline-flex;
align-items: center;
max-width: 180px;
min-height: 26px;
padding: 0 10px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.095);
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
color: rgba(232, 240, 235, 0.68);
font-size: 11px;
font-weight: 780;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-clone-page[data-tool="clone"] .clone-ai-preview-summary span:first-child {
border-color: rgba(var(--ecm-accent-rgb), 0.28);
background: rgba(var(--ecm-accent-rgb), 0.08);
color: var(--ecm-accent);
}
.product-clone-page[data-tool="clone"] :is(
.clone-ai-module-list button,
.clone-ai-model-scene-grid button,
.clone-ai-replicate-levels button,
.clone-ai-video-options button
),
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
.product-set-structure-grid button,
.product-detail-module-grid button,
.product-clone-scene-grid button,
.product-clone-ratio-row button
) {
position: relative;
overflow: hidden;
}
.product-clone-page[data-tool="clone"] :is(
.clone-ai-module-list button.is-active,
.clone-ai-model-scene-grid button.is-active,
.clone-ai-replicate-levels button.is-active,
.clone-ai-video-options button.is-active
),
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
.product-set-structure-grid button.is-active,
.product-detail-module-grid button.is-active,
.product-clone-scene-grid button.is-active,
.product-clone-ratio-row button.is-active
) {
border-color: rgba(var(--ecm-accent-rgb), 0.5);
background:
linear-gradient(90deg, rgba(var(--ecm-accent-rgb), 0.13), rgba(var(--ecm-accent-rgb), 0.035)),
rgba(255, 255, 255, 0.035);
color: var(--ecm-text);
}
.product-clone-page[data-tool="clone"] :is(
.clone-ai-module-list button.is-active,
.clone-ai-model-scene-grid button.is-active,
.clone-ai-replicate-levels button.is-active,
.clone-ai-video-options button.is-active
)::before,
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
.product-set-structure-grid button.is-active,
.product-detail-module-grid button.is-active,
.product-clone-scene-grid button.is-active,
.product-clone-ratio-row button.is-active
)::before {
position: absolute;
inset: 8px auto 8px 0;
width: 3px;
border-radius: 999px;
background: var(--ecm-accent);
content: "";
}
.product-clone-page[data-tool="clone"] .clone-ai-module-list button.is-active span,
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
.product-set-structure-grid button.is-active em,
.product-detail-module-grid button.is-active span
) {
color: rgba(232, 240, 235, 0.62);
}
@media (max-width: 860px) {
.product-clone-page[data-tool="clone"] .clone-ai-preview {
padding-top: 148px;
}
}
@media (max-width: 620px) {
.product-clone-page[data-tool="clone"] .clone-ai-preview {
padding-top: 158px;
}
.product-clone-page[data-tool="clone"] .clone-ai-preview-summary {
gap: 6px;
}
.product-clone-page[data-tool="clone"] .clone-ai-preview-summary span {
max-width: 138px;
min-height: 24px;
padding: 0 8px;
font-size: 10px;
}
}
+62 -16
View File
@@ -148,37 +148,83 @@
min-width: 0;
min-height: 72px;
padding: 0 28px;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--bg-inset);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
background: linear-gradient(180deg, rgba(20, 23, 26, 0.72) 0%, rgba(15, 17, 19, 0.84) 100%);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 2px 8px rgba(0, 0, 0, 0.28);
color: var(--fg-body);
cursor: pointer;
font-size: 17px;
font-weight: 850;
transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.03em;
transition:
border-color 240ms ease,
background 240ms ease,
color 240ms ease,
transform 240ms cubic-bezier(0.34, 1.2, 0.64, 1),
box-shadow 240ms ease;
}
.omni-home__entry .anticon {
font-size: 18px;
font-size: 19px;
transition: color 240ms ease, transform 240ms ease;
}
.omni-home__entry:hover {
border-color: var(--border-default);
background: var(--bg-hover);
border-color: rgba(255, 255, 255, 0.16);
background: linear-gradient(180deg, rgba(28, 32, 36, 0.78) 0%, rgba(18, 22, 25, 0.88) 100%);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.06) inset,
0 0 24px rgba(var(--accent-rgb), 0.06),
0 4px 16px rgba(0, 0, 0, 0.36);
color: #ffffff;
transform: translateY(-1px);
transform: translateY(-2px);
}
.omni-home__entry:hover .anticon {
color: var(--accent);
transform: scale(1.08);
}
.omni-home__entry:active {
transform: translateY(0) scale(0.97);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.02) inset,
0 1px 4px rgba(0, 0, 0, 0.32);
transition-duration: 80ms;
}
.omni-home__entry--primary {
border-color: var(--accent);
background: var(--accent);
color: var(--dg-button-text, #061014);
border-color: rgba(var(--accent-rgb), 0.48);
background: linear-gradient(180deg, rgba(0, 255, 136, 0.22) 0%, rgba(0, 220, 118, 0.14) 100%), var(--accent);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.12) inset,
0 0 28px rgba(var(--accent-rgb), 0.18),
0 2px 12px rgba(0, 0, 0, 0.28);
color: #061014;
}
.omni-home__entry--primary:hover {
border-color: var(--accent-hover, var(--accent));
background: var(--accent-hover, var(--accent));
color: var(--dg-button-text, #061014);
border-color: rgba(var(--accent-rgb), 0.64);
background: linear-gradient(180deg, rgba(0, 255, 136, 0.28) 0%, rgba(0, 230, 124, 0.18) 100%), var(--accent-hover);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.16) inset,
0 0 40px rgba(var(--accent-rgb), 0.28),
0 6px 24px rgba(0, 0, 0, 0.36);
color: #061014;
}
.omni-home__entry--primary .anticon {
color: #061014;
}
.omni-home__entry--primary:hover .anticon {
color: #061014;
transform: scale(1.12);
}
.omni-home__carousel {
+42
View File
@@ -520,6 +520,48 @@
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 */
.script-eval-v5-hero {
margin-bottom: 18px;
+8 -1
View File
@@ -3400,6 +3400,7 @@
width: 100%;
height: 100%;
min-height: 520px;
max-height: 520px;
padding: 18px 22px;
border: none;
outline: none;
@@ -3409,6 +3410,7 @@
font-size: 14px;
line-height: 1.9;
resize: none;
overflow-y: auto;
}
.script-eval-v4-text-input::placeholder {
@@ -4268,6 +4270,11 @@
.script-eval-v4-text-shell,
.script-eval-v4-text-input {
min-height: calc(100vh - 422px);
max-height: calc(100vh - 422px);
}
.script-eval-v4-text-input {
overflow-y: auto;
}
.script-eval-v4-score-card {
@@ -4883,7 +4890,7 @@
}
.management-status-trend {
padding: 6px 14px 10px;
padding: 12px 18px 16px;
border-top: 1px solid var(--border-weak);
}
+263
View File
@@ -563,3 +563,266 @@
overflow: auto;
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",
changeOrigin: true,
},
"/dashscope-api": {
target: "https://dashscope.aliyuncs.com",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/dashscope-api/, "/compatible-mode/v1"),
},
},
},
preview: {