Compare commits

..

24 Commits

Author SHA1 Message Date
ludan 2b65206b84 feat: 电商克隆上传交互升级、视频模型选择器图标
【电商克隆 - 商品图上传交互重构】
- 新增上传预览大图区(clone-ai-upload-preview-wrap),点击缩略图可切换预览
- 选中缩略图增加 is-active 绿色边框高亮
- 预览区显示商品图编号 + 尺寸/比例/格式信息(formatProductImageSpec)
- 上传区到达 7 张上限时显示"已达上限"、阻止拖拽上传、输入框禁用
- 上传图片自动异步读取尺寸(width/height),无需等待上传完成即可展示
- 已上传素材区重构为列表头(标题+计数)+ 缩略图栈式布局
- 缩略图增加序号角标(1-7),删除按钮独立于缩略图下方
- selectedProductImageId 状态自动管理:删除/新增时自动切换到有效图片

【工作台 - 视频模型选择器图标】
- 新增 VIDEO_MODEL_ICON_URLS 映射(HappyHorse/Pixverse/Vidu/Wan/Kling)
- SelectChip 组件在 chipId=video-model 时显示模型品牌图标
- getVideoModelIconUrl 支持中英文模糊匹配

【样式】
- ecommerce.css: 预览区/素材栈/缩略图选中态/上限态完整样式
- dark-green.css: 主题层微调
2026-06-04 17:27:40 +08:00
ludan fb4011bf1f feat: 个人中心视觉重构、画布网点背景、剧本评分色调统一
【个人中心视觉重构】
- 列表卡片新增媒体预览缩略图(图片/视频/项目/资产),支持 image/video 两种媒体类型
- 新增 renderCardPreview 通用预览组件,自动识别视频格式并渲染 <video> 标签
- 新增 formatAssetType 工具函数,资产类型中文化(角色/场景/道具/视频/图像/素材)
- 媒体卡片采用固定高度网格布局(标题行 18px/正文 36px/元信息 18px),保证列表节奏一致
- 卡片预览区左上角显示类型标签徽章(品牌绿边框+半透明背景)
- 删除按钮增加 hover 红色反馈(边框/背景/文字渐变至红色)
- 积分/任务面板从底部区域移至侧边栏头像下方,减少滚动距离
- 新增 account-card 容器包裹积分/任务切换面板
- 侧边栏统计数据改为 3 列网格布局,每项增加独立圆角卡片样式
- 作品/项目/资产/社区发布四个 Tab 改为均分 4 列网格
- 分区标题增加品牌绿圆点前缀装饰
- 响应式断点:960px(侧边栏双列+内容区单列)、640px(全部单列+标签横向滚动)、420px(紧凑间距)

【画布网点背景】
- 移除 ReactFlow <Background> 组件,改用纯 CSS radial-gradient 圆点背景
- 通过 CSS 自定义属性(--canvas-bg-size/--canvas-bg-dot/--canvas-bg-x/--canvas-bg-y)实现缩放/平移时网点同步
- 网点颜色使用半透明灰蓝(rgba(148,163,184,0.34)),随画布缩放动态调整点间距与大小

【剧本评分色调统一】
- 变量 Token 体系重定义为电商同款暗色面板色调(--v5-bg: #0d0d0f, --v5-panel: #151719)
- 移除所有 box-shadow 和 depth 阴影,改用 inset 顶部光泽线
- 移除 backdrop-filter 毛玻璃效果,统一为纯色半透明背景
- hover 交互简化为边框+背景色变化,取消 transform 浮起动画
- 上传区移除 ::after 径向光晕伪元素
- 已上传态/选中态仅通过 border-color 和背景色微调区分
2026-06-04 13:16:38 +08:00
ludan b08a7918da feat: 剧本评分左侧面板滚动优化、电商克隆移动端适配、视觉细节精修
【剧本评分左侧面板滚动重构】
- 新增 script-eval-v5-left-main 滚动容器,上传区/AI信息/历史记录统一在容器内滚动
- 底部操作按钮(开始评测/导出报告)独立于滚动区外,始终可见可点击
- 历史评测列表增加 max-height 限制,超出区域内置滚动条
- 自定义窄滚动条(品牌绿半透明 thumb),保持视觉干净
- 短视口(≤760px/820px)压缩上传区和历史列表最小高度

【剧本评分视觉精修】
- 左侧面板增加渐变背景层次与分区微光分割线
- 上传区增加 ::after 伪元素径向光晕,hover 时品牌绿边框增强
- 已上传状态上传区增加绿色边框高亮(is-ready/is-complete)
- 底部操作栏背景层次加深,导出按钮 hover 增加绿色反馈
- 右侧面板增加底部径向渐变,上传引导卡标题提亮
- 顶部状态栏背景加深,模糊效果增强

【电商克隆移动端适配增强】
- 900px/620px/480px 三级断点增加顶部预留空间,避免与导航重叠
- Logo 区域定位从 sticky 改为 static,避免滚动时遮挡内容
- 设置面板在窄屏下调整内边距与边距

【Token 用量页精简】
- 移除指标卡片序号角标,保持卡片视觉简洁
2026-06-04 09:40:28 +08:00
ludan 7be4e65e1e feat: 管理中心Token用量页面SaaS商业化精修
【页面状态感知】
- 新增页面级状态类名(is-syncing/has-sync-error/has-low-balance/is-healthy)
- 状态指示灯根据加载/错误/正常动态切换样式与动画
- 加载中禁用刷新按钮,防止重复请求

【工具栏视觉升级】
- 标题增加副标题("用量、成员与模型调用监控")
- 状态胶囊 pill 增加圆点动画(绿色脉冲=在线,琥珀色=异常)
- 按钮增加 hover 品牌绿高亮、禁用态半透明
- 成员管理按钮增加 is-muted-action 弱化样式

【指标卡片精修】
- 四张指标卡片增加序号角标(01-04),左侧彩色竖线标识
- 主卡片(可用额度)增加品牌绿光晕背景 + 绿色竖线
- 卡片增加渐变背景、顶部光泽内阴影、圆角阴影

【数据展示增强】
- 模型消耗分布/系统状态/调用记录标题增加图标
- 模型消耗列表项增加 hover 浮起 + 品牌绿边框
- 调用记录表格增加表头背景、行 hover 高亮、状态标签徽章
- 分页按钮增加 hover 品牌绿填充

【响应式适配】
- 1180px/900px/560px 三级断点完整适配
- 900px:工具栏吸顶、指标卡单列、图表区单列
- 560px:工具栏按钮 2 列网格、状态胶囊全宽、卡片间距缩小
2026-06-03 18:52:14 +08:00
ludan 73a6043310 feat: 剧本评分SaaS化精修、画布视觉升级、电商克隆预览响应式修复
【剧本评分页面 SaaS 商业化精修】
- 上传区增加玻璃拟态渐变边框,hover 时高亮为品牌绿
- 已上传文件显示文件名+文件大小,重新上传按钮优化
- 上传按钮文案从"+ 上传剧本"改为"选择剧本",增加图标
- 评测按钮增加 LoadingOutlined/ThunderboltOutlined 图标动画
- 评测等待态增加分步加载提示(结构识别/冲突评估/商业潜力)
- 六维评分柱状图增加 hover/focus 交互:悬停维度高亮,其余维度 dim,底部显示当前维度详细说明
- 评分卡片、报告面板、历史记录项增加渐变背景与阴影层次
- 新增 script-tokens-v5 设计 Token 变量体系
- 响应式断点适配 1180px/900px/680px,移动端左右面板上下堆叠

【画布页面视觉升级】
- 画布背景增加网点纹理 + 径向渐变,增强空间感
- 项目栏/缩放控件增加玻璃拟态毛玻璃效果
- 节点卡片增加渐变背景、内阴影、边框高亮
- 选中节点增加品牌绿外圈光环 + 投影
- 连线 connector 增加 hover 品牌绿高亮
- 节点连线/选区框/缩放手柄统一品牌绿主题色
- 所有编辑器/菜单面板统一玻璃拟态风格
- 移动端 560px 项目栏改为 4 列网格布局,按钮显示中文标签(编辑/最近/导出/提交)

【电商克隆预览响应式修复】
- 短视口(高度≤760px)下预览面板内 header/空状态/底部输入改为静态流式布局
- 平板(≤860px)下预览面板取消绝对定位,改为 grid 流式布局
- 手机(≤620px)缩小间距与最小高度

【其他】
- 个人中心登录注册表单移除 form-kicker 标签
- 品牌色(auth-page__brand)调整为强调绿色
2026-06-03 18:21:10 +08:00
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
45 changed files with 9071 additions and 781 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>
);
+34 -20
View File
@@ -26,7 +26,6 @@
VideoCameraOutlined,
} from "@ant-design/icons";
import {
Background,
ReactFlow,
} from "@xyflow/react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
@@ -48,6 +47,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 +310,7 @@ function getCameraMotionPrompt(value: string): string {
}
function CanvasPage({
workflow,
workflow: rawWorkflow,
projectId,
projects = [],
projectsLoaded = true,
@@ -323,6 +323,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 +405,7 @@ function CanvasPage({
textGenerationState, imageGenerationState, videoGenerationState,
generationToast, setGenerationToast,
imageGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
canvasGenKeepaliveRestoredRef,
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
restoreKeepaliveTasks, resetGenerationState,
} = useCanvasGeneration({ setImageNodes, setVideoNodes });
@@ -524,6 +526,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 +574,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,18 +3530,19 @@ 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-size": `${34 * canvasViewport.zoom}px`,
"--canvas-bg-dot": `${1.35 * canvasViewport.zoom}px`,
"--canvas-bg-x": `${canvasViewport.x}px`,
"--canvas-bg-y": `${canvasViewport.y}px`,
cursor: canvasPanDrag ? "grabbing" : spacePanning ? "grab" : undefined,
@@ -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,11 +3725,9 @@ function CanvasPage({
zoomOnPinch={false}
zoomOnScroll={false}
proOptions={{ hideAttribution: true }}
onPaneClick={shouldShowEmptyProjectState ? undefined : handlePaneClick}
onPaneContextMenu={shouldShowEmptyProjectState ? undefined : handlePaneContextMenu}
>
<Background gap={24} color="transparent" className="studio-canvas__background" />
</ReactFlow>
onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick}
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
/>
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
<button type="button" title="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}>
@@ -3731,7 +3736,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,6 +3747,13 @@ function CanvasPage({
event.stopPropagation();
}}
>
{isWaitingForProjects ? (
<>
<div className="studio-canvas-loading-spinner" />
<strong></strong>
</>
) : (
<>
<strong></strong>
<button
type="button"
@@ -3757,6 +3769,8 @@ function CanvasPage({
>
</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 });
}
},
+367 -46
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,
@@ -521,6 +523,12 @@ const formatUploadedImageRatio = (image?: CloneImageItem) => {
if (!image.width || !image.height) return `上传图片\u00a0\u00a0\u00a0原图比例${format}`;
return `上传图片 ${image.width}×${image.height}px\u00a0\u00a0\u00a0${formatAspectRatio(image.width, image.height)}${format}`;
};
const formatProductImageSpec = (image?: CloneImageItem | null) => {
if (!image) return "等待上传";
const format = image.format ? ` · ${image.format}` : "";
if (!image.width || !image.height) return `正在识别尺寸${format}`;
return `${image.width}×${image.height}px · ${formatAspectRatio(image.width, image.height)}${format}`;
};
const defaultMarketLanguageOption = marketLanguageOptions[0]!;
const normalizeMarket = (value: string) =>
marketLanguageOptions.some((option) => option.country === value) ? value : defaultMarketLanguageOption.country;
@@ -771,10 +779,12 @@ 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);
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
const [selectedProductImageId, setSelectedProductImageId] = useState<string | null>(null);
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>("detail");
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
@@ -819,6 +829,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 +851,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);
@@ -857,6 +869,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const selectedProductSetOutput =
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
const selectedProductImage = productImages.find((image) => image.id === selectedProductImageId) ?? productImages[0] ?? null;
const selectedProductImageIndex = selectedProductImage
? productImages.findIndex((image) => image.id === selectedProductImage.id)
: -1;
const selectedProductImageLabel = selectedProductImageIndex >= 0 ? `商品图 ${selectedProductImageIndex + 1}` : "商品图";
const selectedProductImageSpec = formatProductImageSpec(selectedProductImage);
const isProductImageLimitReached = productImages.length >= maxCloneProductImages;
const productSetPreviewReady = productSetStatus === "done";
const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0);
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
@@ -885,6 +904,30 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
};
useEffect(() => {
if (!productImages.length) {
if (selectedProductImageId !== null) setSelectedProductImageId(null);
return;
}
if (!selectedProductImageId || !productImages.some((image) => image.id === selectedProductImageId)) {
setSelectedProductImageId(productImages[0].id);
}
}, [productImages, selectedProductImageId]);
useEffect(() => {
productImages
.filter((item) => !item.width || !item.height)
.forEach((item) => {
readImageDimensions(item.src)
.then(({ width, height }) => {
setProductImages((current) =>
current.map((currentItem) => (currentItem.id === item.id ? { ...currentItem, width, height } : currentItem)),
);
})
.catch(() => undefined);
});
}, [productImages]);
const addSetImages = (files: File[]) => {
if (setImages.length >= 3) return;
const imageFiles = files.filter((file) => file.type.startsWith("image/"));
@@ -940,6 +983,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const handleProductDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsProductUploadDragging(false);
if (isProductImageLimitReached) return;
const files = Array.from(event.dataTransfer.files);
if (files.length) addProductImages(files);
};
@@ -1339,6 +1383,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 +1758,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 +1814,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 +1835,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 = () => {
@@ -1612,6 +1854,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setSelectedProductSetPreview(null);
setShowHostingModal(false);
setProductImages([]);
setSelectedProductImageId(null);
setIsProductUploadDragging(false);
setCloneOutput("detail");
setRatio((current) => normalizeRatioForPlatform(platform, current, "detail"));
@@ -1667,10 +1910,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;
@@ -1833,18 +2101,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</h2>
<div
role="button"
tabIndex={0}
className={`clone-ai-upload-zone${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => productInputRef.current?.click()}
tabIndex={isProductImageLimitReached ? -1 : 0}
aria-disabled={isProductImageLimitReached}
className={`clone-ai-upload-zone${isProductUploadDragging ? " is-dragging" : ""}${isProductImageLimitReached ? " is-full" : ""}`}
onClick={() => {
if (isProductImageLimitReached) return;
productInputRef.current?.click();
}}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (isProductImageLimitReached) return;
productInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
if (isProductImageLimitReached) return;
setIsProductUploadDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
@@ -1857,35 +2131,68 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</span>
<span className="clone-ai-upload-title"></span>
<strong>
{isProductImageLimitReached ? (
"已达上限"
) : (
<>
<span aria-hidden="true">+</span>
</>
)}
</strong>
<span className="clone-ai-upload-hint"> 7 </span>
</div>
{productImages.length ? (
<div className="clone-ai-uploaded-files" aria-label="已上传商品原图">
{productImages.map((item) => (
<figure key={item.id} className="clone-ai-uploaded-file">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
<div className="clone-ai-upload-preview-wrap" onClick={(event) => event.stopPropagation()} aria-live="polite">
<div className="clone-ai-upload-preview">
<img src={selectedProductImage?.src ?? productImages[0].src} alt={`当前预览:${selectedProductImageLabel}`} />
</div>
<div className="clone-ai-upload-preview__meta">
<span>
<b>{selectedProductImageLabel}</b>
<em title={selectedProductImage?.name ?? productImages[0].name}>{selectedProductImageSpec}</em>
</span>
</div>
</div>
) : null}
{productImages.length ? (
<div className="clone-ai-uploaded-stack">
<div className="clone-ai-uploaded-head">
<span></span>
<b>{productImages.length}/{maxCloneProductImages}</b>
</div>
<div className="clone-ai-uploaded-files" aria-label="已上传商品原图">
{productImages.map((item, index) => (
<figure key={item.id} className={`clone-ai-uploaded-file${item.id === selectedProductImage?.id ? " is-active" : ""}`}>
<button
type="button"
className="clone-ai-uploaded-file__thumb"
onClick={(event) => {
event.stopPropagation();
setSelectedProductImageId(item.id);
}}
aria-label={`预览商品图 ${index + 1}`}
>
<img src={item.src} alt={`商品图 ${index + 1}`} />
<span>{index + 1}</span>
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
removeProductImage(item.id);
}}
aria-label={`删除${item.name}`}
aria-label={`删除商品图 ${index + 1}`}
>
<CloseOutlined />
</button>
</figure>
))}
</div>
</div>
) : null}
</div>
<input ref={productInputRef} type="file" accept="image/*" multiple onChange={handleProductUpload} />
<input ref={productInputRef} type="file" accept="image/*" multiple disabled={isProductImageLimitReached} onChange={handleProductUpload} />
</section>
<section className="clone-ai-card">
@@ -2589,14 +2896,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 +2952,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) => (
{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 +3047,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 +3172,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}
+387 -135
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,119 +185,201 @@ 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 () => {
// ── Phase 2: Image generation per scene ──────────────────────
const handleGenerateImages = async () => {
if (!planResult || !scenes.length) return;
const imageUrl = productImageDataUrls[0] || "";
setStage("rendering");
setError(null);
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;
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
try {
await renderSceneImage(
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
{
onSceneImageSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)),
onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneImageCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)),
onSceneImageFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)),
},
renderAbortRef.current,
);
} catch (err) {
const message = err instanceof Error ? err.message : "图片生成失败";
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "idle", error: message } : s));
}
}
const allHaveImages = currentScenes.every((s) => s.imageUrl);
const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const;
setStage(finalStage);
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
};
// ── Phase 3: Video rendering from generated images ──────────
const handleRenderVideos = async () => {
if (!scenes.length) return;
const firstImage = scenes[0]?.imageUrl;
if (!firstImage) { setError("请先生成分镜图片"); return; }
setStage("rendering"); setError(null);
renderAbortRef.current = { current: false };
const quality = mapResolutionToQuality(resolution);
for (const scene of scenes) {
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;
setScenes((prev) => prev.map((s) =>
s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
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, aspectRatio, resolution: quality },
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality },
{
onSceneSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
onSceneFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
},
renderAbortRef.current,
);
} catch (err) {
const msg = err instanceof Error ? err.message : "生成失败";
const isPayment = err instanceof ServerRequestError && err.status === 402;
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: isPayment ? "余额不足,请充值后继续" : msg } : s));
if (isPayment) { setError("余额不足,请充值后再生成视频"); renderAbortRef.current.current = true; break; }
}
}
const hasFailed = currentScenes.some((s) => s.status === "failed");
const allDone = currentScenes.every((s) => s.status === "completed" || s.status === "failed");
const finalStage = allDone ? (hasFailed ? "partial_failed" as const : "completed" as const) : "rendering" as const;
setScenes(currentScenes);
setStage(finalStage);
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
};
const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); };
const handleRetryScene = async (scene: EcommerceVideoSceneTask) => {
if (!scene.imageUrl) return;
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
try {
await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, aspectRatio, resolution: mapResolutionToQuality(resolution) },
{
onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)),
@@ -192,37 +389,25 @@ export default function EcommerceVideoWorkspace({
renderAbortRef.current,
);
} catch (err) {
setScenes((prev) => prev.map((s) =>
s.sceneId === scene.sceneId ? { ...s, status: "failed", error: err instanceof Error ? err.message : "生成失败" } : s));
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: (err as 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 handleCancel = () => {
abortControllerRef.current?.abort();
renderAbortRef.current.current = true;
setStage("cancelled");
};
// ── 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 ? (
{/* 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>
) : null}
{sourceImage && completedScenes.length > 0 ? (
{/* Connector: source → plan text nodes */}
{visiblePlanSteps.length > 0 ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : 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" />
{/* 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>
{index < completedScenes.length - 1 ? (
{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="成片节点,已完成">
{/* 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">
<video src={primaryVideo} muted playsInline loop autoPlay />
{imgReady ? <img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
: imgRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
: <div className="ecom-video-flow-node__placeholder"></div>}
</div>
{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: "即将上线" },
];
+257 -137
View File
@@ -1,16 +1,21 @@
import {
CameraOutlined,
CheckOutlined,
CheckCircleFilled,
CloseOutlined,
DeleteOutlined,
EditOutlined,
FileImageOutlined,
FolderOpenOutlined,
LockOutlined,
MailOutlined,
MobileOutlined,
PhoneOutlined,
PlayCircleOutlined,
PlusOutlined,
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 +42,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 +140,61 @@ 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 formatAssetType(type: SavedAssetItem["type"]): string {
const labels: Record<string, string> = {
character: "角色",
scene: "场景",
prop: "道具",
video: "视频",
image: "图像",
asset: "资产",
other: "素材",
};
return labels[type] || "素材";
}
function ProfilePage({
session,
usage,
@@ -170,8 +230,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 +240,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 +251,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 +312,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 +374,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 +512,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 />
@@ -507,20 +543,48 @@ function ProfilePage({
</div>
);
const renderCardPreview = (
url: string | null | undefined,
type: "image" | "video" | "project" | "asset",
label: string,
) => {
const mediaUrl = typeof url === "string" ? url.trim() : "";
const isVideoPreview = type === "video" || /\.(mp4|webm|mov)(\?|#|$)/i.test(mediaUrl);
const placeholderIcon =
type === "video" ? <PlayCircleOutlined /> : type === "project" ? <FolderOpenOutlined /> : <FileImageOutlined />;
return (
<div className={`profile-page__list-card-preview${mediaUrl ? " has-media" : ""}`} aria-hidden="true">
{mediaUrl ? (
isVideoPreview ? (
<video src={mediaUrl} muted playsInline preload="metadata" />
) : (
<img src={mediaUrl} alt="" loading="lazy" />
)
) : (
<span className="profile-page__list-card-placeholder">{placeholderIcon}</span>
)}
<span className="profile-page__media-badge">{label}</span>
</div>
);
};
const renderActivePanel = () => {
if (activePanel === "works") {
return visibleWorks.length ? (
<div className="profile-page__list-grid motion-stagger">
{visibleWorks.map((task) => (
<article key={task.id} className="profile-page__list-card">
<article key={task.id} className="profile-page__list-card profile-page__media-card">
{renderCardPreview(task.outputUrl, task.type === "video" ? "video" : "image", formatTaskType(task.type))}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head">
<strong>{task.title}</strong>
<span>{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>
</div>
</article>
))}
@@ -534,10 +598,11 @@ function ProfilePage({
return projects.length ? (
<div className="profile-page__list-grid motion-stagger">
{projects.map((project) => (
<article key={project.id} className="profile-page__list-card">
<article key={project.id} className="profile-page__list-card profile-page__media-card">
{renderCardPreview(project.thumbnailUrl, "project", "项目")}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head">
<strong>{project.name}</strong>
<span>{project.updatedAt}</span>
{onDeleteProject ? (
<button
type="button"
@@ -552,7 +617,8 @@ function ProfilePage({
<p>{project.description || "最近更新的项目"}</p>
<div className="profile-page__list-card-meta">
<span>{project.storyboardCount} </span>
<span>{project.imageCount} / {project.videoCount} </span>
<span>{formatProfileDate(project.updatedAt)}</span>
</div>
</div>
</article>
))}
@@ -566,15 +632,18 @@ function ProfilePage({
return savedAssets.length ? (
<div className="profile-page__list-grid">
{savedAssets.map((asset) => (
<article key={asset.id} className="profile-page__list-card">
<article key={asset.id} className="profile-page__list-card profile-page__media-card">
{renderCardPreview(asset.imageUrl || asset.url, asset.type === "video" ? "video" : "asset", formatAssetType(asset.type))}
<div className="profile-page__list-card-body">
<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>{formatAssetType(asset.type)}</span>
<span>{formatProfileDate(asset.updatedAt)}</span>
</div>
</div>
</article>
))}
@@ -637,15 +706,39 @@ function ProfilePage({
</span>
</div>
<strong className="profile-page__username">{displayName}</strong>
{isBioEditing ? (
<div className="profile-page__bio-editor">
<textarea
className="profile-page__bio"
value={profileBio}
onChange={(event) => setProfileBio(event.target.value)}
onBlur={handleBioBlur}
placeholder={displayedBio}
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 +757,65 @@ function ProfilePage({
</div>
</div>
<button type="button" className="profile-page__share-btn">
<div className="profile-page__account-card">
<div className="profile-page__list-tabs">
<button
type="button"
className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")}
>
{(totalBalance / 100).toFixed(2)}
</button>
<button
type="button"
className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")}
>
{tasks.length}
</button>
</div>
<div className="profile-page__upload-card profile-page__upload-card--meta">
{accountPanel === "credits" ? (
<>
<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 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>
</div>
<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>
@@ -708,40 +848,6 @@ function ProfilePage({
</span>
{renderActivePanel()}
</div>
<div className="profile-page__section">
<div className="profile-page__list-bar">
<div className="profile-page__list-tabs">
<button
type="button"
className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")}
>
{(totalBalance / 100).toFixed(2)}
</button>
<button
type="button"
className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")}
>
{tasks.length}
</button>
</div>
</div>
<div className="profile-page__upload-card profile-page__upload-card--meta">
{accountPanel === "credits" ? (
<>
<span>{displayName}</span>
<span>{(usage.balanceCents / 100).toFixed(2)}</span>
</>
) : (
<>
<span>{tasks.length}</span>
<span>{completedTasks.length}</span>
</>
)}
</div>
</div>
</main>
</div>
</section>
@@ -756,13 +862,31 @@ function ProfilePage({
<source src={AUTH_SHOWCASE_VIDEO_URL} type="video/mp4" />
</video>
<div className="auth-page__video-overlay">
<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>
</div>
@@ -812,10 +936,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 +949,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 +993,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 +1035,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 +1054,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 +1074,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 +1104,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}
<div className="auth-page__agreement">
<span>
@@ -1009,9 +1132,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 () => {
@@ -1,8 +1,11 @@
import {
BarChartOutlined,
CheckCircleFilled,
CopyOutlined,
DownloadOutlined,
FileTextOutlined,
LoadingOutlined,
ThunderboltOutlined,
UploadOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
@@ -168,6 +171,12 @@ function normalizeUploadedText(raw: string, ext: string): string {
return raw;
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
const SCORE_DIMENSIONS: ScoreDimension[] = [
{ key: "hook", label: "钩子设计", maxScore: 20, hint: "开篇吸引力·悬念设置·黄金三秒", detail: "开篇即抛出高概念钩子,悬念设置紧凑有力。" },
{ key: "character", label: "角色塑造", maxScore: 15, hint: "人物立体度·动机合理性·弧光设计", detail: "主角动机有铺垫,配角功能性较强,人物弧光尚可进一步深化。" },
@@ -346,12 +355,14 @@ function ScriptTokensPage() {
const compactTitle = uploadedFile?.name?.replace(/\.[^.]+$/, "") ?? "剧本评测";
const scriptMinutes = Math.max(8, Math.round(script.length / 460));
const reportDate = new Date().toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" });
const statusClass = loading ? "is-loading" : result ? "is-complete" : hasContent ? "is-ready" : "is-idle";
return (
<section className="script-eval-v5 page-motion">
<section className={`script-eval-v5 page-motion ${statusClass}`}>
<div className="script-eval-v5-page">
{/* Left Panel */}
<aside className="script-eval-v5-left">
<div className="script-eval-v5-left-main">
<div className="script-eval-v5-lp-section">
<div className="script-eval-v5-lp-label"></div>
<div
@@ -364,7 +375,10 @@ function ScriptTokensPage() {
{uploadedFile ? (
<div className="script-eval-v5-upload-done is-show">
<CheckCircleFilled />
<span className="script-eval-v5-uf-meta">
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
<span className="script-eval-v5-uf-size">{formatFileSize(uploadedFile.size)}</span>
</span>
<span className="script-eval-v5-uf-re" onClick={(e) => { e.stopPropagation(); handleReset(); }}>
</span>
@@ -374,7 +388,7 @@ function ScriptTokensPage() {
<div className="script-eval-v5-upload-icon"><UploadOutlined /></div>
<div className="script-eval-v5-upload-text"></div>
<button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}>
+
<UploadOutlined />
</button>
<div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div>
</>
@@ -445,12 +459,15 @@ function ScriptTokensPage() {
disabled={loading || !hasContent}
onClick={() => void handleEvaluate()}
>
{loading ? "◆ 评测中..." : "◆ 开始评测"}
{loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
<span>{loading ? "评测中..." : "开始评测"}</span>
</button>
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
<DownloadOutlined />
<span></span>
</button>
</div>
</div>
</aside>
{/* Right Area */}
@@ -475,7 +492,22 @@ 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 className="script-eval-v5-loading-steps" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
) : !result && (
<div className="script-eval-v5-input-section">
<div className="script-eval-v5-illustration" aria-label="上传剧本并开始评测">
<div
@@ -501,7 +533,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 +558,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">
@@ -555,13 +590,23 @@ function ScriptTokensPage() {
<span>0%</span>
</div>
<div className="script-eval-report__chart-grid">
{SCORE_DIMENSIONS.map((dim) => {
{SCORE_DIMENSIONS.map((dim, dimIndex) => {
const score = result.dimensionScores[dim.key] ?? 0;
const pct = Math.max(0, Math.min(1, score / dim.maxScore));
const lossPct = 1 - pct;
const isPerfect = score === dim.maxScore;
const isActive = activeDim === null || activeDim === dimIndex;
return (
<button key={dim.key} type="button" className="script-eval-report__bar-col">
<button
key={dim.key}
type="button"
className={`script-eval-report__bar-col${isActive ? "" : " is-dimmed"}`}
onMouseEnter={() => setActiveDim(dimIndex)}
onFocus={() => setActiveDim(dimIndex)}
onMouseLeave={() => setActiveDim(null)}
onBlur={() => setActiveDim(null)}
aria-label={`${dim.label} ${score}/${dim.maxScore}${dim.hint}`}
>
<div className="script-eval-report__bar-score">
<b>{score}</b><small>/{dim.maxScore}</small>{isPerfect ? <em>*</em> : null}
</div>
@@ -576,6 +621,14 @@ function ScriptTokensPage() {
})}
</div>
</div>
<div className="script-eval-report__chart-note">
<BarChartOutlined />
<span>
{activeDim === null
? "悬停维度可查看当前分项表现,优先从低分项制定改稿计划。"
: `${SCORE_DIMENSIONS[activeDim].label}${SCORE_DIMENSIONS[activeDim].detail}`}
</span>
</div>
</section>
<div className="script-eval-report__findings">
+27 -11
View File
@@ -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);
@@ -230,25 +230,35 @@ function TokenUsagePage({
{ label: "账户类型", value: isEnterpriseAccount ? "企业账户" : "个人账户", tone: "good" },
{ label: "企业空间", value: enterpriseUsage?.enterpriseName || session?.user.enterpriseName || "-" },
];
const pageStatusClass = enterpriseUsageLoading
? "is-syncing"
: enterpriseUsageError
? "has-sync-error"
: isLowBalance
? "has-low-balance"
: "is-healthy";
return (
<section className="script-token-page token-usage-page management-center-page" aria-label="管理中心">
<section className={`script-token-page token-usage-page management-center-page ${pageStatusClass}`} aria-label="管理中心">
<main className="management-center-shell">
<header className="management-center-toolbar" aria-label="管理中心操作">
<div className="management-center-toolbar__title">
<button type="button" className="management-center-toolbar__back" aria-label="返回工具盒" onClick={onOpenMore}>
<ArrowLeftOutlined />
</button>
<span>
<strong></strong>
<small></small>
</span>
</div>
<span className="management-center-status-pill">
<span className={`management-center-status-pill ${enterpriseUsageError ? "is-error" : enterpriseUsageLoading ? "is-loading" : "is-online"}`}>
{enterpriseUsageLoading ? "正在同步企业用量" : enterpriseUsageError || "服务器已连接"}
</span>
<button type="button" onClick={refreshEnterpriseUsage}>
<button type="button" onClick={refreshEnterpriseUsage} disabled={enterpriseUsageLoading}>
<ReloadOutlined />
</button>
<button type="button">
<button type="button" className="is-muted-action">
<UserOutlined />
</button>
@@ -283,7 +293,7 @@ function TokenUsagePage({
<BarChartOutlined />
</h2>
<span>{modelBreakdown.length ? `${modelBreakdown.length} 个模型` : "LIVE"}</span>
<span>{enterpriseUsageLoading ? "SYNC" : modelBreakdown.length ? `${modelBreakdown.length} 个模型` : "LIVE"}</span>
</div>
{modelBreakdown.length ? (
<div className="management-model-list">
@@ -310,7 +320,10 @@ function TokenUsagePage({
<article className="management-card management-status-card">
<div className="management-card__head">
<h2></h2>
<h2>
<LineChartOutlined />
</h2>
</div>
<dl>
{systemStatus.map((item) => (
@@ -364,7 +377,10 @@ function TokenUsagePage({
<section className="management-card management-records">
<div className="management-card__head">
<h2></h2>
<h2>
<BarChartOutlined />
</h2>
<span>{records.length} </span>
</div>
<div className="management-record-table" role="table" aria-label="调用记录">
@@ -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")}
@@ -3,6 +3,24 @@ import type { ReactNode } from "react";
import type { WorkbenchOption, WorkbenchFieldGroup } from "./workbenchConstants";
import { getRatioOptionClassName, getSettingsGridColumnsClassName } from "./workbenchReferenceUtils";
const VIDEO_MODEL_ICON_URLS = {
happyHorse: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/HappyHorse.svg",
pixverse: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/Pixverse.svg",
vidu: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/viduQ3.svg",
wanxiang: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/wan.svg",
kling: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/kling.svg",
} as const;
function getVideoModelIconUrl(option: WorkbenchOption): string | null {
const text = `${option.value} ${option.label}`.toLowerCase();
if (text.includes("happyhorse")) return VIDEO_MODEL_ICON_URLS.happyHorse;
if (text.includes("pixverse")) return VIDEO_MODEL_ICON_URLS.pixverse;
if (text.includes("vidu")) return VIDEO_MODEL_ICON_URLS.vidu;
if (text.includes("wan") || text.includes("万相")) return VIDEO_MODEL_ICON_URLS.wanxiang;
if (text.includes("kling") || text.includes("可灵")) return VIDEO_MODEL_ICON_URLS.kling;
return null;
}
export function SelectChip({
chipId,
value,
@@ -56,6 +74,7 @@ export function SelectChip({
>
{options.map((option, index) => {
const active = option.value === value;
const iconUrl = chipId === "video-model" ? getVideoModelIconUrl(option) : null;
return (
<button
key={option.value}
@@ -71,6 +90,11 @@ export function SelectChip({
>
<span className="ai-workbench-select-chip__option-label">
<span className="ai-workbench-select-chip__option-dot" aria-hidden="true" />
{iconUrl ? (
<span className="ai-workbench-select-chip__option-icon" aria-hidden="true">
<img src={iconUrl} alt="" loading="lazy" />
</span>
) : null}
<span className="ai-workbench-select-chip__option-copy">
<span className="ai-workbench-select-chip__option-title">
<span>{option.label}</span>
+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;
File diff suppressed because it is too large Load Diff
+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 {
File diff suppressed because it is too large Load Diff
+564 -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);
}
@@ -5370,3 +5377,559 @@
flex-shrink: 0;
font-size: 16px;
}
/* ===== Token usage commercial SaaS polish ===== */
.token-usage-page.management-center-page {
--usage-panel: rgba(17, 21, 21, 0.96);
--usage-panel-strong: rgba(21, 26, 25, 0.98);
--usage-inset: rgba(255, 255, 255, 0.035);
--usage-inset-strong: rgba(255, 255, 255, 0.055);
--usage-line: rgba(255, 255, 255, 0.08);
--usage-line-strong: rgba(var(--accent-rgb), 0.28);
--usage-muted: rgba(232, 240, 235, 0.66);
--usage-soft: rgba(232, 240, 235, 0.44);
--usage-card-shadow: 0 18px 46px rgba(0, 0, 0, 0.22);
background:
radial-gradient(circle at 18% 0%, rgba(var(--accent-rgb), 0.06), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.022), transparent 220px),
var(--bg-base);
}
.token-usage-page .management-center-shell {
gap: 16px;
padding: 0 30px 42px;
}
.token-usage-page .management-center-toolbar {
position: sticky;
top: 0;
z-index: 8;
min-height: 64px;
border-bottom-color: var(--usage-line);
border-bottom-left-radius: 18px;
background: rgba(14, 17, 17, 0.88);
backdrop-filter: blur(18px);
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.16);
}
.token-usage-page .management-center-toolbar__title {
gap: 10px;
}
.token-usage-page .management-center-toolbar__title > span {
display: grid;
gap: 3px;
min-width: 0;
}
.token-usage-page .management-center-toolbar__title strong {
color: #f2f8f5;
font-size: 15px;
letter-spacing: 0;
}
.token-usage-page .management-center-toolbar__title small {
overflow: hidden;
color: var(--usage-soft);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.token-usage-page .management-center-toolbar button,
.token-usage-page .management-card__head button,
.token-usage-page .management-center-status-pill {
border-color: var(--usage-line);
border-radius: 10px;
background: var(--usage-inset);
color: var(--usage-muted);
transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease, opacity 160ms ease;
}
.token-usage-page .management-center-toolbar button:hover:not(:disabled),
.token-usage-page .management-card__head button:hover {
border-color: var(--usage-line-strong);
background: rgba(var(--accent-rgb), 0.08);
color: var(--fg-body);
transform: translateY(-1px);
}
.token-usage-page .management-center-toolbar button:disabled {
opacity: 0.52;
cursor: wait;
}
.token-usage-page .management-center-toolbar__back {
border-radius: 999px !important;
}
.token-usage-page .management-center-toolbar button.is-muted-action {
color: var(--usage-soft);
}
.token-usage-page .management-center-toolbar button.is-primary {
border-color: rgba(var(--accent-rgb), 0.72);
background: linear-gradient(180deg, #2fffa5, var(--accent));
color: rgb(5, 15, 11);
box-shadow: 0 12px 26px rgba(var(--accent-rgb), 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.token-usage-page .management-center-status-pill {
position: relative;
gap: 7px;
border-radius: 999px;
padding-inline: 12px;
}
.token-usage-page .management-center-status-pill::before {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 14px rgba(var(--accent-rgb), 0.45);
content: "";
}
.token-usage-page .management-center-status-pill.is-loading::before {
animation: token-usage-pulse 1.2s ease infinite;
}
.token-usage-page .management-center-status-pill.is-error {
border-color: rgba(245, 158, 11, 0.42);
background: rgba(245, 158, 11, 0.1);
color: #f7ca73;
}
.token-usage-page .management-center-status-pill.is-error::before {
background: #f59e0b;
box-shadow: 0 0 14px rgba(245, 158, 11, 0.4);
}
@keyframes token-usage-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.42; transform: scale(0.76); }
}
.token-usage-page .management-balance-alert {
margin: 2px 0 0;
border-color: rgba(245, 158, 11, 0.34);
border-radius: 14px;
background:
linear-gradient(90deg, rgba(245, 158, 11, 0.14), rgba(245, 158, 11, 0.045)),
var(--usage-panel);
box-shadow: var(--usage-card-shadow);
}
.token-usage-page .management-balance-alert button {
border: 0;
border-radius: 999px;
}
.token-usage-page .management-metric-cards {
gap: 12px;
margin: 0;
}
.token-usage-page .management-metric-card {
position: relative;
min-height: 132px;
overflow: hidden;
gap: 8px;
padding: 18px;
border-color: var(--usage-line);
border-radius: 18px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.048), transparent 70%),
var(--usage-panel-strong);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
.token-usage-page .management-metric-card::before {
position: absolute;
inset: 0 auto 0 0;
width: 3px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
content: "";
}
.token-usage-page .management-metric-card.is-accent {
border-color: rgba(var(--accent-rgb), 0.32);
background:
radial-gradient(circle at 88% 16%, rgba(var(--accent-rgb), 0.18), transparent 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.052), transparent 72%),
var(--usage-panel-strong);
}
.token-usage-page .management-metric-card.is-accent::before {
background: var(--accent);
box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.44);
}
.token-usage-page .management-metric-card.is-warn::before {
background: #f59e0b;
box-shadow: 0 0 18px rgba(245, 158, 11, 0.32);
}
.token-usage-page .management-metric-card__index {
position: absolute;
top: 14px;
right: 16px;
color: rgba(255, 255, 255, 0.14);
font-size: 22px;
font-weight: 950;
line-height: 1;
}
.token-usage-page .management-metric-card__label {
color: var(--usage-muted);
font-size: 12px;
font-weight: 800;
}
.token-usage-page .management-metric-card__value {
color: #f6fbf8;
font-size: clamp(24px, 2.5vw, 34px);
font-weight: 920;
letter-spacing: 0;
}
.token-usage-page .management-metric-card__hint {
color: var(--usage-soft);
font-size: 12px;
font-weight: 700;
}
.token-usage-page .management-center-overview {
gap: 14px;
}
.token-usage-page .management-card {
border-color: var(--usage-line);
border-radius: 18px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 64%),
var(--usage-panel);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.026);
}
.token-usage-page .management-card__head {
min-height: 50px;
border-bottom-color: var(--usage-line);
padding-inline: 18px;
}
.token-usage-page .management-card__head h2 {
color: #ecf5f0;
font-size: 14px;
letter-spacing: 0;
}
.token-usage-page .management-card__head h2 .anticon {
color: var(--accent);
}
.token-usage-page .management-card__head > span,
.token-usage-page .management-card__head button {
border-color: var(--usage-line);
border-radius: 999px;
background: var(--usage-inset);
color: var(--usage-muted);
}
.token-usage-page .management-card--chart {
height: clamp(390px, 50vh, 580px);
}
.token-usage-page .management-empty-chart,
.token-usage-page .management-record-empty,
.token-usage-page .management-status-trend__empty {
border: 1px dashed var(--usage-line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.024);
}
.token-usage-page .management-model-list {
gap: 10px;
padding: 14px 18px 18px;
}
.token-usage-page .management-model-bar {
gap: 8px;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.045);
border-radius: 14px;
background: var(--usage-inset);
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
.token-usage-page .management-model-bar:hover {
border-color: var(--usage-line-strong);
background: rgba(var(--accent-rgb), 0.052);
transform: translateY(-1px);
}
.token-usage-page .management-model-bar__top strong {
color: #eef6f2;
font-size: 13px;
}
.token-usage-page .management-model-bar__track {
height: 7px;
background: rgba(255, 255, 255, 0.07);
}
.token-usage-page .management-status-card dl {
padding: 14px 18px 10px;
}
.token-usage-page .management-status-card div {
min-height: 38px;
border-bottom: 1px solid rgba(255, 255, 255, 0.045);
}
.token-usage-page .management-status-card div:last-child {
border-bottom: 0;
}
.token-usage-page .management-status-card dt {
color: var(--usage-soft);
font-weight: 780;
}
.token-usage-page .management-status-trend {
padding: 14px 18px 18px;
border-top-color: var(--usage-line);
}
.token-usage-page .management-status-trend__title {
margin-bottom: 8px;
color: var(--usage-muted);
font-weight: 820;
}
.token-usage-page .usage-trend__svg {
min-height: 160px;
}
.token-usage-page .usage-trend__line {
filter: drop-shadow(0 0 8px rgba(var(--accent-rgb), 0.22));
}
.token-usage-page .usage-trend__dot {
transition: r 160ms ease;
}
.token-usage-page .usage-trend__meta {
border-top-color: var(--usage-line);
color: var(--usage-soft);
}
.token-usage-page .management-members,
.token-usage-page .management-records {
padding-bottom: 14px;
}
.token-usage-page .management-member-list {
gap: 10px;
padding: 12px 18px 2px;
}
.token-usage-page .management-member-row {
min-height: 64px;
border: 1px solid rgba(255, 255, 255, 0.045);
border-radius: 14px;
background: var(--usage-inset);
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
.token-usage-page .management-member-row:hover {
border-color: var(--usage-line-strong);
background: rgba(var(--accent-rgb), 0.052);
transform: translateY(-1px);
}
.token-usage-page .management-member-avatar {
color: rgb(5, 15, 11);
box-shadow: 0 8px 20px rgba(var(--accent-rgb), 0.16);
}
.token-usage-page .management-member-role {
border-color: var(--usage-line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
}
.token-usage-page .management-record-table {
min-width: 0;
padding: 14px 18px 0;
overflow-x: auto;
}
.token-usage-page .management-record-table__head,
.token-usage-page .management-record-table__row {
min-width: 880px;
border: 1px solid transparent;
border-radius: 12px;
}
.token-usage-page .management-record-table__head {
min-height: 38px;
background: rgba(255, 255, 255, 0.045);
color: var(--usage-muted);
}
.token-usage-page .management-record-table__row {
min-height: 46px;
background: var(--usage-inset);
transition: border-color 160ms ease, background 160ms ease;
}
.token-usage-page .management-record-table__row:hover {
border-color: rgba(255, 255, 255, 0.065);
background: rgba(255, 255, 255, 0.052);
}
.token-usage-page .management-record-table__row span.is-good,
.token-usage-page .management-record-table__row span.is-error {
display: inline-flex;
align-items: center;
justify-content: center;
width: max-content;
min-width: 44px;
min-height: 24px;
border-radius: 999px;
padding: 0 9px;
font-size: 11px;
font-weight: 850;
}
.token-usage-page .management-record-table__row span.is-good {
background: rgba(var(--accent-rgb), 0.1);
}
.token-usage-page .management-record-table__row span.is-error {
background: rgba(239, 68, 68, 0.1);
}
.token-usage-page .management-record-pagination {
border-top-color: var(--usage-line);
}
.token-usage-page .management-record-pagination button {
border-color: var(--usage-line);
border-radius: 9px;
background: var(--usage-inset);
color: var(--usage-muted);
}
.token-usage-page .management-record-pagination button:hover:not(:disabled) {
background: var(--accent);
border-color: var(--accent);
color: rgb(5, 15, 11);
}
@media (max-width: 1180px) {
.token-usage-page.management-center-page {
padding-left: 20px;
}
.token-usage-page .management-center-shell {
padding-inline: 22px;
}
}
@media (min-width: 901px) and (max-width: 1180px) {
.token-usage-page.management-center-page {
padding-left: 82px;
}
}
@media (max-width: 900px) {
.token-usage-page.management-center-page {
padding-top: 74px;
padding-left: 0;
}
.token-usage-page .management-center-shell {
padding: 0 16px 34px;
}
.token-usage-page .management-center-toolbar {
top: 0;
align-items: stretch;
margin: 0 -16px;
padding: 12px 16px;
border-radius: 0 0 18px 18px;
}
.token-usage-page .management-center-toolbar__title {
width: 100%;
}
.token-usage-page .management-center-status-pill {
order: 2;
}
.token-usage-page .management-metric-cards {
grid-template-columns: minmax(0, 1fr);
}
.token-usage-page .management-center-overview {
grid-template-columns: minmax(0, 1fr);
}
.token-usage-page .management-card--chart {
height: auto;
min-height: 360px;
}
.token-usage-page .management-member-row {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 10px 12px;
padding: 14px;
}
.token-usage-page .management-member-role,
.token-usage-page .management-member-row > span:not(.management-member-avatar):not(.management-member-role):not(.management-member-meter),
.token-usage-page .management-member-meter,
.token-usage-page .management-member-row > .anticon {
grid-column: 2;
justify-self: start;
}
}
@media (max-width: 560px) {
.token-usage-page .management-center-toolbar button:not(.management-center-toolbar__back) {
flex: 1 1 calc(50% - 6px);
padding-inline: 10px;
}
.token-usage-page .management-center-status-pill {
flex: 1 1 100%;
justify-content: flex-start;
}
.token-usage-page .management-metric-card {
min-height: 118px;
padding: 16px;
}
.token-usage-page .management-metric-card__value {
font-size: 24px;
}
.token-usage-page .management-card__head {
min-height: 46px;
padding-inline: 14px;
}
.token-usage-page .management-model-list,
.token-usage-page .management-member-list,
.token-usage-page .management-record-table {
padding-inline: 14px;
}
}
+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: {