Compare commits

...

29 Commits

Author SHA1 Message Date
stringadmin f9f90bdb96 Merge branch 'master' into feat/home-saas-premium-and-icon-system 2026-06-10 02:22:32 +00:00
stringadmin 2509925644 Merge pull request 'feat: 首页响应式视觉升级与全局UI细节打磨' (#30) from feat/home-responsive-polish-and-ui-refinements into master
Reviewed-on: #30
2026-06-10 02:22:18 +00:00
stringadmin 4562243fd7 Merge branch 'master' into feat/home-responsive-polish-and-ui-refinements 2026-06-10 02:22:13 +00:00
ludan 65bb91c551 feat: 首页SaaS高端化视觉升级与图标系统统一
本次提交包含以下改进:

## 1. 首页架构精简 (HomePage.tsx)
- 移除模型生成(ModelGenerationShowcase)专题区和相关导入
- 移除"一站进入 OmniAI"Experience体验区及HOME_EXPERIENCE_POINTS
- 移除未使用的导入(DashboardOutlined, ThunderboltOutlined)
- 首页专题区从3个(model/ecommerce/script)缩减为2个(ecommerce/script)
- 轮播卡片x偏移量从固定px值改为clamp()流式响应式单位

## 2. 首页入口按钮重设计 (HomePage.tsx)
- 按钮文案升级:新手→快速生成(副标题:新手友好),老手→专业创作(副标题:画布工作流),电商→电商出图(副标题:商品视觉)
- 每个按钮新增small元素展示副标题
- 主按钮(专业创作)渐变绿色背景+发光阴影

## 3. 图标系统统一 — Emoji→Ant Design (ModelGenerationShowcase.tsx + ToolboxSection.tsx)
- ModelGenerationShowcase: emoji(🤖🖼️🎬📷🎨🛍️🌈)全部替换为对应的@ant-design/icons组件
- ToolboxSection: emoji(🎨📷🧑)替换为PictureOutlined/CameraOutlined/VideoCameraOutlined/ScissorOutlined
- SVG播放按钮替换为PlayCircleOutlined

## 4. 工具预览图补充 (MorePage.tsx)
- 为workbench核心工具添加预览图URL
- 为digitalHuman工具添加预览图URL

## 5. 合规页面按需样式加载 (App.tsx)
- 新增COMPLIANCE_PAGE_STYLE_VIEWS集合(communityReview/communityCaseAdd/report/userAgreement/privacyPolicy)
- 新增loadCompliancePageStyles()动态加载compliance.css
- 只在进入合规页面时才加载对应样式,减少首屏CSS体积

## 6. 全局滚动操作按钮禁用 (AppShell.tsx)
- showPageScrollActions强制设为false,全局禁用页面滚动到顶部/底部的浮动按钮

## 7. 首页Premium SaaS视觉升级 (home.css)
- 全新CSS变量体系:--home-card-radius/panel-radius/section-line/panel-border/text-strong/muted/faint
- 背景三层渐变叠加+双radial光晕(brand green+blue accent)
- Hero区域:eyebrow胶囊标签(绿点+毛玻璃)、h1字重950+text-shadow、描述text-wrap:balance
- 轮播卡片:流式clamp尺寸、非激活卡滤镜降饱和、激活卡高亮阴影、底部渐变分割线
- 入口按钮组:统一三列grid布局、图标圆形背景、hover微上移
- Feature专题页:grid网格背景+radial光晕+border分割线、Visual卡片统一玻璃态
- Ecommerce Matrix全面重写:三栏→自适应grid、feature/item/input/ai-node/output全组件玻璃态统一风格
- Experience区域:双radial光晕+网格纹理背景、路线卡片route-color动态渐变
- Script Review专题区:专属高度/内边距/溢出控制,容器查询适配
- Cookie Consent移动端优化

## 8. 响应式断点矩阵 (home.css)
- 1200px: 轮播卡片尺寸压缩
- 1100px: 专题页解除固定高度,Matrix双列布局
- 980px: h1字号clamp、轮播缩小、Matrix紧凑间距
- 899px: 专题页inline padding收紧
- 720px: 入口按钮堆叠、轮播全宽、专题页解除overflow
- 699px: Matrix grid-template-areas重构(copy+input双栏,outputs全宽)
- 640px: Matrix单列、feature-icon/text逐级隐藏
- 520px: output-group水平grid、output-cards三列
- 480px: 全页面紧缩、h1最大字号、carousel最小尺寸

## 9. 入口按钮多轮视觉打磨 (home.css 多轮迭代)
- Round 1: 绿色主题毛玻璃+三列grid
- Round 2: 暗色产品路径选择器风格,actions外框+entry透明
- Round 3: 回归深色面板风格(#101214),保持原始配色调性

## 10. Ecommerce Matrix分层响应式策略 (home.css)
- 1101px+: 三列流式宽度+dvh高度自适应
- 900-1100px: 三列紧凑+逐步隐藏描述文字
- 700-899px: 三列超紧凑+大量line-clamp截断
- <699px: grid-template-areas双栏+全宽输出
- <640px: 全单列堆叠

## 11. 脚本评审Showcase容器查询 (script-review-showcase.css)
- 外层@media保留padding控制
- 内层@container查询适配880px/720px/560px断点
- 图表列720px以下水平进度条布局
- 品牌区域/评分标签/流程卡片逐级压缩
2026-06-09 18:45:57 +08:00
ludan 52677e33f1 feat: 首页响应式视觉升级与全局UI细节打磨
本次提交包含以下改进:

## 1. 首页轮播卡片响应式重构 (HomePage.tsx + home.css)
- 将旋转木马卡片偏移量从固定px值改为clamp()流式单位,随视口宽度自适应缩放
- 使用calc(0px - ...)替代乘法计算方向偏移,兼容CSS变量传递
- 轮播舞台新增mask-image渐变遮罩,边缘卡片自然淡出
- 非激活卡片增加saturate/brightness滤镜,强化主次视觉层级
- 激活卡与非激活卡分别设置图片filter效果
- 移除旧carousel-card-label样式
- 多断点适配:1200px/980px/720px/480px逐级调整卡片尺寸和舞台高度

## 2. 首页入口按钮重设计 (HomePage.tsx + home.css)
- 按钮文案从'新手/老手/电商'改为'快速生成/专业创作/电商出图'
- 每个按钮新增small副标题('新手友好'/'画布工作流'/'商品视觉')
- 主按钮(专业创作)使用渐变绿色背景+发光阴影,新建--primary small样式
- 普通按钮玻璃态背景+内阴影,hover绿色边框高亮
- 720px以下单列全宽布局,按钮居中

## 3. 首页全页视觉强化 (home.css)
- Scrim层三重渐变叠加+radial光晕
- Hero区域文字text-shadow + text-wrap: balance排版
- Feature页面::before叠加渐变遮罩
- Feature Visual卡片增加边框/阴影/背景三层嵌套
- Experience区域斜向分割线装饰背景
- Cookie Consent弹窗玻璃态重设计,移动端自适应

## 4. 首页工具盒区域打磨 (toolbox.css)
- 全新CSS变量(--toolbox-radius-card/inner)
- 工具盒整体深色渐变背景+radial光晕
- Shell容器max-width + clamp流式padding
- 左侧品牌区域标题/brand-icon/subtitle重设计
- 工具列表项、工作流卡片统一玻璃态风格
- 工具卡片hover上浮4px+绿色边框+阴影增强
- @media: 1160px/980px/680px/420px四断点响应式

## 5. 工具盒卡片布局简化 (MorePage.tsx + more.css)
- 核心工具卡片移除独立icon区域,改为单列网格布局
- 普通工具卡片隐藏.more-card__icon(近期记录除外)
- 预览图aspect-ratio从16/9改为4/3,内边距优化
- 移动端移除featured-icon相关样式

## 6. 脚本评审Showcase响应式改造 (script-review-showcase.css)
- 主容器从@media切换为@container查询,跟随父容器自适应
- 新增880px/720px/560px三档container断点
- 图表列在720px以下改为水平进度条布局(bar从垂直改水平)
- 图表列增加卡片边框/圆角/背景
- 品牌区域、评分标签、流程卡片逐级压缩
- @media保留外层padding控制

## 7. 通知中心UI修复 (dark-green.css)
- notification-center改为inline-flex定位锚点
- 面板改为absolute+flex列布局,修复定位偏移
- 列表flex自适应高度+overscroll-behavior: contain
- 移动端面板右偏移clamp适配,箭头位置同步
- 高度单位从vh改为dvh,避免移动浏览器地址栏干扰
2026-06-09 14:22:37 +08:00
stringadmin d535d0d74a Merge pull request 'feat: 新增引导式新手指引 (OnboardingTour) 组件,全站页面接入' (#29) from feat/add-onboarding-tour into master
Reviewed-on: #29
2026-06-08 14:31:17 +00:00
OmniAI Developer 6ed65ca3ee feat: 新增引导式新手指引 (OnboardingTour) 组件,全站页面接入 2026-06-08 21:32:17 +08:00
stringadmin 1e756808c1 Merge pull request 'feat: 工具盒卡片预览图替换与响应式视觉优化' (#28) from feat/toolbox-preview-cards-and-responsive-polish into master
Reviewed-on: #28
2026-06-08 10:59:26 +00:00
stringadmin 1049fa3218 Merge branch 'master' into feat/toolbox-preview-cards-and-responsive-polish 2026-06-08 10:59:20 +00:00
ludan 6f54ad92c0 feat: 工具盒卡片预览图替换与响应式视觉优化
本次提交包含以下改进:

## 1. 工具卡片真实预览图替换 (MorePage.tsx)
- 移除原有的CSS绘制Before/After对比图(ToolComparePanel/CompareScene)
- 新增ToolPreviewPanel组件,使用OSS真实截图展示每个工具的效果预览
- 建立toolPreviewImages映射表,为8个工具分别配置预览图URL
- 预览图支持hover悬浮放大效果(popover),桌面端鼠标悬停时展示大图
- 触摸设备通过@media (hover: none)隐藏popover,避免移动端误触

## 2. 核心工具区重构 (MorePage.tsx)
- 移除FeaturedTool独立接口和featuredTools数组,统一到tools体系
- 新增coreToolIds集合标记核心工具(workbench/inpaint/watermarkRemoval)
- 新增coreToolGradients和coreToolSteps独立配置每张核心卡的渐变和步骤
- 移除openFeaturedTool,统一使用openTool处理所有工具点击
- 核心卡片kick er改为显示分类标签(图像创作/视频创作)
- 视频生成分类标签更名为"视频创作"

## 3. 工具卡片视觉升级 (more.css)
- 新增CSS变量体系:--more-card-surface/surface-strong/border/border-strong
- 核心工具卡:三列网格布局、渐变背景叠加、预览图16:9占位区
- 普通工具卡:增大最小高度至392px、radial-gradient光晕、增强边框
- 卡片预览图:aspect-ratio容器、内边框光效、::after渐变叠加层
- Hover悬浮popover:从卡片底部弹出大图,160ms过渡动画
- CTA按钮强化:渐变背景、内阴影高光、font-weight 850
- :active状态按压反馈(translateY(0)消除位移)
- 阴影系统升级:更深、更柔和的阴影层次

## 4. Prompt案例弹窗响应式重构 (workbench.css)
- 720px断点:从垂直堆叠改为水平左右分栏布局(1.08:0.92)
- 侧边栏从底部面板移至右侧,带左分割线和投影
- 420px断点:紧凑水平分栏(0.9:1.1)、更小字号和间距
- 弹窗增加边框和圆角、关闭按钮毛玻璃效果
- 作者信息采用grid布局、描述文本line-clamp截断

## 5. 响应式细节完善
- more.css 860px: 双列核心卡、增大预览图、调整间距
- more.css 520px: 单列布局、筛选标签横向滚动、CTA按钮全宽
- workbench.css: 各断点prompt-case-modal精确调优
2026-06-08 18:57:07 +08:00
stringadmin 9b7e708f85 Merge pull request 'Codex/generation task reliability' (#27) from codex/generation-task-reliability into master
Reviewed-on: #27
2026-06-08 10:31:08 +00:00
stringadmin 4e97e706fd Add beta application email fields 2026-06-08 18:30:05 +08:00
stringadmin 30536ad15f Fix wan2.7 image quality selection 2026-06-08 18:26:44 +08:00
stringadmin e78cc05299 Merge remote-tracking branch 'origin/master' into codex/generation-task-reliability 2026-06-08 17:39:11 +08:00
stringadmin b88be66e7f Merge pull request 'feat: Workbench SaaS视觉升级与视图重置机制' (#26) from feat/workbench-saas-polish-and-reset into master
Reviewed-on: #26
2026-06-08 09:31:55 +00:00
stringadmin 1a9196a63a Merge branch 'master' into feat/workbench-saas-polish-and-reset 2026-06-08 09:31:49 +00:00
ludan 4dfcb6fc8a feat: Workbench SaaS视觉升级与视图重置机制
本次提交包含以下改进:

## 1. Workbench视图重置机制 (App.tsx + WorkbenchPage.tsx)
- 在App.tsx中新增workbenchResetToken状态,每次导航到workbench页面且存在session时递增token
- WorkbenchPage新增resetToken属性,检测token变化后自动调用handleNewConversation()重置工作台状态
- 重置时清空消息列表和活跃会话ID,确保每次进入工作台都是全新状态

## 2. 滚动操作提示系统 (WorkbenchPage.tsx)
- 新增scrollActionHint状态和hideScrollActionHint/showScrollActionHint方法
- 用户滚动离开消息区域时自动显示滚动方向提示(顶部/底部按钮)
- 1.4秒后自动隐藏提示,优化交互体验
- 手动点击滚动按钮后立即隐藏提示
- 为滚动按钮添加--top/--bottom标识类名,支持独立定位

## 3. Prompt案例弹窗自适应布局 (WorkbenchPage.tsx)
- renderPromptCaseOverlay重构为动态计算moda l类名
- 根据图片实测宽高比(is-tall-media/is-portrait-media)和文案长度(is-long-copy)动态调整布局
- 添加handlePromptCaseImageLoad回调在图片加载后测量尺寸

## 4. Workbench SaaS视觉美化 (workbench.css)
- 全新SaaS风格设计变量(--wb-panel, --wb-line, --wb-shadow等)
- 首页区域:标题样式、Composer输入框圆角/阴影/聚焦态、发送按钮渐变样式
- 模式选择/芯片组件:下拉菜单、悬停态优化、选中态高亮
- 聊天消息区:气泡圆角、头像样式、消息间距、空状态引导
- 图片/视频结果卡片:边框、阴影、标签徽章、视频PLAY标识
- 生成中卡片:停止按钮样式
- 会话侧边栏:折叠态浮动按钮定位、展开态面板样式、选中项左侧指示条
- 滚动快捷键:固定定位圆形按钮、显示/隐藏过渡动画
- Prompt案例弹窗:桌面端毛玻璃双栏布局、移动端底部面板布局
- @media适配:560px/720px/900px/980px四个断点全覆盖

## 5. 全局移动端布局变量 (dark-green.css)
- 新增--dg-mobile-nav-height/gap/space CSS变量,统一移动端底部导航高度计算
- 优化Topbar z-index层级
- 非特殊页面自动添加顶部padding避让移动导航
- Profile弹窗fixed定位及安全区域适配
2026-06-08 17:30:21 +08:00
stringadmin e351e93200 Center beta application review layout 2026-06-08 16:35:32 +08:00
stringadmin 117b9354eb Restore moderation page styles 2026-06-08 16:32:16 +08:00
stringadmin 446514dd06 Fix beta application review page scrolling 2026-06-08 16:26:38 +08:00
stringadmin 85a174bcb5 Avoid clearing sessions on permission errors 2026-06-08 16:20:52 +08:00
stringadmin 560a7baddc Restore image generation estimate to 20 credits 2026-06-08 16:07:04 +08:00
stringadmin 4f7f67a278 Scale generation billing estimates to 1-to-100 credits 2026-06-08 16:03:52 +08:00
stringadmin 3963d9ae2f Show billing estimate and clarify session replacement 2026-06-08 15:55:50 +08:00
stringadmin 60d5cd2edf Merge pull request 'Codex/generation task reliability' (#25) from codex/generation-task-reliability into master
Reviewed-on: #25
2026-06-08 07:49:24 +00:00
stringadmin 2afa73ac18 Align visible credit pricing to 1-to-100 2026-06-08 15:46:31 +08:00
stringadmin 3a1bc0241e feat: add beta application review flow 2026-06-08 15:23:13 +08:00
stringadmin 33723d00f0 Merge remote-tracking branch 'origin/master' into codex/generation-task-reliability 2026-06-08 15:08:26 +08:00
stringadmin fe5a839b37 fix: harden generation task polling fallback 2026-06-08 14:47:27 +08:00
54 changed files with 10013 additions and 615 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 729 KiB

+50 -1
View File
@@ -8,6 +8,7 @@ import ToastContainer from "./components/toast/ToastContainer";
import { toast } from "./components/toast/toastStore";
import { aiGenerationClient } from "./api/aiGenerationClient";
import { keyServerClient } from "./api/keyServerClient";
import { setUserMaxConcurrency } from "./api/generationConcurrency";
import { notificationClient } from "./api/notificationClient";
import {
SERVER_SESSION_REPLACED_EVENT,
@@ -32,6 +33,7 @@ const CharacterMixPage = lazy(() => import("./features/character-mix/CharacterMi
const CommunityPage = lazy(() => import("./features/community/CommunityPage"));
const CommunityCaseAddPage = lazy(() => import("./features/community-review/CommunityCaseAddPage"));
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
const BetaApplicationsPage = lazy(() => import("./features/beta-applications/BetaApplicationsPage"));
const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
@@ -108,6 +110,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
"more",
"communityReview",
"communityCaseAdd",
"betaApplications",
"report",
"providerHealth",
"userAgreement",
@@ -123,6 +126,7 @@ const LEGACY_PAGE_STYLE_VIEWS = new Set<WebViewKey>([
"community",
"communityReview",
"communityCaseAdd",
"betaApplications",
"assets",
"ecommerce",
"ecommerceHub",
@@ -132,14 +136,27 @@ const LEGACY_PAGE_STYLE_VIEWS = new Set<WebViewKey>([
"characterMix",
"more",
]);
const COMPLIANCE_PAGE_STYLE_VIEWS = new Set<WebViewKey>([
"communityReview",
"communityCaseAdd",
"report",
"userAgreement",
"privacyPolicy",
]);
let legacyPageStylesPromise: Promise<unknown> | null = null;
let compliancePageStylesPromise: Promise<unknown> | null = null;
function loadLegacyPageStyles(): Promise<unknown> {
legacyPageStylesPromise ??= import("./styles/pages/legacy-pages.css");
return legacyPageStylesPromise;
}
function loadCompliancePageStyles(): Promise<unknown> {
compliancePageStylesPromise ??= import("./styles/pages/compliance.css");
return compliancePageStylesPromise;
}
function normalizeViewKey(rawView: string): WebViewKey {
const normalized =
rawView === "profile" || rawView === "auth"
@@ -156,6 +173,8 @@ function normalizeViewKey(rawView: string): WebViewKey {
? "communityReview"
: rawView === "community-case-add"
? "communityCaseAdd"
: rawView === "beta-applications" || rawView === "beta-application-review"
? "betaApplications"
: rawView;
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found";
}
@@ -368,6 +387,8 @@ function App() {
})));
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
const [workbenchResetToken, setWorkbenchResetToken] = useState(0);
const [onboardingActive, setOnboardingActive] = useState(false);
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
useEffect(() => {
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
@@ -377,6 +398,9 @@ function App() {
if (LEGACY_PAGE_STYLE_VIEWS.has(activeView) || ecommerceEverMounted) {
void loadLegacyPageStyles();
}
if (COMPLIANCE_PAGE_STYLE_VIEWS.has(activeView)) {
void loadCompliancePageStyles();
}
}, [activeView, ecommerceEverMounted]);
// Dismiss boot splash after first render
@@ -453,6 +477,9 @@ function App() {
);
const handleSetView = useCallback((view: WebViewKey) => {
if (view === "workbench" && Boolean(session)) {
setWorkbenchResetToken((token) => token + 1);
}
window.location.hash = `/${view}`;
setView(view);
if (view !== "login") {
@@ -461,11 +488,23 @@ function App() {
if (isWorkspaceView(view)) {
setWorkspaceExpanded(true);
}
}, [setView, setWorkspaceExpanded]);
}, [session, setView, setWorkspaceExpanded]);
const handleStartOnboarding = useCallback(() => {
setOnboardingActive(true);
try { window.localStorage.setItem("omniai:onboarding", "1"); } catch {}
handleSetView("workbench");
}, [handleSetView]);
const handleEndOnboarding = useCallback(() => {
setOnboardingActive(false);
try { window.localStorage.removeItem("omniai:onboarding"); } catch {}
}, []);
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
clearAllUserStorage();
clearSessionState();
setUserMaxConcurrency(null);
setProjects([]);
setProjectsLoaded(true);
setUsage(emptyUsageSummary);
@@ -574,6 +613,7 @@ function App() {
const nextSession = await keyServerClient.getCurrentSession();
if (cancelled) return;
setSession(nextSession);
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
await hydrateAccountData(nextSession);
};
@@ -606,6 +646,7 @@ function App() {
if (cancelled) return;
if (nextSession) {
setSession(nextSession);
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
} else {
clearAuthenticatedState({ resetView: true });
}
@@ -943,6 +984,7 @@ function App() {
async (nextSession: WebUserSession) => {
hideSessionReplaced();
setSession(nextSession);
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
await hydrateAccountData(nextSession);
if (nextSession.user.email && !nextSession.user.emailVerified) {
@@ -1298,20 +1340,27 @@ function App() {
onOpenReview={() => handleSetView("communityReview")}
/>
);
case "betaApplications":
return <BetaApplicationsPage session={session} onOpenLogin={handleOpenLogin} />;
case "workbench":
return (
<WorkbenchPage
key={`workbench-${workbenchResetToken}`}
isAuthenticated={Boolean(session)}
session={session}
onboarding={onboardingActive}
onEndOnboarding={handleEndOnboarding}
onRequireLogin={handleRequireTaskLogin}
onOpenResultInCanvas={handleOpenResultInCanvas}
onRefreshUsage={refreshUsage}
resetToken={workbenchResetToken}
/>
);
case "home":
return (
<HomePage
onOpenGenerate={() => handleSetView("workbench")}
onStartOnboarding={handleStartOnboarding}
onOpenCanvas={() => handleSetView("canvas")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onOpenScriptReview={() => handleSetView("scriptTokens")}
+139
View File
@@ -0,0 +1,139 @@
import { serverRequest } from "./serverConnection";
export interface BetaApplicationInput {
name: string;
email: string;
phone: string;
wechat: string;
industry: string;
company: string;
city: string;
aiTools: string;
aiDuration: string;
aiTrack: string;
aiDirection: string[];
weeklyUsage: string;
feedbackWilling: string;
wantFeature: string[];
selfStatement: string;
signature: string;
applicationDate: string;
agreeRules: boolean;
}
export type BetaApplicationStatus = "pending" | "approved" | "rejected";
export interface BetaApplicationItem extends BetaApplicationInput {
id: number;
userId: number | null;
username: string | null;
status: BetaApplicationStatus;
inviteCode: string | null;
reviewNote: string | null;
reviewedBy: number | null;
reviewerUsername: string | null;
reviewedAt: string | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: string;
updatedAt: string;
}
export interface BetaApplicationSubmitResult {
id: number;
status: BetaApplicationStatus;
createdAt: string;
}
function readString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function readNullableString(value: unknown): string | null {
return typeof value === "string" && value ? value : null;
}
function readNumberOrNull(value: unknown): number | null {
if (value === null || value === undefined || value === "") return null;
const next = Number(value);
return Number.isFinite(next) ? next : null;
}
function readStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
function normalizeStatus(value: unknown): BetaApplicationStatus {
return value === "approved" || value === "rejected" ? value : "pending";
}
function normalizeApplication(raw: unknown): BetaApplicationItem {
const item = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
return {
id: Number(item.id) || 0,
userId: readNumberOrNull(item.userId),
username: readNullableString(item.username),
name: readString(item.name),
email: readString(item.email),
phone: readString(item.phone),
wechat: readString(item.wechat),
industry: readString(item.industry),
company: readString(item.company),
city: readString(item.city),
aiTools: readString(item.aiTools),
aiDuration: readString(item.aiDuration),
aiTrack: readString(item.aiTrack),
aiDirection: readStringArray(item.aiDirection),
weeklyUsage: readString(item.weeklyUsage),
feedbackWilling: readString(item.feedbackWilling),
wantFeature: readStringArray(item.wantFeature),
selfStatement: readString(item.selfStatement),
signature: readString(item.signature),
applicationDate: readString(item.applicationDate),
agreeRules: item.agreeRules === true,
status: normalizeStatus(item.status),
inviteCode: readNullableString(item.inviteCode),
reviewNote: readNullableString(item.reviewNote),
reviewedBy: readNumberOrNull(item.reviewedBy),
reviewerUsername: readNullableString(item.reviewerUsername),
reviewedAt: readNullableString(item.reviewedAt),
ipAddress: readNullableString(item.ipAddress),
userAgent: readNullableString(item.userAgent),
createdAt: readString(item.createdAt),
updatedAt: readString(item.updatedAt),
};
}
export const betaApplicationClient = {
async submit(input: BetaApplicationInput): Promise<BetaApplicationSubmitResult> {
const payload = await serverRequest<{ application: BetaApplicationSubmitResult }>("beta-applications", {
method: "POST",
body: input,
maxRetries: 0,
fallbackMessage: "提交内测申请失败",
});
return payload.application;
},
async listAdminApplications(status?: BetaApplicationStatus | ""): Promise<BetaApplicationItem[]> {
const query = status ? `?status=${encodeURIComponent(status)}` : "";
const payload = await serverRequest<{ applications?: unknown[] }>(`admin/beta-applications${query}`, {
fallbackMessage: "读取内测申请失败",
});
return Array.isArray(payload.applications) ? payload.applications.map(normalizeApplication) : [];
},
async reviewApplication(
id: number,
action: "approve" | "reject",
reviewNote?: string,
): Promise<BetaApplicationItem> {
const payload = await serverRequest<{ application: unknown }>(`admin/beta-applications/${id}`, {
method: "PATCH",
body: { action, reviewNote },
maxRetries: 0,
fallbackMessage: "审核内测申请失败",
});
return normalizeApplication(payload.application);
},
};
+14 -3
View File
@@ -7,10 +7,20 @@ interface GenerationSlot {
createdAt: number;
}
const MAX_ACTIVE_GENERATION_TASKS = 3;
const DEFAULT_MAX_ACTIVE_GENERATION_TASKS = 3;
const STALE_SLOT_MS = 6 * 60 * 60 * 1000;
const activeSlots = new Map<string, GenerationSlot>();
let userMaxConcurrency: number | null = null;
export function setUserMaxConcurrency(limit: number | null | undefined): void {
userMaxConcurrency = typeof limit === "number" && limit > 0 ? limit : null;
}
function getEffectiveLimit(): number {
return userMaxConcurrency ?? DEFAULT_MAX_ACTIVE_GENERATION_TASKS;
}
export function getGenerationUserKey(userId?: string | number | null): string {
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
}
@@ -39,8 +49,9 @@ export function claimGenerationSlot(input: {
}): () => void {
pruneStaleSlots();
const activeCount = getActiveGenerationTaskCount(input.userKey);
if (activeCount >= MAX_ACTIVE_GENERATION_TASKS) {
throw new Error("当前账号同时最多生成 3 个图片/视频任务,请等待已有任务完成后再提交。");
const effectiveLimit = getEffectiveLimit();
if (activeCount >= effectiveLimit) {
throw new Error(`当前账号同时最多生成 ${effectiveLimit} 个图片/视频任务,请等待已有任务完成后再提交。`);
}
const id = input.id || `generation-slot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+1
View File
@@ -434,6 +434,7 @@ function normalizeUser(raw: unknown): WebUserSession["user"] | null {
candidate.enterpriseBalance ??
candidate.enterprise_balance,
),
maxConcurrency: toNumber(candidate.maxConcurrency ?? candidate.max_concurrency),
activePackages: toActivePackages(candidate.activePackages ?? candidate.active_packages),
};
}
+6 -1
View File
@@ -38,9 +38,14 @@ function normalizeModelOption(raw: unknown): ModelCapabilityOption | null {
const enabled = raw.enabled === undefined ? status !== "maintenance" && status !== "disabled" : Boolean(raw.enabled);
if (!enabled) return null;
const label = toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value);
return {
value,
label: toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value),
label:
value === "wan2.7-image-pro"
? label.replace(/\s*4k\b/i, "").trim() || "wan 2.7 Pro"
: label,
description: toStringValue(raw.description) || undefined,
badge: toStringValue(raw.badge) || undefined,
enabled,
+12
View File
@@ -248,6 +248,17 @@ function isNonAuthErrorCode(code: string | undefined): boolean {
].includes(code);
}
function isAuthFailureResponse(status: number, payload: unknown): boolean {
if (status === 401) return true;
if (status !== 403) return false;
const code = getPayloadCode(payload);
if (code === "SESSION_REPLACED" || code === "TOKEN_EXPIRED" || code === "ACCOUNT_DISABLED") return true;
const message = getPayloadMessage(payload) || "";
return /账号已禁用|登录已过期|登录状态|session|token|企业信息不存在/i.test(message);
}
function notifySessionExpired(status: number, response: Response, payload: unknown): void {
if (status !== 401 && status !== 403) return;
if (typeof window === "undefined") return;
@@ -263,6 +274,7 @@ function notifySessionExpired(status: number, response: Response, payload: unkno
// Non-auth 403 errors (enterprise model access, insufficient balance) must
// not trigger session expiry.
if (status === 403 && isNonAuthErrorCode(getPayloadCode(payload))) return;
if (!isAuthFailureResponse(status, payload)) return;
const now = Date.now();
if (now - lastSessionExpiredEventAt < 1500) return;
+1 -3
View File
@@ -44,7 +44,6 @@ export function waitForTask(
let settled = false;
let cleanup: (() => void) | null = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let sseConnected = false;
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
let lastProgress = 0;
let lastProgressAt = startedAt;
@@ -83,10 +82,9 @@ export function waitForTask(
};
cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate);
sseConnected = true;
fallbackTimerId = setTimeout(() => {
if (settled || !sseConnected) return;
if (settled) return;
if (cleanup) cleanup();
startPolling();
}, 5000);
+21 -1
View File
@@ -64,6 +64,12 @@ function formatBalance(cents: number): string {
return `${value.toFixed(2)} 积分`;
}
function canReviewBetaApplications(session: WebUserSession | null): boolean {
const role = String(session?.user.role || "").trim().toLowerCase();
const username = String(session?.user.username || "").trim().toLowerCase();
return role === "admin" || username === "xqy1912";
}
function AppShell({
activeView,
navItems,
@@ -95,7 +101,7 @@ function AppShell({
const isAuthView = activeView === "login";
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home";
const showPageScrollActions = showFloatingNav && !TOOL_SURFACE_VIEW_SET.has(activeView);
const showPageScrollActions = false;
const visibleNavItems = useMemo(
() => {
@@ -249,6 +255,7 @@ function AppShell({
const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分";
const showCommunityReview = canReviewCommunity(session);
const showCommunityCaseAdd = canManageCommunityCases(session);
const showBetaApplicationReview = canReviewBetaApplications(session);
return (
<div
@@ -486,6 +493,19 @@ function AppShell({
</button>
</>
) : null}
{showBetaApplicationReview ? (
<button
type="button"
className="profile-popover__review-btn"
onClick={() => {
setProfileOpen(false);
onSelectView("betaApplications");
}}
>
<ShellIcon name="check-circle" />
</button>
) : null}
{showCommunityCaseAdd ? (
<>
<button
+78 -12
View File
@@ -1,5 +1,6 @@
import { CloseOutlined, ExperimentOutlined } from "@ant-design/icons";
import { useState } from "react";
import { betaApplicationClient } from "../api/betaApplicationClient";
interface BetaApplicationModalProps {
open: boolean;
@@ -9,6 +10,7 @@ interface BetaApplicationModalProps {
/* ── Form state ── */
interface BetaFormData {
name: string;
email: string;
phone: string;
wechat: string;
industry: string;
@@ -23,11 +25,13 @@ interface BetaFormData {
wantFeature: string[];
selfStatement: string;
signature: string;
applicationDate: string;
agreeRules: boolean;
}
const INITIAL_FORM: BetaFormData = {
name: "",
email: "",
phone: "",
wechat: "",
industry: "",
@@ -42,6 +46,7 @@ const INITIAL_FORM: BetaFormData = {
wantFeature: [],
selfStatement: "",
signature: "",
applicationDate: "",
agreeRules: false,
};
@@ -140,16 +145,74 @@ function TextField({
const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
const [form, setForm] = useState<BetaFormData>(INITIAL_FORM);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<{ tone: "success" | "error"; text: string } | null>(null);
const update = <K extends keyof BetaFormData>(key: K, value: BetaFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
setMessage(null);
};
const close = () => {
if (submitting) return;
onClose();
};
const validate = () => {
if (!form.name.trim()) return "请填写姓名 / 常用昵称";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) return "请填写用于接收内测码的有效邮箱";
if (!form.phone.trim()) return "请填写联系手机号码";
if (!form.wechat.trim()) return "请填写微信账号";
if (!form.selfStatement.trim()) return "请填写申请自述";
if (!form.signature.trim()) return "请填写申请人确认签字";
if (!form.applicationDate.trim()) return "请填写申请日期";
if (!form.agreeRules) return "请先阅读并同意内测规则";
return null;
};
const submit = async () => {
if (submitting) return;
const validationError = validate();
if (validationError) {
setMessage({ tone: "error", text: validationError });
return;
}
setSubmitting(true);
setMessage(null);
try {
await betaApplicationClient.submit({
...form,
name: form.name.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
wechat: form.wechat.trim(),
industry: form.industry.trim(),
company: form.company.trim(),
city: form.city.trim(),
aiTools: form.aiTools.trim(),
aiDuration: form.aiDuration.trim(),
aiTrack: form.aiTrack.trim(),
weeklyUsage: form.weeklyUsage.trim(),
feedbackWilling: form.feedbackWilling.trim(),
selfStatement: form.selfStatement.trim(),
signature: form.signature.trim(),
applicationDate: form.applicationDate.trim(),
});
setForm(INITIAL_FORM);
setMessage({ tone: "success", text: "申请已提交,请留意预留邮箱中的审核结果。" });
} catch (error) {
setMessage({ tone: "error", text: error instanceof Error ? error.message : "提交内测申请失败" });
} finally {
setSubmitting(false);
}
};
if (!open) return null;
return (
<div className="beta-application-modal" role="dialog" aria-modal="true" aria-labelledby="beta-modal-title">
<button type="button" className="beta-application-modal__backdrop" onClick={onClose} aria-label="关闭内测申请弹窗" />
<button type="button" className="beta-application-modal__backdrop" onClick={close} aria-label="关闭内测申请弹窗" />
<section className="beta-application-modal__panel">
{/* ── Header ── */}
@@ -158,10 +221,10 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
<ExperimentOutlined className="beta-modal-header__icon" />
<div>
<h2 id="beta-modal-title">OmniAI </h2>
<p className="beta-modal-header__subtitle"> · <strong>30 </strong> · <strong>500 </strong> </p>
<p className="beta-modal-header__subtitle"> · <strong>30 </strong> · <strong>500 50,000 </strong></p>
</div>
</div>
<button type="button" className="beta-modal-header__close" onClick={onClose} aria-label="关闭">
<button type="button" className="beta-modal-header__close" onClick={close} aria-label="关闭" disabled={submitting}>
<CloseOutlined />
</button>
</header>
@@ -174,6 +237,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
<h3 className="beta-doc-section__title"></h3>
<div className="beta-doc-grid">
<TextField label="姓名 / 常用昵称" value={form.name} onChange={(v) => update("name", v)} />
<TextField label="接收内测码邮箱" value={form.email} onChange={(v) => update("email", v)} placeholder="审核通过后内测码将发送到此邮箱" />
<TextField label="联系手机号码" value={form.phone} onChange={(v) => update("phone", v)} />
<TextField label="微信账号" value={form.wechat} onChange={(v) => update("wechat", v)} />
<TextField label="所在行业 / 职业" value={form.industry} onChange={(v) => update("industry", v)} />
@@ -239,10 +303,10 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
<h3 className="beta-doc-section__title"></h3>
<ol className="beta-rules-list">
<li> <strong>30 </strong> + </li>
<li> <strong>500 </strong> 使</li>
<li> <strong>500 50,000 </strong>使</li>
<li>线</li>
<li></li>
<li> <strong>48 </strong> </li>
<li> <strong>48 </strong> </li>
<li>线</li>
</ol>
@@ -257,10 +321,7 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
<div className="beta-doc-grid beta-doc-grid--two">
<TextField label="申请人确认签字" value={form.signature} onChange={(v) => update("signature", v)} placeholder="请签署姓名" />
<div className="beta-text-field">
<span className="beta-text-field__label"></span>
<input type="text" className="beta-text-field__input" value="2026年 月 日" readOnly />
</div>
<TextField label="申请填写日期" value={form.applicationDate} onChange={(v) => update("applicationDate", v)} placeholder="例如:2026年6月8日" />
</div>
</section>
@@ -268,11 +329,16 @@ const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
{/* ── Footer ── */}
<footer className="beta-modal-footer">
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--secondary" onClick={onClose}>
{message ? (
<p className={`beta-modal-footer__message beta-modal-footer__message--${message.tone}`} role="status">
{message.text}
</p>
) : null}
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--secondary" onClick={close} disabled={submitting}>
</button>
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--primary" onClick={onClose}>
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--primary" onClick={() => void submit()} disabled={submitting}>
{submitting ? "提交中..." : "提交申请"}
</button>
</footer>
</section>
+500
View File
@@ -0,0 +1,500 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { createPortal } from "react-dom";
import { CloseOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons";
import "../styles/components/onboarding.css";
// ─── Types ───────────────────────────────────────────────────
export type TourPhaseId = "chat" | "image" | "video";
interface TooltipStep {
target: string;
title: string;
description: string;
/** Which side of the target to place the tooltip on (preferred). */
placement?: "top" | "bottom" | "left" | "right";
/** If true, this step requires the user to interact with the element to proceed. */
interactive?: boolean;
/** Shown as hint text when interactive. */
actionHint?: string;
}
interface TourPhase {
id: TourPhaseId;
label: string;
steps: TooltipStep[];
}
interface OnboardingTourProps {
active: boolean;
phase: TourPhaseId;
stepIndex: number;
onNext: (phase: TourPhaseId, stepIndex: number) => void;
onSkip: (phase: TourPhaseId) => void;
onDone: () => void;
}
// ─── Tour definitions ────────────────────────────────────────
const PHASES: Record<TourPhaseId, TourPhase> = {
chat: {
id: "chat",
label: "对话模式",
steps: [
{
target: "onboarding-chat-upload",
title: "参考素材上传",
description: "点击或拖拽上传图片、视频、音频等参考素材,帮助 AI 更好地理解你的需求。",
placement: "right",
},
{
target: "onboarding-chat-model",
title: "AI 模型选择",
description: "在这里选择对话使用的 AI 模型,不同模型有不同的擅长领域和风格。",
placement: "bottom",
},
{
target: "onboarding-chat-speed",
title: "思考速度",
description: "「思考速度:高」回复更迅速简洁;「思考速度:急速」适合快速问答场景。",
placement: "bottom",
},
{
target: "onboarding-chat-depth",
title: "推理深度",
description: "「推理深度:强」进行更深层逻辑推理;「推理深度:极限」适合复杂多步骤问题。",
placement: "bottom",
},
{
target: "onboarding-chat-input",
title: "提示词输入框",
description: "在这里输入你的问题或创作需求,按 Enter 发送,Shift + Enter 换行。",
placement: "top",
},
{
target: "onboarding-mode-selector",
title: "切换到图像生成模式",
description: "点击「下一步」自动切换,或点击这个按钮手动选择「图像生成」进入下一阶段。",
placement: "bottom",
},
],
},
image: {
id: "image",
label: "图像生成",
steps: [
{
target: "onboarding-image-upload",
title: "参考图上传",
description: "上传参考图片,AI 将基于参考图的风格和内容生成新图像。支持 PNG / JPG / WebP。",
placement: "right",
},
{
target: "onboarding-image-model",
title: "图像模型选择",
description: "选择用于图像生成的 AI 模型,不同模型在风格、精度和速度上有所侧重。",
placement: "bottom",
},
{
target: "onboarding-image-settings",
title: "比例与分辨率",
description: "设置生成图像的宽高比(如 16:9、1:1)和清晰度(1K/2K),根据使用场景选择。",
placement: "bottom",
},
{
target: "onboarding-image-grid",
title: "单图 / 多宫格模式",
description: "「单图」生成一张完整图像;「多宫格」一次生成多张变体供你挑选最佳方案。",
placement: "bottom",
},
{
target: "onboarding-image-input",
title: "图像提示词",
description: "描述你想要的图像内容、风格和细节,越具体效果越好。",
placement: "top",
},
{
target: "onboarding-mode-selector",
title: "切换到视频生成模式",
description: "点击「下一步」自动切换,或点击这个按钮手动选择「视频生成」进入下一阶段。",
placement: "bottom",
},
],
},
video: {
id: "video",
label: "视频生成",
steps: [
{
target: "onboarding-video-upload",
title: "参考素材上传",
description: "上传参考图片或视频片段,帮助 AI 确定视频的风格、色调和内容方向。",
placement: "right",
},
{
target: "onboarding-video-model",
title: "视频模型选择",
description: "选择视频生成模型。不同模型在画质、时长、运动流畅度上各有优势。",
placement: "bottom",
},
{
target: "onboarding-video-frame",
title: "生成方式:全能 / 首尾帧",
description: "「全能参考」根据描述直接生成;「首尾帧」通过设定起始和结束画面精确控制转场。",
placement: "bottom",
},
{
target: "onboarding-video-ratio",
title: "视频画面比例",
description: "选择画面比例。9:16 适合手机短视频(抖音/Reels),16:9 适合横屏展示。",
placement: "bottom",
},
{
target: "onboarding-video-duration",
title: "视频时长设置",
description: "设置生成视频的秒数。时长越长,生成时间越久,建议从 5 秒开始尝试。",
placement: "bottom",
},
{
target: "onboarding-video-quality",
title: "分辨率与画质",
description: "选择视频清晰度。720P 生成更快适合预览,1080P 画质更高适合最终成品。",
placement: "bottom",
},
{
target: "onboarding-video-generate",
title: "一切就绪,开始创作!",
description: "设置完毕后,点击发送按钮(或按 Enter)即可开始你的首次视频生成。祝你创作愉快!",
placement: "top",
},
],
},
};
// ─── Connector line calculation ──────────────────────────────
interface ConnectorPoints {
x1: number; y1: number; // tooltip edge center
x2: number; y2: number; // target edge center
}
function calcConnector(
tooltipRect: DOMRect,
targetRect: DOMRect,
placement: TooltipStep["placement"],
): ConnectorPoints {
const tx = targetRect.left + targetRect.width / 2;
const ty = targetRect.top + targetRect.height / 2;
const tcx = tooltipRect.left + tooltipRect.width / 2;
const tcy = tooltipRect.top + tooltipRect.height / 2;
switch (placement) {
case "top":
return { x1: tcx, y1: tooltipRect.bottom, x2: tx, y2: targetRect.top };
case "bottom":
return { x1: tcx, y1: tooltipRect.top, x2: tx, y2: targetRect.bottom };
case "left":
return { x1: tooltipRect.right, y1: tcy, x2: targetRect.left, y2: ty };
case "right":
return { x1: tooltipRect.left, y1: tcy, x2: targetRect.right, y2: ty };
default:
return { x1: tcx, y1: tooltipRect.top, x2: tx, y2: targetRect.bottom };
}
}
// ─── Placement engine ─────────────────────────────────────────
interface PlacementResult {
left: number;
top: number;
actualPlacement: TooltipStep["placement"];
}
/** Score a candidate — lower is better. Penalises covering the target or overflow. */
function scorePlacement(
left: number, top: number, tw: number, th: number,
targetRect: DOMRect, vw: number, vh: number,
): number {
let score = 0;
// Overflow penalty
if (left < 0) score += Math.abs(left);
if (top < 0) score += Math.abs(top);
if (left + tw > vw) score += (left + tw - vw);
if (top + th > vh) score += (top + th - vh);
// Overlap with target penalty (avoid covering the highlighted element)
const overlapX = Math.max(0, Math.min(left + tw, targetRect.right) - Math.max(left, targetRect.left));
const overlapY = Math.max(0, Math.min(top + th, targetRect.bottom) - Math.max(top, targetRect.top));
if (overlapX > 0 && overlapY > 0) score += overlapX * overlapY * 0.01;
return score;
}
function findBestPlacement(
targetRect: DOMRect, tw: number, th: number,
preferred: TooltipStep["placement"],
): PlacementResult {
const gap = 144;
const vw = window.innerWidth;
const vh = window.innerHeight;
const all: Array<TooltipStep["placement"]> = [
preferred ?? "bottom",
...(["bottom", "top", "right", "left"] as const).filter((p) => p !== (preferred ?? "bottom")),
];
let best: PlacementResult = { left: 0, top: 0, actualPlacement: "bottom" };
let bestScore = Infinity;
for (const p of all) {
let left = 0, top = 0;
switch (p) {
case "bottom":
left = targetRect.left + targetRect.width / 2 - tw / 2;
top = targetRect.bottom + gap;
break;
case "top":
left = targetRect.left + targetRect.width / 2 - tw / 2;
top = targetRect.top - th - gap;
break;
case "right":
left = targetRect.right + gap;
top = targetRect.top + targetRect.height / 2 - th / 2;
break;
case "left":
left = targetRect.left - tw - gap;
top = targetRect.top + targetRect.height / 2 - th / 2;
break;
}
left = Math.max(12, Math.min(left, vw - tw - 12));
top = Math.max(12, Math.min(top, vh - th - 12));
const s = scorePlacement(left, top, tw, th, targetRect, vw, vh);
if (s < bestScore) {
bestScore = s;
best = { left, top, actualPlacement: p };
}
if (s === 0) break; // perfect
}
return best;
}
// ─── Component ────────────────────────────────────────────────
export default function OnboardingTour({
active, phase, stepIndex, onNext, onSkip, onDone,
}: OnboardingTourProps) {
const [pos, setPos] = useState<PlacementResult>({ left: 0, top: 0, actualPlacement: "bottom" });
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
const [visible, setVisible] = useState(false);
const [connector, setConnector] = useState<ConnectorPoints | null>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const prevPhaseRef = useRef(phase);
const prevStepRef = useRef(stepIndex);
const phaseDef = PHASES[phase];
const currentStep = phaseDef?.steps[stepIndex];
const totalSteps = phaseDef?.steps.length ?? 0;
const isLastStep = stepIndex >= totalSteps - 1;
const isVideoLastStep = phase === "video" && isLastStep;
const stepChanged = prevPhaseRef.current !== phase || prevStepRef.current !== stepIndex;
prevPhaseRef.current = phase;
prevStepRef.current = stepIndex;
const recalc = useCallback(() => {
if (!currentStep) return;
const el = document.querySelector(`[data-onboarding="${currentStep.target}"]`) as HTMLElement | null;
if (!el) return; // Will be retried by the polling loop
const rect = el.getBoundingClientRect();
setTargetRect(rect);
const tooltip = tooltipRef.current;
if (!tooltip) return;
const tr = tooltip.getBoundingClientRect();
const best = findBestPlacement(rect, tr.width, tr.height, currentStep.placement);
setPos(best);
// Recalculate tooltip rect after position update (use the same best pos)
const virtualTooltipRect = new DOMRect(best.left, best.top, tr.width, tr.height);
setConnector(calcConnector(virtualTooltipRect, rect, best.actualPlacement));
}, [currentStep]);
useEffect(() => {
if (!active) { setVisible(false); return; }
const t = setTimeout(() => { setVisible(true); recalc(); }, 120);
return () => clearTimeout(t);
}, [active, phase, stepIndex, recalc]);
// Reposition and retry when elements aren't ready
useEffect(() => {
if (!active || !visible) return;
const h = () => recalc();
window.addEventListener("resize", h);
window.addEventListener("scroll", h, true);
const obs = new MutationObserver(h);
obs.observe(document.body, { childList: true, subtree: true, attributes: true });
// Polling retry: keep looking for the target element if not found yet
let retryId: number | null = null;
let attempts = 0;
const poll = () => {
recalc();
attempts += 1;
if (attempts < 40) retryId = requestAnimationFrame(poll);
};
// Start polling after a short delay
const startTimer = setTimeout(() => { poll(); }, 200);
return () => {
window.removeEventListener("resize", h);
window.removeEventListener("scroll", h, true);
obs.disconnect();
clearTimeout(startTimer);
if (retryId !== null) cancelAnimationFrame(retryId);
};
}, [active, visible, recalc]);
// Animate in on step change
useEffect(() => {
if (!active || !visible || !stepChanged) return;
const el = tooltipRef.current;
if (!el) return;
el.classList.remove("onboarding-tooltip--pop");
void el.offsetWidth; // force reflow
el.classList.add("onboarding-tooltip--pop");
}, [active, visible, stepChanged, phase, stepIndex]);
if (!active || !currentStep) return null;
const connectorPath = connector
? `M ${connector.x1} ${connector.y1} L ${connector.x2} ${connector.y2}`
: "";
const arrowAngle = connector
? Math.atan2(connector.y2 - connector.y1, connector.x2 - connector.x1) * (180 / Math.PI)
: 0;
const clipPath = targetRect
? `polygon(0% 0%, 0% 100%, ${targetRect.left - 6}px 100%, ${targetRect.left - 6}px ${targetRect.top - 6}px, ${targetRect.right + 6}px ${targetRect.top - 6}px, ${targetRect.right + 6}px ${targetRect.bottom + 6}px, ${targetRect.left - 6}px ${targetRect.bottom + 6}px, ${targetRect.left - 6}px 100%, 100% 100%, 100% 0%)`
: "";
return createPortal(
<div className={`onboarding-root${visible ? " is-visible" : ""}`} aria-label="新手引导教程">
{/* Overlay */}
<div className="onboarding-overlay" style={{ clipPath, WebkitClipPath: clipPath }} />
{/* Spotlight ring */}
{targetRect && (
<div
className="onboarding-spotlight"
style={{
left: targetRect.left - 8,
top: targetRect.top - 8,
width: targetRect.width + 16,
height: targetRect.height + 16,
}}
>
{/* Animated pulse ring */}
<div className="onboarding-spotlight__pulse" />
<div className="onboarding-spotlight__pulse onboarding-spotlight__pulse--delay" />
</div>
)}
{/* Connector SVG line */}
{connector && (
<svg className="onboarding-connector" aria-hidden="true">
<defs>
<linearGradient id="ob-conn-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="var(--accent, #00ff88)" stopOpacity="0.2" />
<stop offset="100%" stopColor="var(--accent, #00ff88)" stopOpacity="0.9" />
</linearGradient>
</defs>
{/* Animated dash line */}
<path
d={connectorPath}
fill="none"
stroke="var(--accent, #00ff88)"
strokeWidth="2"
strokeDasharray="8 4"
strokeLinecap="round"
opacity="0.7"
className="onboarding-connector__path"
/>
{/* Arrow at target end */}
<circle
cx={connector.x2}
cy={connector.y2}
r="5"
fill="var(--accent, #00ff88)"
className="onboarding-connector__dot"
/>
</svg>
)}
{/* Tooltip card */}
<div
ref={tooltipRef}
className={`onboarding-tooltip onboarding-tooltip--${pos.actualPlacement}`}
style={{ left: pos.left, top: pos.top }}
role="dialog"
aria-label={currentStep.title}
>
{/* Arrow pointing toward target */}
<div
className={`onboarding-tooltip__arrow onboarding-tooltip__arrow--${pos.actualPlacement}`}
style={{ transform: `rotate(${arrowAngle}deg)` }}
/>
<div className="onboarding-tooltip__head">
<span className="onboarding-tooltip__phase-badge">{phaseDef.label}</span>
<span className="onboarding-tooltip__counter">
{stepIndex + 1} / {totalSteps}
</span>
</div>
<strong className="onboarding-tooltip__title">{currentStep.title}</strong>
<p className="onboarding-tooltip__desc">{currentStep.description}</p>
<div className="onboarding-tooltip__actions">
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--ghost" onClick={onDone}>
<CloseOutlined />
</button>
{stepIndex > 0 && (
<button
type="button"
className="onboarding-tooltip__btn onboarding-tooltip__btn--ghost"
onClick={() => onNext(phase, stepIndex - 1)}
>
<LeftOutlined />
</button>
)}
{isVideoLastStep ? (
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={onDone}>
使 <RightOutlined />
</button>
) : isLastStep && phase !== "video" ? (
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={() => onSkip(phase)}>
{phase === "chat" ? "进入图像生成" : "进入视频生成"} <RightOutlined />
</button>
) : (
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={() => onNext(phase, stepIndex + 1)}>
<RightOutlined />
</button>
)}
</div>
</div>
{/* Bottom progress bar */}
<div className="onboarding-progress" aria-hidden="true">
{(["chat", "image", "video"] as TourPhaseId[]).map((p) => (
<div key={p} className="onboarding-progress__phase">
<div
className={`onboarding-progress__dot${p === phase ? " is-active" : ""}${
(["chat", "image", "video"].indexOf(p) < ["chat", "image", "video"].indexOf(phase)) ? " is-done" : ""
}`}
/>
<span>{PHASES[p].label}</span>
</div>
))}
</div>
</div>,
document.body,
);
}
@@ -29,7 +29,7 @@ const membershipPlans: MembershipPlan[] = [
subtitle: "Pro",
period: "月付",
price: "299 元 / 月",
grant: "每月赠送 10000 积分,30 天有效",
grant: "每月赠送 29900 积分,30 天有效",
comparisonLabel: "专业版基础权益",
icon: <CrownOutlined />,
benefits: ["通用大模型全解锁", "积分与 API 消耗 9 折", "并发提升到 3 个", "去水印、插队加速、专属客服"],
@@ -41,7 +41,7 @@ const membershipPlans: MembershipPlan[] = [
subtitle: "Pro",
period: "季付",
price: "897 元 / 季",
grant: "连续 3 个月按月发放 Pro 积分",
grant: "季度合计 89700 积分,默认按月分摊",
comparisonLabel: "相比月付新增",
badge: "季度",
icon: <CrownOutlined />,
@@ -54,7 +54,7 @@ const membershipPlans: MembershipPlan[] = [
subtitle: "Pro",
period: "年付",
price: "1990 元 / 年",
grant: "全年合计 140000 积分,默认按月分摊",
grant: "全年合计 199000 积分,默认按月分摊",
comparisonLabel: "相比季付新增",
badge: "年费优惠",
icon: <CrownOutlined />,
@@ -67,7 +67,7 @@ const membershipPlans: MembershipPlan[] = [
subtitle: "Enterprise",
period: "月付",
price: "499 元 / 月",
grant: "每月赠送 2000 积分,30 天有效",
grant: "每月赠送 49900 积分,30 天有效",
comparisonLabel: "企业版基础权益",
icon: <RocketOutlined />,
benefits: ["企业私有模型与高性能模型", "默认 10 并发,可申请提升", "积分与 API 消耗 8 折", "用量报表与正式 API 权限"],
@@ -79,7 +79,7 @@ const membershipPlans: MembershipPlan[] = [
subtitle: "Enterprise",
period: "季付",
price: "1497 元 / 季",
grant: "连续 3 个月按月发放企业版积分",
grant: "季度合计 149700 积分,默认按月分摊",
comparisonLabel: "相比月付新增",
badge: "季度",
icon: <RocketOutlined />,
@@ -92,7 +92,7 @@ const membershipPlans: MembershipPlan[] = [
subtitle: "Enterprise",
period: "年付",
price: "4990 元 / 年",
grant: "全年合计 340000 积分,默认按月分摊",
grant: "全年合计 499000 积分,默认按月分摊",
comparisonLabel: "相比季付新增",
badge: "企业年费",
icon: <RocketOutlined />,
+21 -2
View File
@@ -10,7 +10,7 @@ import {
SearchOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type JSX } from "react";
import "../../styles/pages/assets.css";
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
import { aiGenerationClient } from "../../api/aiGenerationClient";
@@ -95,6 +95,17 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; asset: LibraryAssetItem } | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const [isUploadDragging, setIsUploadDragging] = useState(false);
const handleUploadDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsUploadDragging(true); };
const handleUploadDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsUploadDragging(false); };
const handleUploadDrop = (e: DragEvent) => {
e.preventDefault();
setIsUploadDragging(false);
if (e.dataTransfer.files.length) {
void handleUploadFiles(e.dataTransfer.files);
}
};
const handleContextMenu = useCallback((e: React.MouseEvent, asset: LibraryAssetItem) => {
e.preventDefault();
@@ -270,7 +281,15 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
placeholder="搜索资产..."
/>
</label>
<button type="button" className="studio-generate-btn studio-generate-btn--compact" onClick={() => uploadInputRef.current?.click()} disabled={isUploading}>
<button
type="button"
className={`studio-generate-btn studio-generate-btn--compact${isUploadDragging ? " is-dragging" : ""}`}
onClick={() => uploadInputRef.current?.click()}
onDragOver={handleUploadDragOver}
onDragLeave={handleUploadDragLeave}
onDrop={handleUploadDrop}
disabled={isUploading}
>
{isUploading ? <LoadingOutlined /> : <PlusOutlined />}
{isUploading ? "上传中..." : "添加"}
</button>
@@ -0,0 +1,298 @@
import {
CheckCircleOutlined,
CloseCircleOutlined,
ExperimentOutlined,
FileSearchOutlined,
LoginOutlined,
ReloadOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useState } from "react";
import { betaApplicationClient, type BetaApplicationItem, type BetaApplicationStatus } from "../../api/betaApplicationClient";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import type { WebUserSession } from "../../types";
import "../../styles/pages/beta-applications.css";
interface BetaApplicationsPageProps {
session: WebUserSession | null;
onOpenLogin: () => void;
}
type StatusFilter = BetaApplicationStatus | "";
const STATUS_OPTIONS: Array<{ value: StatusFilter; label: string }> = [
{ value: "pending", label: "待审核" },
{ value: "approved", label: "已通过" },
{ value: "rejected", label: "已驳回" },
{ value: "", label: "全部" },
];
const STATUS_LABEL: Record<BetaApplicationStatus, string> = {
pending: "待审核",
approved: "已通过",
rejected: "已驳回",
};
function canReviewBetaApplications(session: WebUserSession | null): boolean {
const role = String(session?.user.role || "").trim().toLowerCase();
const username = String(session?.user.username || "").trim().toLowerCase();
return role === "admin" || username === "xqy1912";
}
function formatDate(value?: string | null): string {
if (!value) return "暂无时间";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function valueOrEmpty(value?: string | null): string {
return value?.trim() || "未填写";
}
function joinValues(values: string[]): string {
return values.length ? values.join("、") : "未选择";
}
function DetailField({ label, value, wide }: { label: string; value: string; wide?: boolean }) {
return (
<div className={`beta-admin-field${wide ? " beta-admin-field--wide" : ""}`}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export default function BetaApplicationsPage({ session, onOpenLogin }: BetaApplicationsPageProps) {
const allowed = canReviewBetaApplications(session);
const [status, setStatus] = useState<StatusFilter>("pending");
const [applications, setApplications] = useState<BetaApplicationItem[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [reviewNote, setReviewNote] = useState("");
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedApplication = useMemo(
() => applications.find((item) => item.id === selectedId) ?? applications[0] ?? null,
[applications, selectedId],
);
const load = useCallback(async () => {
if (!allowed) return;
setLoading(true);
setError(null);
try {
const items = await betaApplicationClient.listAdminApplications(status);
setApplications(items);
setSelectedId((current) =>
current && items.some((item) => item.id === current) ? current : (items[0]?.id ?? null),
);
} catch (loadError) {
setApplications([]);
setError(loadError instanceof Error ? loadError.message : "内测申请列表加载失败");
} finally {
setLoading(false);
}
}, [allowed, status]);
useEffect(() => {
void load();
}, [load]);
const handleDecision = async (action: "approve" | "reject") => {
if (!selectedApplication || selectedApplication.status !== "pending" || submitting) return;
setSubmitting(true);
setError(null);
try {
await betaApplicationClient.reviewApplication(selectedApplication.id, action, reviewNote.trim());
setReviewNote("");
await load();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "审核操作失败");
} finally {
setSubmitting(false);
}
};
if (!session) {
return (
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
<section className="beta-admin-access">
<LoginOutlined />
<h1></h1>
<p> xqy1912</p>
<button type="button" onClick={onOpenLogin}> / </button>
</section>
</WorkspacePageShell>
);
}
if (!allowed) {
return (
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
<section className="beta-admin-access">
<FileSearchOutlined />
<h1></h1>
<p> admin xqy1912 </p>
</section>
</WorkspacePageShell>
);
}
return (
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
<div className="beta-admin-page__inner">
<section className="beta-admin-toolbar">
<div>
<span></span>
<h1></h1>
<p></p>
</div>
<button type="button" onClick={() => void load()} disabled={loading}>
<ReloadOutlined />
</button>
</section>
<div className="beta-admin-status-tabs" role="tablist" aria-label="内测申请状态">
{STATUS_OPTIONS.map((option) => (
<button
key={option.value || "all"}
type="button"
role="tab"
aria-selected={status === option.value}
className={status === option.value ? "is-active" : ""}
onClick={() => setStatus(option.value)}
>
{option.label}
</button>
))}
</div>
{error ? <p className="beta-admin-error">{error}</p> : null}
<section className="beta-admin-layout">
<aside className="beta-admin-list" aria-label="内测申请列表">
{loading ? <div className="beta-admin-list__empty">...</div> : null}
{!loading && applications.length === 0 ? (
<div className="beta-admin-list__empty"></div>
) : null}
{applications.map((item) => (
<button
key={item.id}
type="button"
className={`beta-admin-list__item${item.id === selectedApplication?.id ? " is-active" : ""}`}
onClick={() => setSelectedId(item.id)}
>
<span className={`beta-admin-status beta-admin-status--${item.status}`}>{STATUS_LABEL[item.status]}</span>
<strong>{item.name || item.username || `申请 #${item.id}`}</strong>
<small>{item.industry || "未填写行业"} · {formatDate(item.createdAt)}</small>
</button>
))}
</aside>
{selectedApplication ? (
<article className="beta-admin-detail">
<header className="beta-admin-detail__header">
<div>
<span><ExperimentOutlined /> {STATUS_LABEL[selectedApplication.status]}</span>
<h2>{selectedApplication.name || "未填写姓名"}</h2>
<p>{selectedApplication.selfStatement || "申请人未填写自述。"}</p>
</div>
{selectedApplication.inviteCode ? (
<strong className="beta-admin-code">{selectedApplication.inviteCode}</strong>
) : null}
</header>
<section className="beta-admin-form-card">
<h3></h3>
<div className="beta-admin-field-grid">
<DetailField label="姓名 / 常用昵称" value={valueOrEmpty(selectedApplication.name)} />
<DetailField label="接收内测码邮箱" value={valueOrEmpty(selectedApplication.email)} />
<DetailField label="联系手机号码" value={valueOrEmpty(selectedApplication.phone)} />
<DetailField label="微信账号" value={valueOrEmpty(selectedApplication.wechat)} />
<DetailField label="所在行业 / 职业" value={valueOrEmpty(selectedApplication.industry)} />
<DetailField label="所属公司 / 机构" value={valueOrEmpty(selectedApplication.company)} />
<DetailField label="所在城市" value={valueOrEmpty(selectedApplication.city)} />
<DetailField label="关联账号" value={selectedApplication.username || `UID ${selectedApplication.userId ?? "未登录提交"}`} />
<DetailField label="提交时间" value={formatDate(selectedApplication.createdAt)} />
</div>
</section>
<section className="beta-admin-form-card">
<h3>AI 使</h3>
<div className="beta-admin-field-grid">
<DetailField label="常用 AI 创作工具" value={valueOrEmpty(selectedApplication.aiTools)} wide />
<DetailField label="AI 内容创作从业时长" value={valueOrEmpty(selectedApplication.aiDuration)} />
<DetailField label="是否深耕相关赛道" value={valueOrEmpty(selectedApplication.aiTrack)} />
<DetailField label="日常主要创作方向" value={joinValues(selectedApplication.aiDirection)} wide />
</div>
</section>
<section className="beta-admin-form-card">
<h3>使</h3>
<div className="beta-admin-field-grid">
<DetailField label="每周稳定使用次数" value={valueOrEmpty(selectedApplication.weeklyUsage)} />
<DetailField label="反馈意愿" value={valueOrEmpty(selectedApplication.feedbackWilling)} />
<DetailField label="最想体验功能" value={joinValues(selectedApplication.wantFeature)} wide />
</div>
</section>
<section className="beta-admin-form-card">
<h3></h3>
<p className="beta-admin-statement">{selectedApplication.selfStatement || "未填写"}</p>
<div className="beta-admin-field-grid">
<DetailField label="申请人确认签字" value={valueOrEmpty(selectedApplication.signature)} />
<DetailField label="申请填写日期" value={valueOrEmpty(selectedApplication.applicationDate)} />
<DetailField label="同意规则" value={selectedApplication.agreeRules ? "已同意" : "未同意"} />
<DetailField label="IP" value={valueOrEmpty(selectedApplication.ipAddress)} />
<DetailField label="客户端" value={valueOrEmpty(selectedApplication.userAgent)} wide />
</div>
</section>
{selectedApplication.status !== "pending" ? (
<section className="beta-admin-form-card">
<h3></h3>
<div className="beta-admin-field-grid">
<DetailField label="审核人" value={selectedApplication.reviewerUsername || `UID ${selectedApplication.reviewedBy ?? "-"}`} />
<DetailField label="审核时间" value={formatDate(selectedApplication.reviewedAt)} />
<DetailField label="审核备注" value={valueOrEmpty(selectedApplication.reviewNote)} wide />
</div>
</section>
) : (
<section className="beta-admin-review-box">
<label>
<span></span>
<textarea
value={reviewNote}
onChange={(event) => setReviewNote(event.target.value)}
placeholder="填写通过说明或驳回原因;驳回时该备注会作为用户通知内容。"
/>
</label>
<div className="beta-admin-actions">
<button type="button" disabled={submitting} onClick={() => void handleDecision("reject")}>
<CloseCircleOutlined />
</button>
<button type="button" disabled={submitting} onClick={() => void handleDecision("approve")}>
<CheckCircleOutlined />
</button>
</div>
</section>
)}
</article>
) : (
<div className="beta-admin-detail beta-admin-detail--empty"></div>
)}
</section>
</div>
</WorkspacePageShell>
);
}
@@ -61,6 +61,9 @@ function CharacterMixPage({
const abortRef = useRef(false);
const taskIdRef = useRef<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
const characterInputRef = useRef<HTMLInputElement | null>(null);
const videoInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
return () => {
@@ -262,6 +265,23 @@ function CharacterMixPage({
}
};
const handleCanvasDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsCanvasDragging(true); };
const handleCanvasDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsCanvasDragging(false); };
const handleCanvasDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsCanvasDragging(false);
handleDrop(e);
};
const handleCanvasClick = () => {
if (!characterPreview) {
characterInputRef.current?.click();
} else if (!videoPreview) {
videoInputRef.current?.click();
}
};
return (
<section className="image-workbench-page character-mix-page" aria-label="角色迁移">
<header className="image-workbench-topbar">
@@ -342,6 +362,7 @@ function CharacterMixPage({
<div className="studio-panel__section-body">
<label className={characterFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
<input
ref={characterInputRef}
type="file"
accept="image/*"
onChange={(event) => {
@@ -383,6 +404,7 @@ function CharacterMixPage({
<div className="studio-panel__section-body">
<label className={videoFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
<input
ref={videoInputRef}
type="file"
accept="video/*"
onChange={(event) => {
@@ -441,12 +463,21 @@ function CharacterMixPage({
) : null}
</div>
) : (
<div className="studio-canvas-ghost">
<div
className={`studio-canvas-ghost${isCanvasDragging ? " is-dragging" : ""}`}
onClick={handleCanvasClick}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCanvasClick(); }}
>
<div className="studio-canvas-ghost__icon">
<SwapOutlined />
</div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"></div>
<div className="studio-canvas-ghost__hint"> (PNG/JPG) (MP4/MOV/AVI)</div>
</div>
)
}
@@ -6,10 +6,11 @@ import {
PictureOutlined,
UploadOutlined,
} from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { communityClient } from "../../api/communityClient";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import "../../styles/pages/compliance.css";
import type { WebCanvasWorkflow, WebUserSession } from "../../types";
import { getWorkflowCoverUrl, isCanvasWorkflow } from "../community/communityCaseUtils";
import { canManageCommunityCases } from "./communityPermissions";
@@ -72,6 +73,29 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
const allowed = canManageCommunityCases(session);
const imageInputRef = useRef<HTMLInputElement | null>(null);
const workflowInputRef = useRef<HTMLInputElement | null>(null);
const [isImageDragging, setIsImageDragging] = useState(false);
const [isWorkflowDragging, setIsWorkflowDragging] = useState(false);
const handleImageDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsImageDragging(true); };
const handleImageDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsImageDragging(false); };
const handleImageDrop = (e: DragEvent) => {
e.preventDefault();
setIsImageDragging(false);
if (e.dataTransfer.files.length) {
void handleImageChange({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
const handleWorkflowDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsWorkflowDragging(true); };
const handleWorkflowDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsWorkflowDragging(false); };
const handleWorkflowDrop = (e: DragEvent) => {
e.preventDefault();
setIsWorkflowDragging(false);
if (e.dataTransfer.files.length) {
void handleWorkflowChange({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
const [target, setTarget] = useState<CaseTarget>("generation");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
@@ -330,7 +354,14 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
</label>
<div className="community-case-add-upload-row">
<input ref={imageInputRef} type="file" accept="image/*" hidden onChange={handleImageChange} />
<button type="button" onClick={() => imageInputRef.current?.click()}>
<button
type="button"
className={isImageDragging ? "is-dragging" : ""}
onClick={() => imageInputRef.current?.click()}
onDragOver={handleImageDragOver}
onDragLeave={handleImageDragLeave}
onDrop={handleImageDrop}
>
<UploadOutlined />
</button>
@@ -344,7 +375,14 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
<>
<div className="community-case-add-upload-row">
<input ref={workflowInputRef} type="file" accept="application/json,.json" hidden onChange={handleWorkflowChange} />
<button type="button" onClick={() => workflowInputRef.current?.click()}>
<button
type="button"
className={isWorkflowDragging ? "is-dragging" : ""}
onClick={() => workflowInputRef.current?.click()}
onDragOver={handleWorkflowDragOver}
onDragLeave={handleWorkflowDragLeave}
onDrop={handleWorkflowDrop}
>
<UploadOutlined />
JSON
</button>
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
import { reportClient, type AdminReportItem } from "../../api/reportClient";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import "../../styles/pages/compliance.css";
import type { WebUserSession } from "../../types";
import { canManageCommunityCases, canReviewCommunity } from "./communityPermissions";
@@ -98,6 +98,10 @@ function DigitalHumanPage({
activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
const [isDragging, setIsDragging] = useState(false);
const imageInputRef = useRef<HTMLInputElement | null>(null);
const audioInputRef = useRef<HTMLInputElement | null>(null);
const canvasDragCounterRef = useRef(0);
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
useEffect(() => {
return () => {
@@ -171,6 +175,39 @@ function DigitalHumanPage({
}
};
const handleCanvasDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsCanvasDragging(true); };
const handleCanvasDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsCanvasDragging(false); };
const handleCanvasDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsCanvasDragging(false);
const file = e.dataTransfer.files[0];
if (!file) return;
if (file.type.startsWith("image/")) {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImageName(file.name);
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
pushDebugEntry("选择图片", `${file.name} / ${file.type || "unknown"} / ${formatFileSize(file.size)}`);
setNotice(`已拖放参考图 ${file.name}`);
} else if (file.type.startsWith("audio/")) {
if (audioPreview) URL.revokeObjectURL(audioPreview);
setAudioName(file.name);
setAudioFile(file);
setAudioPreview(URL.createObjectURL(file));
pushDebugEntry("选择音频", `${file.name} / ${file.type || "unknown"} / ${formatFileSize(file.size)}`);
setNotice(`已拖放音频 ${file.name}`);
}
};
const handleCanvasClick = () => {
if (!imagePreview) {
imageInputRef.current?.click();
} else if (!audioPreview) {
audioInputRef.current?.click();
}
};
const handleDownloadResult = async () => {
if (!resultVideoUrl || isDownloadingResult) return;
setIsDownloadingResult(true);
@@ -463,6 +500,7 @@ function DigitalHumanPage({
<div className="studio-panel__section-body">
<label className={imageName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
<input
ref={imageInputRef}
type="file"
accept="image/*"
onChange={(event) => {
@@ -501,6 +539,7 @@ function DigitalHumanPage({
<div className="studio-panel__section-body">
<label className={audioName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
<input
ref={audioInputRef}
type="file"
accept="audio/*"
onChange={(event) => {
@@ -541,12 +580,21 @@ function DigitalHumanPage({
<img src={imagePreview} alt="参考人像" />
</div>
) : (
<div className="studio-canvas-ghost">
<div
className={`studio-canvas-ghost${isCanvasDragging ? " is-dragging" : ""}`}
onClick={handleCanvasClick}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCanvasClick(); }}
>
<div className="studio-canvas-ghost__icon">
<CustomerServiceOutlined />
</div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"></div>
<div className="studio-canvas-ghost__hint"> (PNG/JPG/WEBP) (MP3/WAV/M4A)</div>
</div>
)
}
+5 -4
View File
@@ -988,6 +988,10 @@ 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 cloneRequirementPlaceholder =
cloneOutput === "model"
? "建议包含以下信息:产品名称、核心卖点、期望场景、模特外貌描写(如小麦色皮肤、齐刘海、眼角有泪痣)、具体参数"
: "建议包含以下信息,产品名称,核心卖点,期望场景,具体参数";
const productSetPreviewReady = productSetStatus === "done";
const cloneSetTotal = useMemo(
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
@@ -1934,7 +1938,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
age: cloneModelAge,
ethnicity: cloneModelEthnicity,
body: cloneModelBody,
appearance: cloneModelAppearance,
scenes: selectedCloneModelScenes,
customScene: cloneModelCustomScene,
}
@@ -2225,7 +2228,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
cloneModelSelects={cloneModelSelects}
openCloneModelSelect={openCloneModelSelect}
cloneModelSelectDropUp={cloneModelSelectDropUp}
cloneModelAppearance={cloneModelAppearance}
cloneVideoQuality={cloneVideoQuality}
cloneVideoQualityOptions={cloneVideoQualityOptions}
cloneVideoDuration={cloneVideoDuration}
@@ -2257,7 +2259,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setCloneModelCustomScene={setCloneModelCustomScene}
setOpenCloneModelSelect={setOpenCloneModelSelect}
setCloneModelSelectDropUp={setCloneModelSelectDropUp}
setCloneModelAppearance={setCloneModelAppearance}
setCloneVideoQuality={setCloneVideoQuality}
setCloneVideoDuration={setCloneVideoDuration}
clampCloneVideoDuration={clampCloneVideoDuration}
@@ -2620,7 +2621,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
if (event.key === "Escape") setRequirementImageMentionQuery(null);
}}
maxLength={500}
placeholder="建议包含以下信息,产品名称,核心卖点,期望场景,具体参数"
placeholder={cloneRequirementPlaceholder}
/>
{requirementImageMentionQuery !== null && ecommerceMentionImages.length ? (
<ImageMentionMenu images={ecommerceMentionImages} query={requirementImageMentionQuery} onSelect={insertRequirementImageMention} />
@@ -100,7 +100,6 @@ interface EcommerceClonePanelProps {
cloneModelSelects: CloneModelSelectItem[];
openCloneModelSelect: CloneModelSelectKey | null;
cloneModelSelectDropUp: boolean;
cloneModelAppearance: string;
cloneVideoQuality: CloneVideoQualityKey;
cloneVideoQualityOptions: CloneVideoQualityOption[];
cloneVideoDuration: number;
@@ -132,7 +131,6 @@ interface EcommerceClonePanelProps {
setCloneModelCustomScene: (value: string) => void;
setOpenCloneModelSelect: (value: CloneModelSelectKey | null) => void;
setCloneModelSelectDropUp: (value: boolean) => void;
setCloneModelAppearance: (value: string) => void;
setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
setCloneVideoDuration: (value: number) => void;
clampCloneVideoDuration: (value: number) => number;
@@ -172,7 +170,6 @@ export default function EcommerceClonePanel({
cloneModelSelects,
openCloneModelSelect,
cloneModelSelectDropUp,
cloneModelAppearance,
cloneVideoQuality,
cloneVideoQualityOptions,
cloneVideoDuration,
@@ -204,7 +201,6 @@ export default function EcommerceClonePanel({
setCloneModelCustomScene,
setOpenCloneModelSelect,
setCloneModelSelectDropUp,
setCloneModelAppearance,
setCloneVideoQuality,
setCloneVideoDuration,
clampCloneVideoDuration,
@@ -668,14 +664,6 @@ export default function EcommerceClonePanel({
);
})}
</div>
<label className="clone-ai-model-textarea">
<strong></strong>
<textarea
value={cloneModelAppearance}
onChange={(event) => setCloneModelAppearance(event.target.value)}
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
/>
</label>
</div>
)}
</div>
@@ -758,7 +746,7 @@ export default function EcommerceClonePanel({
style={{ display: "none" }}
/>
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitVideoRef.current?.click()}>
{videoOutfitVideoUrl ? "重新选择视频" : "选择视频文件"}
{videoOutfitVideoUrl ? "重新上传视频" : "点击上传视频"}
</button>
{videoOutfitVideoUrl ? <span className="clone-ai-video-outfit-info"></span> : null}
</div>
@@ -774,7 +762,7 @@ export default function EcommerceClonePanel({
style={{ display: "none" }}
/>
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitRefRef.current?.click()}>
{videoOutfitRefUrl ? "重新选择参考图" : "选择参考图"}
{videoOutfitRefUrl ? "重新上传参考图" : "点击上传参考图"}
</button>
{videoOutfitRefUrl ? <span className="clone-ai-video-outfit-info"></span> : null}
</div>
@@ -1,5 +1,5 @@
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import type { ChangeEvent, RefObject } from "react";
import { useState, type ChangeEvent, type DragEvent, type RefObject } from "react";
import { EcommerceProgressBar } from "../EcommerceProgressBar";
interface EcommerceDetailPanelProps {
@@ -59,6 +59,31 @@ export default function EcommerceDetailPanel({
handleDetailGenerate,
onCancelGenerate,
}: EcommerceDetailPanelProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes("Files")) setIsDragging(true);
};
const handleDragLeave = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragging(false);
}
};
const handleDrop = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files.length) {
handleDetailUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
return (
<>
<div className="product-clone-panel__scroll">
@@ -67,7 +92,14 @@ export default function EcommerceDetailPanel({
<QuestionCircleOutlined />
</h2>
<button type="button" className="product-clone-upload-zone product-detail-upload" onClick={() => detailInputRef.current?.click()}>
<button
type="button"
className={`product-clone-upload-zone product-detail-upload${isDragging ? " is-dragging" : ""}`}
onClick={() => detailInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<strong>
<CloudUploadOutlined />
@@ -1,5 +1,5 @@
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import type { ChangeEvent, RefObject } from "react";
import { useState, type ChangeEvent, type DragEvent, type RefObject } from "react";
import { EcommerceProgressBar } from "../EcommerceProgressBar";
interface EcommerceTryOnPanelProps {
@@ -73,12 +73,44 @@ export default function EcommerceTryOnPanel({
handleTryOnGenerate,
onCancelGenerate,
}: EcommerceTryOnPanelProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes("Files")) setIsDragging(true);
};
const handleDragLeave = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragging(false);
}
};
const handleDrop = (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files.length) {
handleGarmentUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
return (
<>
<div className="product-clone-panel__scroll">
<section className="product-clone-field">
<h2></h2>
<button type="button" className="product-clone-upload-zone product-try-on-upload" onClick={() => garmentInputRef.current?.click()}>
<button
type="button"
className={`product-clone-upload-zone product-try-on-upload${isDragging ? " is-dragging" : ""}`}
onClick={() => garmentInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<strong>
<CloudUploadOutlined />
+26 -64
View File
@@ -1,11 +1,9 @@
import {
ArrowRightOutlined,
DashboardOutlined,
FileSearchOutlined,
PlayCircleOutlined,
PlusOutlined,
ShoppingOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
@@ -15,7 +13,6 @@ import "../../styles/pages/home.css";
import WelcomeSplash from "./WelcomeSplash";
import ToolboxSection from "./ToolboxSection";
import ScriptReviewShowcase from "./ScriptReviewShowcase";
import ModelGenerationShowcase from "./ModelGenerationShowcase";
function ScrollEntrance({ children, className, ...rest }: { children: React.ReactNode; className?: string } & React.HTMLAttributes<HTMLElement>) {
const { ref, isVisible } = useScrollEntrance<HTMLElement>();
@@ -30,11 +27,11 @@ const [heroImage1, heroImage2, heroImage3] = ossAssets.home.heroSlides;
const {
ecommerce: featureEcommerceImage,
script: featureScriptImage,
token: featureTokenImage,
} = ossAssets.home.features;
interface HomePageProps {
onOpenGenerate: () => void;
onStartOnboarding?: () => void;
onOpenCanvas?: () => void;
onOpenEcommerce: () => void;
onOpenScriptReview?: () => void;
@@ -52,16 +49,6 @@ const HOME_CAROUSEL_IMAGES = [
];
const HOME_FEATURES = [
{
key: "model",
eyebrow: "AI Generation",
title: "模型生成",
description: "通过AI模型生成文本、图片、视频,三种模式覆盖全内容类型,Agent对话式交互智能产出。",
imageUrl: featureTokenImage,
actionLabel: "开始生成",
icon: <ThunderboltOutlined />,
stats: ["文本生成", "图片生成", "视频生成"],
},
{
key: "ecommerce",
eyebrow: "AI Commerce",
@@ -84,13 +71,6 @@ const HOME_FEATURES = [
},
];
const HOME_EXPERIENCE_POINTS = [
{ label: "生成", meta: "图像 / 视频", tone: "green" },
{ label: "测评", meta: "剧本质量", tone: "cyan" },
{ label: "成本", meta: "Token 用量", tone: "violet" },
{ label: "电商", meta: "商品视觉", tone: "amber" },
];
const ECOMMERCE_MATRIX_FEATURES = [
{ icon: "⚡", title: "高效工作流", description: "自动化处理,一键触发" },
{ icon: "⊞", title: "矩阵式产出", description: "多场景、多尺寸批量生成" },
@@ -194,10 +174,18 @@ function getHomeCarouselCardStyle(offset: number): CSSProperties {
const depth = Math.abs(offset);
const direction = Math.sign(offset);
const isActive = depth === 0;
const xByDepth = [0, 190, 320, 430, 520, 590];
const xByDepth = [
"0px",
"clamp(52px, 13.5vw, 198px)",
"clamp(90px, 22.5vw, 334px)",
"clamp(122px, 30.5vw, 448px)",
"clamp(148px, 37vw, 542px)",
"clamp(170px, 42vw, 614px)",
];
const yByDepth = [8, -2, -8, -13, -18, -24];
const scaleByDepth = [1, 1, 1, 1, 1, 1];
const x = direction * (xByDepth[depth] ?? xByDepth[xByDepth.length - 1]!);
const xDistance = xByDepth[depth] ?? xByDepth[xByDepth.length - 1]!;
const x = direction < 0 ? `calc(0px - ${xDistance})` : xDistance;
const y = yByDepth[depth] ?? yByDepth[yByDepth.length - 1]!;
const z = isActive ? 90 : 28 - depth;
const scale = scaleByDepth[depth] ?? scaleByDepth[scaleByDepth.length - 1]!;
@@ -206,7 +194,7 @@ function getHomeCarouselCardStyle(offset: number): CSSProperties {
"--apple-card-offset": offset,
"--apple-card-depth": depth,
"--apple-card-z": 80 - depth,
"--apple-card-x": `${x}px`,
"--apple-card-x": x,
"--apple-card-y": `${y}px`,
"--apple-card-z-offset": `${z}px`,
"--apple-card-rotate-y": "0deg",
@@ -468,7 +456,7 @@ function EcommerceFeatureShowcase() {
);
}
function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor, onSelectView, onOpenImageTool }: HomePageProps) {
function HomePage({ onOpenGenerate, onStartOnboarding, 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);
@@ -620,17 +608,26 @@ function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptR
</div>
<div className="omni-home__actions" aria-label="首页入口">
<button type="button" className="omni-home__entry" onClick={onOpenGenerate}>
<button type="button" className="omni-home__entry" onClick={onStartOnboarding || onOpenGenerate}>
<PlusOutlined />
<span></span>
<span>
<small></small>
</span>
</button>
<button type="button" className="omni-home__entry omni-home__entry--primary" onClick={onOpenCanvas || onOpenGenerate}>
<PlayCircleOutlined />
<span></span>
<span>
<small></small>
</span>
</button>
<button type="button" className="omni-home__entry" onClick={onOpenEcommerce}>
<ShoppingOutlined />
<span></span>
<span>
<small></small>
</span>
</button>
</div>
</section>
@@ -656,8 +653,6 @@ function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptR
<div className="omni-home__feature-visual" aria-hidden={feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce"}>
{feature.key === "script" ? (
<ScriptReviewShowcase />
) : feature.key === "model" ? (
<ModelGenerationShowcase />
) : feature.key === "ecommerce" ? (
<EcommerceFeatureShowcase />
) : (
@@ -675,39 +670,6 @@ function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptR
))}
<ToolboxSection onSelectView={onSelectView} onOpenImageTool={onOpenImageTool} />
<section className="omni-home__experience" aria-label="点击体验">
<div className="omni-home__experience-copy">
<span>
<ThunderboltOutlined />
Click To Experience
</span>
<h2> OmniAI</h2>
<p></p>
</div>
<div className="omni-home__experience-visual" aria-hidden="true">
<div className="omni-home__experience-line is-top" />
<div className="omni-home__experience-line is-bottom" />
<div className="omni-home__experience-routes">
{HOME_EXPERIENCE_POINTS.map((point) => (
<span key={point.label} className={`omni-home__experience-route is-${point.tone}`}>
<b>{point.label}</b>
<small>{point.meta}</small>
</span>
))}
</div>
</div>
<div className="omni-home__experience-actions">
<button type="button" className="is-primary" onClick={onOpenGenerate}>
<PlayCircleOutlined />
</button>
<button type="button" onClick={onOpenEcommerce}>
<ShoppingOutlined />
</button>
</div>
</section>
</main>
</section>
</>
+21 -12
View File
@@ -1,12 +1,21 @@
import {
BgColorsOutlined,
CameraOutlined,
PictureOutlined,
PlayCircleOutlined,
RobotOutlined,
ShoppingOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState } from "react";
import "../../styles/pages/model-generation-showcase.css";
type ShowMode = "agent" | "image" | "video";
const MODE_TABS = [
{ key: "agent" as const, icon: "🤖", title: "Agent 模式", desc: "文本生成,对话式交互,智能推理" },
{ key: "image" as const, icon: "🖼️", title: "图片模式", desc: "图像生成,风格迁移,场景合成" },
{ key: "video" as const, icon: "🎬", title: "视频模式", desc: "视频生成,动态场景,数字人演绎" },
{ key: "agent" as const, icon: <RobotOutlined />, title: "Agent 模式", desc: "文本生成,对话式交互,智能推理" },
{ key: "image" as const, icon: <PictureOutlined />, title: "图片模式", desc: "图像生成,风格迁移,场景合成" },
{ key: "video" as const, icon: <VideoCameraOutlined />, title: "视频模式", desc: "视频生成,动态场景,数字人演绎" },
];
const AGENT_OUTPUTS = [
@@ -16,9 +25,9 @@ const AGENT_OUTPUTS = [
];
const IMAGE_OUTPUTS = [
{ tag: "Image", title: "写实风格", icon: "📷", styleClass: "realistic" },
{ tag: "Image", title: "插画风格", icon: "🎨", styleClass: "illustration" },
{ tag: "Image", title: "电商风格", icon: "🛍️", styleClass: "ecommerce" },
{ tag: "Image", title: "写实风格", icon: <CameraOutlined />, styleClass: "realistic" },
{ tag: "Image", title: "插画风格", icon: <BgColorsOutlined />, styleClass: "illustration" },
{ tag: "Image", title: "电商风格", icon: <ShoppingOutlined />, styleClass: "ecommerce" },
];
const VIDEO_OUTPUTS = [
@@ -160,10 +169,10 @@ function ModelGenerationShowcase() {
))}
</div>
<div className="mgs-img-grid">
<div className="mgs-img-cell">🎨</div>
<div className="mgs-img-cell">🖼</div>
<div className="mgs-img-cell"></div>
<div className="mgs-img-cell">🌈</div>
<div className="mgs-img-cell"><BgColorsOutlined /></div>
<div className="mgs-img-cell"><PictureOutlined /></div>
<div className="mgs-img-cell"><CameraOutlined /></div>
<div className="mgs-img-cell"><ShoppingOutlined /></div>
</div>
</div>
)}
@@ -195,7 +204,7 @@ function ModelGenerationShowcase() {
</div>
<div className="mgs-video-preview">
<div className="mgs-play-btn">
<svg viewBox="0 0 24 24"><polygon points="6,3 20,12 6,21" /></svg>
<PlayCircleOutlined />
</div>
</div>
<div className="mgs-video-timeline">
@@ -266,7 +275,7 @@ function ModelGenerationShowcase() {
</div>
<div className="mgs-out-video-placeholder">
<div className="mgs-mini-play">
<svg viewBox="0 0 24 24"><polygon points="6,3 20,12 6,21" /></svg>
<PlayCircleOutlined />
</div>
<span className="mgs-video-duration">{item.duration}</span>
</div>
+9 -9
View File
@@ -1,4 +1,4 @@
import { ToolOutlined } from "@ant-design/icons";
import { CameraOutlined, PictureOutlined, ScissorOutlined, ToolOutlined, VideoCameraOutlined } from "@ant-design/icons";
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import { ossAssets } from "../../data/ossAssets";
import "../../styles/pages/toolbox.css";
@@ -18,25 +18,25 @@ interface ToolboxSectionProps {
const TOOLS = [
{
key: "image-studio",
icon: "🎨",
icon: <PictureOutlined />,
name: "图片工作室",
desc: "图片二次加工,调色裁剪特效风格迁移",
},
{
key: "lens-lab",
icon: "📷",
icon: <CameraOutlined />,
name: "镜头实验室",
desc: "多视角镜头生成,不同角度与姿势",
},
{
key: "digital-human",
icon: "🧑",
icon: <VideoCameraOutlined />,
name: "一键数字人",
desc: "上传图片和音频,生成数字人视频",
},
{
key: "watermark-removal",
icon: "✨",
icon: <ScissorOutlined />,
name: "去除水印",
desc: "AI智能识别去除图片视频水印",
},
@@ -47,7 +47,7 @@ const CARDS = [
key: "image-studio",
title: "图片工作室",
tag: "图片加工",
icon: "🎨",
icon: <PictureOutlined />,
features: ["二次加工", "调色", "裁剪", "风格迁移"],
targetView: "imageWorkbench" as WebViewKey,
render: () => (
@@ -72,7 +72,7 @@ const CARDS = [
key: "lens-lab",
title: "镜头实验室",
tag: "多视角",
icon: "📷",
icon: <CameraOutlined />,
features: ["正面", "45°侧", "俯拍", "仰拍", "背面"],
targetView: "imageWorkbench" as WebViewKey,
render: () => (
@@ -91,7 +91,7 @@ const CARDS = [
key: "digital-human",
title: "一键数字人",
tag: "视频生成",
icon: "🧑",
icon: <VideoCameraOutlined />,
features: ["上传人像", "匹配音频", "唇形同步", "生成视频"],
targetView: "digitalHuman" as WebViewKey,
render: () => (
@@ -122,7 +122,7 @@ const CARDS = [
key: "watermark-removal",
title: "去除水印",
tag: "AI清除",
icon: "✨",
icon: <ScissorOutlined />,
features: ["智能识别", "精准去除", "无损画质"],
targetView: "watermarkRemoval" as WebViewKey,
render: () => (
@@ -947,19 +947,22 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div>
</div>
) : (
<button
type="button"
className={`image-workbench-empty image-workbench-empty--button${isInpaintDragging ? " is-dragging" : ""}`}
<div
className={`studio-canvas-ghost${isInpaintDragging ? " is-dragging" : ""}`}
onClick={() => inpaintFileInputRef.current?.click()}
onDragOver={handleInpaintDragOver}
onDragLeave={handleInpaintDragLeave}
onDrop={handleInpaintDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inpaintFileInputRef.current?.click(); }}
>
{isInpaintDragging ? <span className="image-workbench-upload-drop-overlay" style={{ borderRadius: "var(--radius-sm)" }}><span></span></span> : null}
<FileImageOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</button>
<div className="studio-canvas-ghost__icon">
<FileImageOutlined />
</div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"> PNG / JPG / WebP使</div>
</div>
)}
</section>
@@ -1389,12 +1392,21 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
<img src={referenceImage} alt="参考图预览" />
</div>
) : (
<div className="studio-canvas-ghost">
<div
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
>
<div className="studio-canvas-ghost__icon">
<PictureOutlined />
</div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"></div>
<div className="studio-canvas-ghost__hint"> (PNG / JPG / WebP)</div>
</div>
)}
</section>
+175 -131
View File
@@ -37,117 +37,155 @@ interface MoreTool {
imageTool?: WebImageWorkbenchTool;
ready: boolean;
badge?: string;
featured?: boolean;
}
type CompareScene =
| "workbench"
| "inpaint"
| "camera"
| "upscale"
| "watermark"
| "dialog"
| "subtitle"
| "digital-human"
| "character"
| "avatar";
const toolCompareScenes: Record<string, CompareScene> = {
workbench: "workbench",
inpaint: "inpaint",
camera: "camera",
upscale: "upscale",
watermarkRemoval: "watermark",
dialogGenerator: "dialog",
subtitleRemoval: "subtitle",
digitalHuman: "digital-human",
characterMix: "character",
avatarConsole: "avatar",
const toolPreviewImages: Record<string, string> = {
workbench: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/toolbox/image-workbench-20260609132455.png",
inpaint: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%B1%80%E9%83%A8%E9%87%8D%E7%BB%98.PNG",
camera: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E9%95%9C%E5%A4%B4%E5%AE%9E%E9%AA%8C%E5%AE%A4.PNG",
upscale: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%88%86%E8%BE%A8%E7%8E%87%E6%8F%90%E5%8D%87.PNG",
watermarkRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%8E%BB%E6%B0%B4%E5%8D%B0.PNG",
dialogGenerator: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E4%BA%A4%E4%BA%92%E5%BC%8F%E5%AF%B9%E8%AF%9D%E6%A1%86%E7%94%9F%E6%88%90%E5%99%A8.PNG",
subtitleRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%AD%97%E5%B9%95%E5%8E%BB%E9%99%A4.PNG",
digitalHuman: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/toolbox/digital-human-20260609132455.png",
characterMix: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E8%A7%92%E8%89%B2%E8%BF%81%E7%A7%BB.PNG",
avatarConsole: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E6%95%B0%E5%AD%97%E4%BA%BA%E6%8E%A7%E5%88%B6%E5%8F%B0.PNG",
};
function ToolComparePanel({ scene }: { scene: CompareScene }) {
function ToolPreviewPanel({ toolId }: { toolId: string }) {
const imageUrl = toolPreviewImages[toolId];
if (!imageUrl) return null;
return (
<span className={`more-card__compare more-card__compare--${scene}`} aria-hidden="true">
<span className="more-card__compare-labels">
<span>Before</span>
<span>After</span>
</span>
<span className="more-card__compare-stage">
<span className="more-card__compare-side more-card__compare-side--before">
<span className="more-card__scene-subject" />
<span className="more-card__scene-detail" />
<span className="more-card__scene-overlay" />
</span>
<span className="more-card__compare-divider">
<span />
</span>
<span className="more-card__compare-side more-card__compare-side--after">
<span className="more-card__scene-subject" />
<span className="more-card__scene-detail" />
<span className="more-card__scene-overlay" />
</span>
<span className="more-card__preview" aria-hidden="true">
<span className="more-card__preview-frame">
<img src={imageUrl} alt="" loading="lazy" decoding="async" />
</span>
<img className="more-card__preview-popover" src={imageUrl} alt="" loading="lazy" decoding="async" />
</span>
);
}
const tools: MoreTool[] = [
{ id: "workbench", title: "图片工作台", text: "融合、修复、局部增强", useCase: "适合商品图精修、创意合成和局部画面重做", tags: ["热门", "一站式", "商品图"], icon: <EditOutlined />, category: "image", imageTool: "workbench", ready: true, featured: true },
{ id: "inpaint", title: "局部重绘", text: "修掉瑕疵、替换物体、重做局部画面", useCase: "适合快速处理商品瑕疵、人物细节和背景杂物", tags: ["新手推荐", "精修"], icon: <HighlightOutlined />, category: "image", imageTool: "inpaint", ready: true },
{ id: "camera", title: "镜头实验室", text: "快速生成俯拍、特写、广角等商业镜头", useCase: "适合做产品主图、种草图和不同机位方案", tags: ["电商常用", "镜头"], icon: <CameraOutlined />, category: "image", imageTool: "camera", ready: true },
{ id: "upscale", title: "分辨率提升", text: "把低清图片或视频提升到可交付质感", useCase: "适合修复旧素材、放大商品图和增强短视频清晰度", tags: ["高清", "交付前"], icon: <ColumnWidthOutlined />, category: "image", target: "resolutionUpscale", ready: true },
{ id: "watermarkRemoval", title: "去水印", text: "智能去除图片水印、文字和遮挡元素", useCase: "适合整理素材、清理参考图和恢复画面干净度", tags: ["素材清理", "高频"], icon: <DeleteOutlined />, category: "image", target: "watermarkRemoval", ready: true },
{ id: "dialogGenerator", title: "交互式对话框生成器", text: "上传背景图,快速制作可拖拽编辑的对话框", useCase: "适合剧情海报、社媒截图和角色对白设计", tags: ["内容创作", "可编辑"], icon: <MessageOutlined />, category: "image", target: "dialogGenerator", ready: true },
{ id: "subtitleRemoval", title: "字幕去除", text: "擦除视频字幕,让画面重新变干净", useCase: "适合二创前素材整理、短视频重剪和画面修复", tags: ["视频增强", "素材清理"], icon: <DeleteOutlined />, category: "video", target: "subtitleRemoval", ready: true },
{ id: "digitalHuman", title: "数字人", text: "用一张人像和音频生成口播视频", useCase: "适合品牌讲解、课程口播和带货短视频", tags: ["热门", "口播", "视频"], icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true },
{ id: "characterMix", title: "角色迁移", text: "把人物图迁移到参考视频的动作里", useCase: "适合角色短片、动作复刻和虚拟人内容生产", tags: ["角色视频", "动作"], icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true },
{ id: "avatarConsole", title: "数字人控制台", text: "管理形象、播报、互动与接入配置", useCase: "适合持续运营数字人、配置品牌形象和复用口播模板", tags: ["运营台", "企业"], icon: <DashboardOutlined />, category: "video", target: "avatarConsole", ready: true },
];
interface FeaturedTool {
id: string;
title: string;
desc: string;
kicker: string;
steps: string[];
outcome: string;
icon: ReactNode;
imageTool?: WebImageWorkbenchTool;
target?: WebViewKey;
category: ToolCategory;
gradient: string;
function getPreviewClassName(toolId: string) {
return toolPreviewImages[toolId] ? " more-card--has-preview" : " more-card--no-preview";
}
const featuredTools: FeaturedTool[] = [
const tools: MoreTool[] = [
{
id: "workbench",
title: "图片工作台",
desc: "从一张素材开始,完成精修、合成和二次创作。",
kicker: "图片精修工作流",
steps: ["上传素材", "局部修复", "高清导出"],
outcome: "适合商品图、海报图和创意视觉",
text: "融合、修复、局部增强",
useCase: "适合商品图精修、创意合成和局部画面重做",
tags: ["热门", "一站式", "商品图"],
icon: <EditOutlined />,
imageTool: "workbench",
category: "image",
gradient: "linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(139, 92, 246, 0.06))",
imageTool: "workbench",
ready: true,
},
{
id: "inpaint",
title: "局部重绘",
text: "修掉瑕疵、替换物体、重做局部画面",
useCase: "适合快速处理商品瑕疵、人物细节和背景杂物",
tags: ["新手推荐", "精修"],
icon: <HighlightOutlined />,
category: "image",
imageTool: "inpaint",
ready: true,
},
{
id: "camera",
title: "镜头实验室",
text: "快速生成俯拍、特写、广角等商业镜头",
useCase: "适合做产品主图、种草图和不同机位方案",
tags: ["电商常用", "镜头"],
icon: <CameraOutlined />,
category: "image",
imageTool: "camera",
ready: true,
},
{
id: "upscale",
title: "分辨率提升",
text: "把低清图片或视频提升到可交付质感",
useCase: "适合修复旧素材、放大商品图和增强短视频清晰度",
tags: ["高清", "交付前"],
icon: <ColumnWidthOutlined />,
category: "image",
target: "resolutionUpscale",
ready: true,
},
{
id: "watermarkRemoval",
title: "去水印",
text: "智能去除图片水印、文字和遮挡元素",
useCase: "适合整理素材、清理参考图和恢复画面干净度",
tags: ["素材清理", "高频"],
icon: <DeleteOutlined />,
category: "image",
target: "watermarkRemoval",
ready: true,
},
{
id: "dialogGenerator",
title: "交互式对话框生成器",
text: "上传背景图,快速制作可拖拽编辑的对话框",
useCase: "适合剧情海报、社媒截图和角色对白设计",
tags: ["内容创作", "可编辑"],
icon: <MessageOutlined />,
category: "image",
target: "dialogGenerator",
ready: true,
},
{
id: "subtitleRemoval",
title: "字幕去除",
text: "擦除视频字幕,让画面重新变干净",
useCase: "适合二创前素材整理、短视频重剪和画面修复",
tags: ["视频增强", "素材清理"],
icon: <DeleteOutlined />,
category: "video",
target: "subtitleRemoval",
ready: true,
},
{
id: "digitalHuman",
title: "数字人",
desc: "用参考人像和音频,快速生成可交付口播视频",
kicker: "口播视频工作流",
steps: ["选择人像", "上传音频", "生成视频"],
outcome: "适合品牌讲解、课程和带货短视频",
text: "用一张人像和音频生成口播视频",
useCase: "适合品牌讲解、课程口播和带货短视频",
tags: ["热门", "口播", "视频"],
icon: <CustomerServiceOutlined />,
target: "digitalHuman",
category: "video",
gradient: "linear-gradient(135deg, rgba(13, 148, 136, 0.12), rgba(6, 182, 212, 0.06))",
target: "digitalHuman",
ready: true,
},
{
id: "characterMix",
title: "角色迁移",
text: "把人物图迁移到参考视频的动作里",
useCase: "适合角色短片、动作复刻和虚拟人内容生产",
tags: ["角色视频", "动作"],
icon: <SwapOutlined />,
category: "video",
target: "characterMix",
ready: true,
},
{
id: "avatarConsole",
title: "数字人控制台",
text: "管理形象、播报、互动与接入配置",
useCase: "适合持续运营数字人、配置品牌形象和复用口播模板",
tags: ["运营台", "企业"],
icon: <DashboardOutlined />,
category: "video",
target: "avatarConsole",
ready: true,
},
];
const categoryLabels: Record<ToolCategory, string> = {
image: "图像创作",
video: "视频生成",
video: "视频创作",
};
const categoryIcons: Record<ToolCategory, ReactNode> = {
@@ -162,6 +200,20 @@ const filters: { key: FilterKey; label: string }[] = [
{ key: "upcoming", label: "即将上线" },
];
const coreToolIds = new Set(["workbench", "inpaint", "watermarkRemoval"]);
const coreToolGradients: Record<string, string> = {
workbench: "linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(139, 92, 246, 0.06))",
inpaint: "linear-gradient(135deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.055))",
watermarkRemoval: "linear-gradient(135deg, rgba(16, 185, 129, 0.13), rgba(var(--accent-rgb), 0.055))",
};
const coreToolSteps: Record<string, string[]> = {
workbench: ["上传素材", "局部修复", "高清导出"],
inpaint: ["选定区域", "描述修改", "生成结果"],
watermarkRemoval: ["上传素材", "智能识别", "干净导出"],
};
const RECENT_STORAGE_KEY = "omniai:more-recent-tools";
const MAX_RECENT = 4;
@@ -169,7 +221,9 @@ function getRecentToolIds(): string[] {
try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch { return []; }
} catch {
return [];
}
}
function pushRecentToolId(id: string) {
@@ -199,39 +253,29 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
}
}, [onOpenImageTool, onSelectView]);
const openFeaturedTool = useCallback((tool: FeaturedTool) => {
pushRecentToolId(tool.id);
setRecentIds(getRecentToolIds());
if (tool.imageTool && onOpenImageTool) {
onOpenImageTool(tool.imageTool);
return;
}
if (tool.target && onSelectView) {
onSelectView(tool.target);
}
}, [onOpenImageTool, onSelectView]);
const filteredTools = tools.filter((t) => {
if (t.featured) return false;
const filteredTools = tools.filter((tool) => {
if (coreToolIds.has(tool.id)) return false;
if (filter === "all") return true;
if (filter === "upcoming") return !t.ready;
return t.category === filter;
if (filter === "upcoming") return !tool.ready;
return tool.category === filter;
});
const filterCounts: Record<FilterKey, number> = {
all: tools.filter((t) => !t.featured).length,
image: tools.filter((t) => !t.featured && t.category === "image").length,
video: tools.filter((t) => !t.featured && t.category === "video").length,
upcoming: tools.filter((t) => !t.featured && !t.ready).length,
all: tools.filter((tool) => !coreToolIds.has(tool.id)).length,
image: tools.filter((tool) => !coreToolIds.has(tool.id) && tool.category === "image").length,
video: tools.filter((tool) => !coreToolIds.has(tool.id) && tool.category === "video").length,
upcoming: tools.filter((tool) => !coreToolIds.has(tool.id) && !tool.ready).length,
};
const recentTools = recentIds
.map((id) => tools.find((t) => t.id === id))
.filter((t): t is MoreTool => Boolean(t) && (t?.ready ?? false));
.map((id) => tools.find((tool) => tool.id === id))
.filter((tool): tool is MoreTool => Boolean(tool) && (tool?.ready ?? false));
const groupedTools = filteredTools.reduce<Record<ToolCategory, MoreTool[]>>((acc, t) => {
if (!acc[t.category]) acc[t.category] = [];
acc[t.category].push(t);
const coreTools = tools.filter((tool) => coreToolIds.has(tool.id));
const groupedTools = filteredTools.reduce<Record<ToolCategory, MoreTool[]>>((acc, tool) => {
if (!acc[tool.category]) acc[tool.category] = [];
acc[tool.category].push(tool);
return acc;
}, {} as Record<ToolCategory, MoreTool[]>);
@@ -247,19 +291,19 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
</div>
<div className="more-page-v2__header-meta" aria-label="工具盒概览">
<span>{tools.filter((tool) => tool.ready).length} </span>
<span>{featuredTools.length} </span>
<span>{coreTools.length} </span>
</div>
<nav className="more-page-v2__filters" aria-label="工具分类筛选">
{filters.map((f) => (
{filters.map((item) => (
<button
key={f.key}
key={item.key}
type="button"
className={filter === f.key ? "is-active" : ""}
aria-pressed={filter === f.key}
onClick={() => setFilter(f.key)}
className={filter === item.key ? "is-active" : ""}
aria-pressed={filter === item.key}
onClick={() => setFilter(item.key)}
>
<span>{f.label}</span>
<em>{filterCounts[f.key]}</em>
<span>{item.label}</span>
<em>{filterCounts[item.key]}</em>
</button>
))}
</nav>
@@ -298,27 +342,28 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
<ThunderboltOutlined />
</h2>
<div className="more-page-v2__featured-grid">
{featuredTools.map((tool) => (
{coreTools.map((tool) => (
<button
key={tool.id}
type="button"
className="more-card more-card--featured"
style={{ "--card-gradient": tool.gradient } as CSSProperties}
aria-label={`打开核心工具:${tool.title}${tool.desc}`}
onClick={() => openFeaturedTool(tool)}
className={`more-card more-card--featured${getPreviewClassName(tool.id)}`}
style={{
"--card-gradient": coreToolGradients[tool.id] ?? "linear-gradient(135deg, rgba(var(--accent-rgb), 0.12), rgba(var(--accent-rgb), 0.04))",
} as CSSProperties}
aria-label={`打开核心工具:${tool.title}${tool.text}`}
onClick={() => openTool(tool)}
>
<span className="more-card__featured-icon">{tool.icon}</span>
<div className="more-card__featured-body">
<span className="more-card__featured-kicker">{tool.kicker}</span>
<span className="more-card__featured-kicker">{categoryLabels[tool.category]}</span>
<strong>{tool.title}</strong>
<ToolComparePanel scene={toolCompareScenes[tool.id]} />
<span className="more-card__featured-desc">{tool.desc}</span>
<ToolPreviewPanel toolId={tool.id} />
<span className="more-card__featured-desc">{tool.text}</span>
<span className="more-card__steps" aria-hidden="true">
{tool.steps.map((step) => (
{(coreToolSteps[tool.id] ?? tool.tags.slice(0, 3)).map((step) => (
<span key={step}>{step}</span>
))}
</span>
<span className="more-card__outcome">{tool.outcome}</span>
<span className="more-card__outcome">{tool.useCase}</span>
<span className="more-card__cta">使 </span>
</div>
</button>
@@ -341,19 +386,18 @@ function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
<button
key={tool.id}
type="button"
className={`more-card${tool.ready ? " more-card--ready" : " more-card--pending"}`}
className={`more-card${tool.ready ? " more-card--ready" : " more-card--pending"}${getPreviewClassName(tool.id)}`}
aria-label={tool.ready ? `打开工具:${tool.title}${tool.text}` : `${tool.title}暂未开放`}
onClick={() => openTool(tool)}
disabled={!tool.ready}
>
<span className="more-card__icon">{tool.icon}</span>
<span className="more-card__topline">
{tool.tags.slice(0, 2).map((tag) => (
<span key={tag}>{tag}</span>
))}
</span>
<strong>{tool.title}</strong>
<ToolComparePanel scene={toolCompareScenes[tool.id]} />
<ToolPreviewPanel toolId={tool.id} />
<span className="more-card__desc">{tool.text}</span>
<span className="more-card__use-case">{tool.useCase}</span>
<span className="more-card__action"> </span>
+37 -4
View File
@@ -18,7 +18,7 @@ import {
ShareAltOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type FormEvent, type KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import "../../styles/pages/profile.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
@@ -228,6 +228,28 @@ function ProfilePage({
const avatarLabel = displayName.trim().slice(0, 1).toUpperCase() || "访";
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const bannerInputRef = useRef<HTMLInputElement | null>(null);
const [isBannerDragging, setIsBannerDragging] = useState(false);
const [isAvatarDragging, setIsAvatarDragging] = useState(false);
const handleBannerDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsBannerDragging(true); };
const handleBannerDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsBannerDragging(false); };
const handleBannerDrop = (e: DragEvent) => {
e.preventDefault();
setIsBannerDragging(false);
if (e.dataTransfer.files.length) {
handleBannerUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
const handleAvatarDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsAvatarDragging(true); };
const handleAvatarDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsAvatarDragging(false); };
const handleAvatarDrop = (e: DragEvent) => {
e.preventDefault();
setIsAvatarDragging(false);
if (e.dataTransfer.files.length) {
handleAvatarUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
}
};
const [mode, setMode] = useState<WebAuthMode>("login");
const [authTab, setAuthTab] = useState<AuthTab>("password");
@@ -1047,8 +1069,11 @@ function ProfilePage({
<input ref={avatarInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleAvatarUpload(event)} />
<input ref={bannerInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleBannerUpload(event)} />
<header
className={`profile-page__banner${bannerUrl ? " has-image" : ""}`}
className={`profile-page__banner${bannerUrl ? " has-image" : ""}${isBannerDragging ? " is-dragging" : ""}`}
style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined}
onDragOver={handleBannerDragOver}
onDragLeave={handleBannerDragLeave}
onDrop={handleBannerDrop}
>
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()} aria-label="更换背景">
<CameraOutlined />
@@ -1060,13 +1085,21 @@ function ProfilePage({
<div className="profile-page__body">
<aside className="profile-page__sidebar">
<div className="profile-page__sidebar-head">
<div className="profile-page__avatar-ring">
<div className={`profile-page__avatar-ring${isAvatarDragging ? " is-dragging" : ""}`}>
{avatarUrl ? (
<img className="profile-page__avatar" src={avatarUrl} alt="" />
) : (
<span className="profile-page__avatar">{avatarLabel}</span>
)}
<button type="button" className="profile-page__avatar-edit" onClick={() => avatarInputRef.current?.click()} aria-label="更换头像">
<button
type="button"
className="profile-page__avatar-edit"
onClick={() => avatarInputRef.current?.click()}
onDragOver={handleAvatarDragOver}
onDragLeave={handleAvatarDragLeave}
onDrop={handleAvatarDrop}
aria-label="更换头像"
>
<CameraOutlined />
</button>
<span className="profile-page__avatar-badge">
+1
View File
@@ -2,6 +2,7 @@ import { CheckCircleOutlined, FlagOutlined, MailOutlined, PhoneOutlined } from "
import { useEffect, useState, type FormEvent } from "react";
import { publicConfigClient, type WebPublicConfig } from "../../api/publicConfigClient";
import { reportClient, type ReportInput } from "../../api/reportClient";
import "../../styles/pages/compliance.css";
type SubmitState = "idle" | "loading" | "success" | "error";
@@ -601,11 +601,20 @@ function ResolutionUpscalePage({
</div>
)
) : (
<div className="studio-canvas-ghost">
<div
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
>
<div className="studio-canvas-ghost__icon">
<ThunderboltOutlined />
</div>
<div className="studio-canvas-ghost__title">{mode === "image" ? "拖拽或选择图片" : "拖拽或选择视频"}</div>
<div className="studio-canvas-ghost__title">{mode === "image" ? "点击或拖拽上传图片" : "点击或拖拽上传视频"}</div>
<div className="studio-canvas-ghost__hint">{mode === "image" ? "支持 PNG / JPG / WebP" : "支持 MP4 / MOV / WebM"}</div>
</div>
)}
@@ -679,14 +679,23 @@ function ScriptTokensPage() {
</div>
) : !result && (
<div className="script-eval-v5-input-section">
<div className="script-eval-v5-illustration" aria-label="上传剧本并开始评测">
<div className={`script-eval-v5-illustration${isDragging ? " is-dragging" : ""}`} aria-label="上传剧本并开始评测">
<div
className="script-eval-v5-illustration-hit"
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onKeyDown={uploadKeyDown}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging && (
<div className="script-eval-v5-upload-drop-overlay">
<UploadOutlined />
<span></span>
</div>
)}
<div className="script-eval-v5-upload-card-icon">
<ShellIcon name="file-text" />
</div>
@@ -447,15 +447,19 @@ function SubtitleRemovalPage({
</div>
) : (
<div
className="studio-canvas-ghost"
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleFileDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
>
<div className="studio-canvas-ghost__icon">
<VideoCameraOutlined />
</div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"> MP4 1GB 1080P</div>
</div>
)}
@@ -12,7 +12,7 @@ import {
ScissorOutlined,
SwapOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
@@ -48,6 +48,7 @@ function WatermarkRemovalPage({
const [status, setStatus] = useState("上传含水印的图片,点击开始去水印");
const [activeTaskId, setActiveTaskId] = useState("");
const [taskProgress, setTaskProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isSavingAsset, setIsSavingAsset] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
@@ -124,6 +125,10 @@ function WatermarkRemovalPage({
setStatus(`已导入 ${file.name}`);
};
const handleDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsDragging(true); };
const handleDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); };
const handleCanvasDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); handleFileDrop(e); };
const handleImportUrl = () => {
const normalizedUrl = sourceUrl.trim();
if (!/^https?:\/\//i.test(normalizedUrl)) {
@@ -403,17 +408,22 @@ function WatermarkRemovalPage({
</div>
</div>
) : (
<button
type="button"
className="image-workbench-empty image-workbench-empty--button"
<div
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={handleFileDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleCanvasDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
>
<DeleteOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</button>
<div className="studio-canvas-ghost__icon">
<DeleteOutlined />
</div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"> PNG / JPG / WebP"开始去水印"</div>
</div>
)}
</section>
</main>
+322 -28
View File
@@ -67,7 +67,6 @@ import { downloadResultAsset } from "./workbenchDownload";
import { translateTaskError } from "../../utils/translateTaskError";
import {
buildLocalTimeoutMessage,
formatTextTokenUsage,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../../utils/taskLifecycle";
@@ -79,10 +78,12 @@ import {
import { isViduModel } from "../../utils/viduRouting";
import { isPixverseModel } from "../../utils/pixverseRouting";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
import {
getImageQualityOptions,
getImageQualityOptionsForContext,
getDefaultImageQuality,
getDefaultImageQualityForContext,
getVideoQualityOptions,
getDefaultVideoQuality,
getVideoQualityLabel,
@@ -192,15 +193,19 @@ import {
PromptPreviewLayer,
} from "./WorkbenchPromptPreview";
import { SelectChip, CompoundSelectChip, InlineOptionChip } from "./WorkbenchSelectChips";
import OnboardingTour, { type TourPhaseId } from "../../components/OnboardingTour";
export type { WorkbenchResultActionPayload } from "./workbenchConstants";
interface WorkbenchPageProps {
isAuthenticated: boolean;
session: WebUserSession | null;
onboarding?: boolean;
onEndOnboarding?: () => void;
onRequireLogin: (input: CreatePreviewTaskInput) => void;
onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void;
onRefreshUsage?: () => void;
resetToken?: number;
}
// ─── Component ───────────────────────────────────────────────────────────
@@ -220,12 +225,21 @@ const MODE_ICONS: Record<WorkbenchMode, ReactNode> = {
video: <VideoCameraOutlined />,
};
function formatCreditValue(value: number): string {
if (!Number.isFinite(value)) return "-";
if (value >= 100) return Math.round(value).toLocaleString("zh-CN");
return Number(value.toFixed(2)).toString();
}
function WorkbenchPage({
isAuthenticated,
session,
onboarding,
onEndOnboarding,
onRequireLogin,
onOpenResultInCanvas,
onRefreshUsage,
resetToken,
}: WorkbenchPageProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const referenceInputRef = useRef<HTMLInputElement | null>(null);
@@ -244,19 +258,54 @@ function WorkbenchPage({
const activeConversationIdRef = useRef<number | null>(null);
const messagesRef = useRef<ChatMessage[]>([]);
const conversationMessagesCacheRef = useRef<Map<number, ChatMessage[]>>(new Map());
const skipConversationAutoSelectRef = useRef(false);
const skipConversationAutoSelectRef = useRef(Boolean(resetToken));
const keepaliveTasksRef = useRef<Record<string, WorkbenchKeepaliveTask>>(readStoredKeepaliveTasks());
const taskAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
const lastScrollTopRef = useRef(0);
const scrollActionHintTimerRef = useRef<number | null>(null);
const shouldFollowNewMessagesRef = useRef(true);
const pendingScrollToLatestRef = useRef(true);
const genTracker = useGenerationTasks({ sourceView: "workbench" });
const renderedMessageIdsRef = useRef<string[]>([]);
const hasHandledInitialMessagesRef = useRef(false);
const [activeMode, setActiveMode] = useState<WorkbenchMode>("video");
// Onboarding signal — init from prop or localStorage
const [effectiveOnboarding, setEffectiveOnboarding] = useState(
() => onboarding || (() => { try { return window.localStorage.getItem("omniai:onboarding") === "1"; } catch { return false; } })(),
);
// Track whether onboarding prop was ever true, to avoid overwriting localStorage-initiated true
const obWasActiveRef = useRef(onboarding);
useEffect(() => {
if (onboarding) {
obWasActiveRef.current = true;
setEffectiveOnboarding(true);
} else if (obWasActiveRef.current) {
// Only deactivate when prop transitions true→false (user dismissed)
setEffectiveOnboarding(false);
obWasActiveRef.current = false;
}
// If prop was never true, don't touch effectiveOnboarding (preserves localStorage init)
}, [onboarding]);
// Poll localStorage as a fallback (handles cases where prop isn't propagated)
useEffect(() => {
if (effectiveOnboarding) return;
const check = () => {
try {
if (window.localStorage.getItem("omniai:onboarding") === "1") {
setEffectiveOnboarding(true);
}
} catch {}
};
check();
const interval = setInterval(check, 200);
return () => clearInterval(interval);
}, [effectiveOnboarding]);
const [activeMode, setActiveMode] = useState<WorkbenchMode>(() => effectiveOnboarding ? "chat" : "video");
const [inputValue, setInputValue] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>(() => readStoredMessages());
const [messages, setMessages] = useState<ChatMessage[]>(() => (resetToken ? [] : readStoredMessages()));
const [promptHistory, setPromptHistory] = useState<string[]>(() => readStoredPromptHistory());
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
@@ -279,17 +328,47 @@ function WorkbenchPage({
const [projectError, setProjectError] = useState<string | null>(null);
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [activeConversationId, setActiveConversationId] = useState<number | null>(() =>
readStoredActiveConversationId(readStoredMessages()),
resetToken ? null : readStoredActiveConversationId(readStoredMessages()),
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
// ── Onboarding tour state ──────────────────────────────
const [tourPhase, setTourPhase] = useState<TourPhaseId>("chat");
const [tourStep, setTourStep] = useState(0);
// Sync activeMode with tour phase and keep home view during onboarding
useEffect(() => {
if (!effectiveOnboarding) return;
// Reset tour state for repeat runs
setTourPhase("chat");
setTourStep(0);
// Force "今天想生成什么?" home view — prevent conversation auto-select
skipConversationAutoSelectRef.current = true;
setWorkspaceStarted(false);
setActiveConversationId(null);
activeConversationIdRef.current = null;
persistActiveConversationId(null);
messagesRef.current = [];
setMessages([]);
}, [effectiveOnboarding]);
useEffect(() => {
if (effectiveOnboarding) {
if (tourPhase === "chat") setActiveMode("chat");
else if (tourPhase === "image") setActiveMode("image");
else if (tourPhase === "video") setActiveMode("video");
}
}, [effectiveOnboarding, tourPhase]);
// ───────────────────────────────────────────────────────
const [, setGenerationProgress] = useState(0);
const [cursorIndex, setCursorIndex] = useState(0);
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
const [mentionActiveIndex, setMentionActiveIndex] = useState(0);
const [composerHidden, setComposerHidden] = useState(false);
const [scrollActionHint, setScrollActionHint] = useState<"top" | "bottom" | null>(null);
const [workspaceStarted, setWorkspaceStarted] = useState(false);
const lastResetTokenRef = useRef(resetToken);
useEffect(() => {
activeConversationIdRef.current = activeConversationId;
@@ -415,7 +494,7 @@ function WorkbenchPage({
const toolTheme = MODE_META[activeMode];
const workbenchAccent = "#00ff88";
const hasConversationRecords = activeConversationId !== null || messages.length > 0;
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
const hasActivatedWorkspace = !effectiveOnboarding && (workspaceStarted || isGenerating || hasConversationRecords);
const referenceCount = referenceItems.length;
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
const activeModelValue =
@@ -443,6 +522,7 @@ function WorkbenchPage({
[conversations],
);
const hasSidebarRecords = conversationRecords.length > 0;
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
const activeConversationTitle = useMemo(() => {
if (!activeConversationId) return "";
@@ -459,11 +539,72 @@ function WorkbenchPage({
setSidebarCollapsed(!hasSidebarRecords);
}, [hasSidebarRecords]);
const imageQualityOptions = useMemo(() => getImageQualityOptions(imageModel), [imageModel]);
const hasImageReferences = activeMode === "image" && referenceItems.some((item) => item.kind === "image");
const isImageGridMode = activeMode === "image" && imageGridMode !== "single";
const imageQualityContext = useMemo(
() => ({
hasReferenceImages: hasImageReferences,
isGridMode: isImageGridMode,
}),
[hasImageReferences, isImageGridMode],
);
const imageQualityOptions = useMemo(
() => getImageQualityOptionsForContext(imageModel, imageQualityContext),
[imageModel, imageQualityContext],
);
const imageGridModeOptions = useMemo(
() =>
String(imageModel || "").toLowerCase().startsWith("wan2.7-")
? GRID_MODE_OPTIONS.filter((option) => option.value !== "grid-25")
: GRID_MODE_OPTIONS,
[imageModel],
);
const videoQualityOptions = getVideoQualityOptions(videoModel);
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
const billingEstimate = useMemo(() => {
if (activeMode === "image") {
return {
label: "预计 20 积分",
title: `图片生成按任务计费:${activeModel}${imageSettingsSummary},预计 20 积分`,
};
}
if (activeMode === "video") {
try {
const durationSeconds = Math.max(1, Math.ceil(Number(videoDuration) || 1));
const credits = calculateEnterpriseVideoCredits({
model: activeModelValue,
resolution: videoQuality,
durationSeconds,
muted: false,
hasReferenceVideo: referenceItems.some((item) => item.kind === "video"),
});
return {
label: `预计 ${formatCreditValue(credits)} 积分`,
title: `${activeModel}${videoQualityLabel}${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`,
};
} catch {
return {
label: "计费以提交后为准",
title: "当前模型的预估计费暂不可用,实际扣费以服务端结算为准",
};
}
}
return {
label: "按 Token 结算",
title: "文本对话按输入、输出 Token 实际用量结算,完成后显示本次积分",
};
}, [
activeMode,
activeModel,
activeModelValue,
imageSettingsSummary,
referenceItems,
videoDuration,
videoQuality,
videoQualityLabel,
]);
const composerPlaceholder =
referenceItems.length > 0 ? `${toolTheme.placeholder},可输入 @ 引用参考内容` : toolTheme.placeholder;
const dropdownDirection = hasActivatedWorkspace ? "up" : "down";
@@ -496,6 +637,31 @@ function WorkbenchPage({
});
}, []);
const hideScrollActionHint = useCallback(() => {
if (scrollActionHintTimerRef.current !== null) {
window.clearTimeout(scrollActionHintTimerRef.current);
scrollActionHintTimerRef.current = null;
}
setScrollActionHint(null);
}, []);
const showScrollActionHint = useCallback((direction: "top" | "bottom") => {
if (scrollActionHintTimerRef.current !== null) {
window.clearTimeout(scrollActionHintTimerRef.current);
}
setScrollActionHint(direction);
scrollActionHintTimerRef.current = window.setTimeout(() => {
setScrollActionHint(null);
scrollActionHintTimerRef.current = null;
}, 1400);
}, []);
useEffect(() => () => {
if (scrollActionHintTimerRef.current !== null) {
window.clearTimeout(scrollActionHintTimerRef.current);
}
}, []);
const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>(
() => [
{
@@ -1128,9 +1294,15 @@ function WorkbenchPage({
useEffect(() => {
if (!imageQualityOptions.some((option) => option.value === imageQuality)) {
setImageQuality(getDefaultImageQuality(imageModel));
setImageQuality(getDefaultImageQualityForContext(imageModel, imageQualityContext));
}
}, [imageModel, imageQuality, imageQualityOptions]);
}, [imageModel, imageQuality, imageQualityContext, imageQualityOptions]);
useEffect(() => {
if (!imageGridModeOptions.some((option) => option.value === imageGridMode)) {
setImageGridMode("single");
}
}, [imageGridMode, imageGridModeOptions]);
useEffect(() => {
if (activeMode !== "video" || videoFrameMode !== "start-end" || referenceItems.length <= 2) return;
@@ -1266,6 +1438,12 @@ function WorkbenchPage({
activeConversationIdRef.current = null;
}, [syncActiveGenerationUi]);
useEffect(() => {
if (resetToken === undefined || lastResetTokenRef.current === resetToken) return;
lastResetTokenRef.current = resetToken;
handleNewConversation();
}, [handleNewConversation, resetToken]);
const handleSelectProject = useCallback((id: string) => {
if (!id) {
handleNewConversation();
@@ -1420,6 +1598,7 @@ function WorkbenchPage({
const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold;
shouldFollowNewMessagesRef.current = atBottom;
setComposerHidden(!(atTop || atBottom));
hideScrollActionHint();
lastScrollTopRef.current = top;
};
@@ -1434,31 +1613,68 @@ function WorkbenchPage({
shouldFollowNewMessagesRef.current = atBottom;
if (atTop || atBottom) {
setComposerHidden(false);
hideScrollActionHint();
} else if (Math.abs(delta) > scrollDeltaThreshold) {
setComposerHidden(true);
showScrollActionHint(delta < 0 ? "top" : "bottom");
}
lastScrollTopRef.current = top;
};
surface.addEventListener("scroll", handleScroll, { passive: true });
return () => surface.removeEventListener("scroll", handleScroll);
}, [hasActivatedWorkspace]);
}, [hasActivatedWorkspace, hideScrollActionHint, showScrollActionHint]);
const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => {
const surface = messagesSurfaceRef.current;
if (!surface) return;
const top = direction === "top" ? 0 : surface.scrollHeight;
hideScrollActionHint();
setComposerHidden(false);
surface.scrollTo({ top, behavior: "smooth" });
}, []);
}, [hideScrollActionHint]);
const closeToolbarMenus = () => setToolbarMenuId(null);
const toggleToolbarMenu = (menuId: Exclude<ToolbarMenuId, null>) => {
setToolbarMenuId((current) => (current === menuId ? null : menuId));
};
// ── Onboarding tour helpers ────────────────────────────
const obTarget = (map: Partial<Record<TourPhaseId, string>>): string | undefined =>
effectiveOnboarding ? map[tourPhase] : undefined;
const handleTourNext = useCallback((_phase: TourPhaseId, stepIndex: number) => {
setTourStep(stepIndex);
}, []);
const handleTourSkip = useCallback((phase: TourPhaseId) => {
const next: Record<TourPhaseId, TourPhaseId> = { chat: "image", image: "video", video: "video" };
const nextPhase = next[phase];
if (nextPhase === phase) {
onEndOnboarding?.();
} else {
setTourPhase(nextPhase);
setTourStep(0);
if (nextPhase === "image") setActiveMode("image");
else if (nextPhase === "video") setActiveMode("video");
}
}, [onEndOnboarding, setActiveMode]);
const handleTourDone = useCallback(() => {
setEffectiveOnboarding(false);
onEndOnboarding?.();
}, [onEndOnboarding]);
// Advance tour phase when user switches mode during onboarding
const handleModeChange = (mode: WorkbenchMode) => {
if (effectiveOnboarding) {
// Advance tour phase when switching to the next mode
if (tourPhase === "chat" && mode === "image") { setTourPhase("image"); setTourStep(0); }
else if (tourPhase === "image" && mode === "video") { setTourPhase("video"); setTourStep(0); }
// Block switching to other modes during guided tour
else if (mode !== tourPhase) return;
}
setActiveMode(mode);
setToolbarMenuId(null);
setReferencePreviewOpen(false);
@@ -2545,7 +2761,15 @@ function WorkbenchPage({
}
};
const sendDisabled = !inputValue.trim() || (activeMode !== "chat" && getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3);
const activeGenerationCount = getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id));
const generationLimitReached = activeMode !== "chat" && activeGenerationCount >= 3;
const promptIsEmpty = !inputValue.trim();
const sendDisabled = promptIsEmpty || generationLimitReached;
const sendButtonTitle = promptIsEmpty
? "输入内容后可发送"
: generationLimitReached
? `当前已有 ${activeGenerationCount} 个任务进行中,请等待任一任务完成`
: billingEstimate.title;
const suggestedPrompts = [
{ text: "画一个赛博朋克风格的城市夜景", mode: "image" as WorkbenchMode },
@@ -2673,6 +2897,7 @@ function WorkbenchPage({
className="wb-composer__ref-upload"
onClick={handleReferenceUploadClick}
disabled={disabled}
data-onboarding={obTarget({ chat: "onboarding-chat-upload", image: "onboarding-image-upload", video: "onboarding-video-upload" })}
aria-label={`上传${referenceUploadLabel}`}
aria-expanded={referenceItems.length > 0 ? referencePreviewOpen : undefined}
aria-controls={referenceItems.length > 0 ? "workbench-reference-stack" : undefined}
@@ -2732,6 +2957,7 @@ function WorkbenchPage({
const renderComposerToolbar = (disabled = false, showStop = false) => (
<div className="wb-composer__toolbar">
<div className="wb-composer__toolbar-left">
<span data-onboarding={obTarget({ chat: "onboarding-mode-selector", image: "onboarding-mode-selector" })}>
<SelectChip
chipId="studio-mode"
value={activeMode}
@@ -2744,8 +2970,10 @@ function WorkbenchPage({
ariaLabel="工作台模式"
direction={dropdownDirection}
/>
</span>
{activeMode === "chat" && (
<>
<span data-onboarding={obTarget({ chat: "onboarding-chat-model" })}>
<SelectChip
chipId="chat-model"
value={chatModel}
@@ -2758,6 +2986,8 @@ function WorkbenchPage({
ariaLabel="对话模型"
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ chat: "onboarding-chat-speed" })}>
<SelectChip
chipId="chat-speed"
value={thinkingSpeed}
@@ -2770,6 +3000,8 @@ function WorkbenchPage({
ariaLabel="思考速度"
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ chat: "onboarding-chat-depth" })}>
<SelectChip
chipId="chat-depth"
value={thinkingDepth}
@@ -2782,10 +3014,12 @@ function WorkbenchPage({
ariaLabel="思考深度"
direction={dropdownDirection}
/>
</span>
</>
)}
{activeMode === "image" && (
<>
<span data-onboarding={obTarget({ image: "onboarding-image-model" })}>
<SelectChip
chipId="image-model"
value={imageModel}
@@ -2797,6 +3031,8 @@ function WorkbenchPage({
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ image: "onboarding-image-settings" })}>
<CompoundSelectChip
chipId="image-settings"
summary={imageSettingsSummary}
@@ -2806,11 +3042,13 @@ function WorkbenchPage({
onToggle={() => toggleToolbarMenu("image-settings")}
direction={dropdownDirection}
/>
</span>
{GRID_SUPPORTED_MODELS.has(imageModel) && (
<span data-onboarding={obTarget({ image: "onboarding-image-grid" })}>
<SelectChip
chipId="image-grid-mode"
value={imageGridMode}
options={GRID_MODE_OPTIONS}
options={imageGridModeOptions}
disabled={disabled}
isOpen={toolbarMenuId === "image-grid-mode"}
onToggle={() => toggleToolbarMenu("image-grid-mode")}
@@ -2818,11 +3056,13 @@ function WorkbenchPage({
onChange={setImageGridMode}
direction={dropdownDirection}
/>
</span>
)}
</>
)}
{activeMode === "video" && (
<>
<span data-onboarding={obTarget({ video: "onboarding-video-model" })}>
<SelectChip
chipId="video-model"
value={videoModel}
@@ -2834,6 +3074,8 @@ function WorkbenchPage({
onChange={setVideoModel}
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ video: "onboarding-video-frame" })}>
<SelectChip
chipId="video-mode"
value={videoFrameMode}
@@ -2845,6 +3087,8 @@ function WorkbenchPage({
onChange={setVideoFrameMode}
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ video: "onboarding-video-ratio" })}>
<CompoundSelectChip
chipId="video-ratio"
summary={videoRatio}
@@ -2854,6 +3098,8 @@ function WorkbenchPage({
onToggle={() => toggleToolbarMenu("video-ratio")}
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ video: "onboarding-video-duration" })}>
<InlineOptionChip
chipId="video-duration"
value={videoDuration}
@@ -2866,6 +3112,8 @@ function WorkbenchPage({
onChange={setVideoDuration}
direction={dropdownDirection}
/>
</span>
<span data-onboarding={obTarget({ video: "onboarding-video-quality" })}>
<InlineOptionChip
chipId="video-quality"
value={videoQuality}
@@ -2878,14 +3126,21 @@ function WorkbenchPage({
onChange={setVideoQuality}
direction={dropdownDirection}
/>
</span>
</>
)}
</div>
<div className="wb-composer__toolbar-right">
<span className="wb-composer__billing-estimate" title={billingEstimate.title}>
{billingEstimate.label}
</span>
<button
type="button"
className={`wb-composer__send-primary${isGenerating ? " is-loading" : ""}`}
disabled={sendDisabled || isGenerating}
title={isGenerating ? "任务处理中" : sendButtonTitle}
aria-label={isGenerating ? "任务处理中" : sendButtonTitle}
data-onboarding={obTarget({ video: "onboarding-video-generate" })}
onClick={() => {
if (getCachedRole() === "admin") console.log("[ai/workbench-send-click]", {
mode: activeMode,
@@ -2933,9 +3188,29 @@ function WorkbenchPage({
</div>
) : null;
const renderPromptCaseOverlay = () =>
selectedPromptCase ? (
<div className="wb-prompt-case-modal" role="dialog" aria-modal="true" aria-labelledby="wb-prompt-case-title">
const renderPromptCaseOverlay = () => {
if (!selectedPromptCase) return null;
const measuredRatio = promptCaseMeasuredRatios[selectedPromptCase.id];
const ratioParts = selectedPromptCase.ratio.replace(/\s+/g, "").split(":").map(Number);
const declaredRatio =
ratioParts.length === 2 && ratioParts[0] > 0 && ratioParts[1] > 0
? ratioParts[0] / ratioParts[1]
: null;
const caseRatio =
typeof measuredRatio === "number" && Number.isFinite(measuredRatio) && measuredRatio > 0
? measuredRatio
: declaredRatio;
const copyLength = `${selectedPromptCase.summary} ${selectedPromptCase.prompt}`.length;
const modalClassName = [
"wb-prompt-case-modal",
caseRatio && caseRatio < 0.72 ? "is-tall-media" : "",
caseRatio && caseRatio >= 0.72 && caseRatio < 1 ? "is-portrait-media" : "",
copyLength > 260 ? "is-long-copy" : "",
].filter(Boolean).join(" ");
return (
<div className={modalClassName} role="dialog" aria-modal="true" aria-labelledby="wb-prompt-case-title">
<button
type="button"
className="wb-prompt-case-modal__backdrop"
@@ -2944,7 +3219,11 @@ function WorkbenchPage({
/>
<section className="wb-prompt-case-modal__panel">
<div className="wb-prompt-case-modal__media">
<img src={selectedPromptCase.imageUrl} alt={selectedPromptCase.title} />
<img
src={selectedPromptCase.imageUrl}
alt={selectedPromptCase.title}
onLoad={(event) => handlePromptCaseImageLoad(selectedPromptCase.id, event)}
/>
</div>
<aside className="wb-prompt-case-modal__sidebar">
<button
@@ -2984,7 +3263,8 @@ function WorkbenchPage({
</aside>
</section>
</div>
) : null;
);
};
if (!hasActivatedWorkspace) {
return (
@@ -3019,6 +3299,7 @@ function WorkbenchPage({
className={`wb-composer__textarea${showPromptPreview ? " wb-composer__textarea--overlay-mode" : ""}`}
placeholder={composerPlaceholder}
value={inputValue}
data-onboarding={obTarget({ chat: "onboarding-chat-input", image: "onboarding-image-input" })}
onChange={handlePromptChange}
onSelect={handlePromptSelectionChange}
onKeyUp={handlePromptSelectionChange}
@@ -3079,11 +3360,19 @@ function WorkbenchPage({
</div>
</div>
{renderConversationSidebar()}
</div>
{renderConversationSidebar()}
{renderMessagePreviewOverlay()}
{renderPromptCaseOverlay()}
{renderDeleteDialog()}
<OnboardingTour
active={Boolean(effectiveOnboarding)}
phase={tourPhase}
stepIndex={tourStep}
onNext={handleTourNext}
onSkip={handleTourSkip}
onDone={handleTourDone}
/>
</section>
);
}
@@ -3166,11 +3455,6 @@ function WorkbenchPage({
<span>{message.taskStatusLabel || generationStatus}</span>
</div>
)}
{message.role === "assistant" && message.mode === "chat" && message.status === "completed" && (
<div className="ai-chat-task-billing-note">
{formatTextTokenUsage(message.taskUsage)}
</div>
)}
{(message.resultUrl || (message.result && message.status !== "thinking")) && (
<ResultCard
message={message}
@@ -3210,6 +3494,7 @@ function WorkbenchPage({
placeholder={composerPlaceholder}
value={inputValue}
disabled={false}
data-onboarding={obTarget({ chat: "onboarding-chat-input", image: "onboarding-image-input" })}
onChange={handlePromptChange}
onSelect={handlePromptSelectionChange}
onKeyUp={handlePromptSelectionChange}
@@ -3227,10 +3512,10 @@ function WorkbenchPage({
{renderComposerToolbar(false, isGenerating)}
</div>
</section>
<div className="wb-chat-scroll-actions" aria-label="聊天滚动">
<div className={`wb-chat-scroll-actions${scrollActionHint ? ` is-showing-${scrollActionHint}` : ""}`} 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")}
@@ -3239,7 +3524,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")}
@@ -3257,6 +3542,15 @@ function WorkbenchPage({
{showRechargeModal && RechargeModal ? (
<RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} />
) : null}
<OnboardingTour
active={Boolean(effectiveOnboarding)}
phase={tourPhase}
stepIndex={tourStep}
onNext={handleTourNext}
onSkip={handleTourSkip}
onDone={handleTourDone}
/>
</section>
);
}
+5 -5
View File
@@ -149,14 +149,14 @@ export const CHAT_MODEL_OPTIONS: WorkbenchOption[] = [
export const THINKING_SPEED_OPTIONS: WorkbenchOption[] = [
{ value: "default", label: "默认" },
{ value: "high", label: "高" },
{ value: "ultra", label: "急速" },
{ value: "high", label: "思考速度:高" },
{ value: "ultra", label: "思考速度:急速" },
];
export const THINKING_DEPTH_OPTIONS: WorkbenchOption[] = [
{ value: "default", label: "默认" },
{ value: "strong", label: "强" },
{ value: "extreme", label: "极限" },
{ value: "strong", label: "推理深度:强" },
{ value: "extreme", label: "推理深度:极限" },
];
export const CHAT_NATURAL_SYSTEM_PROMPT = [
@@ -231,7 +231,7 @@ export const MODE_OPTIONS: WorkbenchOption[] = (Object.keys(MODE_META) as Workbe
}));
export const IMAGE_MODEL_OPTIONS: WorkbenchOption[] = [
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro 4K" },
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro" },
{ value: "wan2.7-image", label: "wan 2.7" },
{ value: "gpt-image-2", label: "omni-GPT" },
{ value: "gpt-image-2-vip", label: "omni-GPT VIP" },
+2 -2
View File
@@ -33,7 +33,7 @@ const initialState: SessionState = {
loginPromptOpen: false,
pendingAction: null,
sessionReplacedOpen: false,
sessionReplacedMessage: '您的账号已在其他设备登录,此设备的登录状态已失效。',
sessionReplacedMessage: '当前账号已在其他设备登录,此设备的登录状态已失效。',
};
export const useSessionStore = create<SessionState & SessionActions>((set) => ({
@@ -55,7 +55,7 @@ export const useSessionStore = create<SessionState & SessionActions>((set) => ({
showSessionReplaced: (message) => set({
sessionReplacedOpen: true,
sessionReplacedMessage: message || '您的账号已在其他设备登录(最多同时 2 台设备),此设备的登录状态已失效。',
sessionReplacedMessage: message || '当前账号已在其他设备登录,此设备的登录状态已失效。',
}),
hideSessionReplaced: () => set({ sessionReplacedOpen: false }),
@@ -107,6 +107,12 @@
color: #1e1e1e;
}
.beta-modal-header__close:disabled,
.beta-modal-footer__btn:disabled {
opacity: 0.58;
cursor: wait;
}
/* ── Scrollable body ── */
.beta-modal-body {
flex: 1;
@@ -412,6 +418,22 @@
background: #f5f1ea;
}
.beta-modal-footer__message {
flex: 1;
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.5;
}
.beta-modal-footer__message--success {
color: #166534;
}
.beta-modal-footer__message--error {
color: #dc2626;
}
.beta-modal-footer__btn {
display: inline-flex;
align-items: center;
+317
View File
@@ -0,0 +1,317 @@
/* ─── Onboarding Tour ──────────────────────────────────────── */
.onboarding-root {
position: fixed;
inset: 0;
z-index: 10000;
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
font-family: Inter, "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.onboarding-root.is-visible {
opacity: 1;
}
/* ─── Overlay ──────────────────────────────────────────────── */
.onboarding-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.64);
pointer-events: auto;
transition: clip-path 0.32s cubic-bezier(0.4, 0, 0.2, 1);
will-change: clip-path;
}
/* During interactive steps, let clicks pass through to dropdowns etc. */
.onboarding-overlay--passive {
pointer-events: none;
}
/* Tooltip also lets clicks through during interactive steps, except for buttons */
.onboarding-tooltip--passive {
pointer-events: none;
}
.onboarding-tooltip--passive .onboarding-tooltip__btn {
pointer-events: auto;
}
/* ─── Spotlight ring ───────────────────────────────────────── */
.onboarding-spotlight {
position: fixed;
z-index: 10001;
border-radius: 10px;
border: 2px solid rgba(0, 255, 136, 0.5);
box-shadow:
0 0 22px rgba(0, 255, 136, 0.18),
0 0 50px rgba(0, 255, 136, 0.06),
inset 0 0 0 1px rgba(0, 255, 136, 0.05);
pointer-events: none;
transition: left 0.32s cubic-bezier(0.4, 0, 0.2, 1),
top 0.32s cubic-bezier(0.4, 0, 0.2, 1),
width 0.32s cubic-bezier(0.4, 0, 0.2, 1),
height 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.onboarding-spotlight__pulse {
position: absolute;
inset: -4px;
border-radius: 14px;
border: 1px solid rgba(0, 255, 136, 0.3);
animation: ob-pulse 2.4s ease-out infinite;
}
.onboarding-spotlight__pulse--delay {
animation-delay: 1.2s;
}
@keyframes ob-pulse {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(1.08); opacity: 0; }
}
/* ─── Connector SVG ────────────────────────────────────────── */
.onboarding-connector {
position: fixed;
inset: 0;
z-index: 10002;
width: 100%;
height: 100%;
pointer-events: none;
}
.onboarding-connector__path {
animation: ob-dash 1.6s linear infinite;
stroke-dashoffset: 0;
}
@keyframes ob-dash {
to { stroke-dashoffset: -24; }
}
.onboarding-connector__dot {
animation: ob-dot-pulse 1.4s ease-in-out infinite;
}
@keyframes ob-dot-pulse {
0%, 100% { r: 4; opacity: 0.7; }
50% { r: 7; opacity: 1; }
}
/* ─── Tooltip card ─────────────────────────────────────────── */
.onboarding-tooltip {
position: fixed;
z-index: 10003;
width: min(92vw, 360px);
padding: 20px 22px 18px;
border-radius: 16px;
background: var(--bg-elevated, #1e1e1e);
border: 1px solid var(--border-subtle, #333);
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
0 0 60px rgba(0, 255, 136, 0.04);
pointer-events: auto;
transition: left 0.32s cubic-bezier(0.4, 0, 0.2, 1),
top 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.onboarding-tooltip--pop {
animation: ob-pop-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes ob-pop-in {
from { opacity: 0; transform: scale(0.92) translateY(6px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* Tooltip arrow — CSS triangle pointing toward target */
.onboarding-tooltip__arrow {
position: absolute;
width: 0;
height: 0;
display: none; /* replaced by SVG connector; fallback for simple cases */
}
/* ─── Tooltip head ─────────────────────────────────────────── */
.onboarding-tooltip__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.onboarding-tooltip__phase-badge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 10px;
border-radius: 999px;
background: rgba(var(--accent-rgb, 0, 255, 136), 0.12);
color: var(--accent, #00ff88);
}
.onboarding-tooltip__counter {
font-size: 11px;
font-weight: 600;
color: var(--fg-muted, #777);
font-variant-numeric: tabular-nums;
}
/* ─── Tooltip body ─────────────────────────────────────────── */
.onboarding-tooltip__title {
display: block;
font-size: 17px;
font-weight: 700;
color: var(--fg-body, #e5e5e5);
margin-bottom: 6px;
line-height: 1.3;
}
.onboarding-tooltip__desc {
font-size: 13px;
line-height: 1.6;
color: var(--fg-muted, #999);
margin: 0 0 4px;
}
.onboarding-tooltip__action-hint {
font-size: 13px;
line-height: 1.5;
color: var(--accent, #00ff88);
margin: 8px 0 4px;
font-weight: 600;
animation: ob-hint-blink 1.8s ease-in-out infinite;
}
@keyframes ob-hint-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ─── Tooltip actions ──────────────────────────────────────── */
.onboarding-tooltip__actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 14px;
}
.onboarding-tooltip__btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 16px;
border-radius: 8px;
border: none;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease, transform 0.12s ease;
font-family: inherit;
white-space: nowrap;
}
.onboarding-tooltip__btn:active {
transform: scale(0.97);
}
.onboarding-tooltip__btn--primary {
background: var(--accent, #00ff88);
color: #000;
}
.onboarding-tooltip__btn--primary:hover {
opacity: 0.85;
}
.onboarding-tooltip__btn--ghost {
background: transparent;
color: var(--fg-muted, #888);
border: 1px solid var(--border-subtle, #444);
}
.onboarding-tooltip__btn--ghost:hover {
color: var(--fg-body, #e5e5e5);
border-color: var(--fg-muted, #888);
}
.onboarding-tooltip__wait-hint {
font-size: 11px;
color: var(--fg-muted, #777);
font-style: italic;
animation: ob-hint-blink 2s ease-in-out infinite;
}
/* ─── Progress bar (bottom-right) ──────────────────────────── */
.onboarding-progress {
position: fixed;
z-index: 10002;
bottom: 24px;
right: 32px;
display: flex;
align-items: center;
gap: 40px;
pointer-events: auto;
}
.onboarding-progress__phase {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.onboarding-progress__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border-subtle, #444);
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
}
.onboarding-progress__dot.is-active {
background: var(--accent, #00ff88);
transform: scale(1.4);
box-shadow: 0 0 12px rgba(0, 255, 136, 0.4);
}
.onboarding-progress__dot.is-done {
background: rgba(var(--accent-rgb, 0, 255, 136), 0.5);
}
.onboarding-progress__phase span {
font-size: 10px;
color: var(--fg-muted, #666);
white-space: nowrap;
}
.onboarding-progress__phase .is-active + span,
.onboarding-progress__phase .is-done + span {
color: var(--fg-body, #ccc);
}
/* ─── Responsive ───────────────────────────────────────────── */
@media (max-width: 640px) {
.onboarding-tooltip {
width: calc(100vw - 20px);
left: 10px !important;
}
.onboarding-progress {
gap: 20px;
}
}
+421
View File
@@ -0,0 +1,421 @@
.beta-admin-page__inner {
display: flex;
flex-direction: column;
gap: 18px;
width: min(1180px, calc(100vw - 48px));
margin: 0 auto;
height: 100%;
min-height: 0;
padding: 24px;
overflow: hidden;
}
.beta-admin-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.beta-admin-toolbar span {
color: var(--accent);
font-size: 12px;
font-weight: 850;
}
.beta-admin-toolbar h1 {
margin: 4px 0;
color: var(--text-primary);
font-size: 22px;
}
.beta-admin-toolbar p {
max-width: 620px;
margin: 0;
color: var(--text-muted);
font-size: 13px;
line-height: 1.7;
}
.beta-admin-toolbar button,
.beta-admin-status-tabs button,
.beta-admin-actions button,
.beta-admin-access button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--surface-elevated);
color: var(--text-primary);
cursor: pointer;
font-size: 13px;
font-weight: 800;
}
.beta-admin-toolbar button {
min-height: 36px;
padding: 0 14px;
}
.beta-admin-toolbar button:disabled,
.beta-admin-actions button:disabled {
opacity: 0.55;
cursor: wait;
}
.beta-admin-status-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.beta-admin-status-tabs button {
min-height: 34px;
padding: 0 14px;
color: var(--text-muted);
}
.beta-admin-status-tabs button.is-active {
border-color: rgba(var(--accent-rgb), 0.45);
background: rgba(var(--accent-rgb), 0.14);
color: var(--accent);
}
.beta-admin-error {
margin: 0;
color: var(--error, #ef4444);
font-size: 13px;
font-weight: 700;
}
.beta-admin-layout {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 16px;
flex: 1;
min-height: 0;
align-items: start;
}
.beta-admin-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 100%;
overflow: auto;
padding-right: 4px;
}
.beta-admin-list__item {
display: grid;
gap: 6px;
width: 100%;
padding: 13px;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--surface-card);
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
.beta-admin-list__item.is-active {
border-color: rgba(var(--accent-rgb), 0.52);
background: rgba(var(--accent-rgb), 0.1);
}
.beta-admin-list__item strong {
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.beta-admin-list__item small {
overflow: hidden;
color: var(--text-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.beta-admin-list__empty,
.beta-admin-detail--empty {
display: grid;
min-height: 180px;
place-items: center;
border: 1px dashed var(--border-subtle);
border-radius: 8px;
color: var(--text-muted);
font-size: 13px;
}
.beta-admin-status {
width: fit-content;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 850;
}
.beta-admin-status--pending {
background: rgba(245, 158, 11, 0.16);
color: #f59e0b;
}
.beta-admin-status--approved {
background: rgba(16, 185, 129, 0.16);
color: #10b981;
}
.beta-admin-status--rejected {
background: rgba(239, 68, 68, 0.16);
color: #ef4444;
}
.beta-admin-detail {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
max-height: 100%;
min-height: 0;
overflow: auto;
padding-right: 4px;
}
.beta-admin-detail__header,
.beta-admin-form-card,
.beta-admin-review-box {
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--surface-card);
}
.beta-admin-detail__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px;
}
.beta-admin-detail__header span {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent);
font-size: 12px;
font-weight: 850;
}
.beta-admin-detail__header h2 {
margin: 5px 0 8px;
color: var(--text-primary);
font-size: 20px;
}
.beta-admin-detail__header p {
display: -webkit-box;
overflow: hidden;
margin: 0;
color: var(--text-muted);
font-size: 13px;
line-height: 1.7;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.beta-admin-code {
flex-shrink: 0;
padding: 8px 10px;
border: 1px solid rgba(var(--accent-rgb), 0.35);
border-radius: 8px;
background: rgba(var(--accent-rgb), 0.12);
color: var(--accent);
font-size: 13px;
}
.beta-admin-form-card {
padding: 16px;
}
.beta-admin-form-card h3 {
margin: 0 0 12px;
color: var(--text-primary);
font-size: 15px;
}
.beta-admin-field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.beta-admin-field {
min-width: 0;
padding: 10px;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: var(--surface-elevated);
}
.beta-admin-field--wide {
grid-column: 1 / -1;
}
.beta-admin-field span {
display: block;
margin-bottom: 4px;
color: var(--text-muted);
font-size: 12px;
}
.beta-admin-field strong {
display: block;
overflow-wrap: anywhere;
color: var(--text-primary);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.beta-admin-statement {
margin: 0 0 12px;
padding: 12px;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: var(--surface-elevated);
color: var(--text-primary);
font-size: 13px;
line-height: 1.8;
white-space: pre-wrap;
}
.beta-admin-review-box {
padding: 16px;
}
.beta-admin-review-box label {
display: grid;
gap: 8px;
}
.beta-admin-review-box label span {
color: var(--text-primary);
font-size: 13px;
font-weight: 850;
}
.beta-admin-review-box textarea {
width: 100%;
min-height: 92px;
resize: vertical;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--surface-elevated);
color: var(--text-primary);
font: inherit;
font-size: 13px;
line-height: 1.7;
outline: none;
padding: 10px;
}
.beta-admin-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
}
.beta-admin-actions button {
min-height: 38px;
padding: 0 16px;
}
.beta-admin-actions button:first-child {
border-color: rgba(239, 68, 68, 0.35);
color: #ef4444;
}
.beta-admin-actions button:last-child {
border-color: rgba(16, 185, 129, 0.35);
background: rgba(16, 185, 129, 0.14);
color: #10b981;
}
.beta-admin-access {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 420px;
color: var(--text-muted);
text-align: center;
}
.beta-admin-access svg {
color: var(--accent);
font-size: 28px;
}
.beta-admin-access h1 {
margin: 0;
color: var(--text-primary);
font-size: 20px;
}
.beta-admin-access p {
margin: 0;
font-size: 13px;
}
.beta-admin-access button {
min-height: 38px;
padding: 0 18px;
border-color: rgba(var(--accent-rgb), 0.38);
background: rgba(var(--accent-rgb), 0.14);
color: var(--accent);
}
@media (max-width: 900px) {
.beta-admin-page__inner {
width: min(100%, calc(100vw - 24px));
padding: 16px 12px;
overflow: auto;
}
.beta-admin-toolbar,
.beta-admin-detail__header {
flex-direction: column;
}
.beta-admin-layout {
grid-template-columns: 1fr;
overflow: visible;
}
.beta-admin-list {
max-height: none;
}
.beta-admin-detail {
max-height: none;
overflow: visible;
padding-right: 0;
}
}
@media (max-width: 640px) {
.beta-admin-field-grid {
grid-template-columns: 1fr;
}
.beta-admin-actions {
flex-direction: column;
}
}
+72 -97
View File
@@ -1542,9 +1542,7 @@
.product-clone-page[data-tool="clone"] .clone-ai-replicate-panel {
display: grid;
flex: 0 0 auto;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 9px;
min-height: 0;
overflow: visible;
border: 1px solid #303540;
border-radius: 14px;
@@ -1874,11 +1872,8 @@
.product-clone-page[data-tool="clone"] .clone-ai-module-panel {
display: grid;
flex: 0 0 272px;
grid-template-rows: auto minmax(0, 1fr);
flex: 0 0 auto;
gap: 10px;
height: 272px;
min-height: 0;
border: 1px solid #303540;
border-radius: 14px;
background: #1c1f26;
@@ -1906,25 +1901,6 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
align-content: start;
gap: 8px;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: #3d4552 #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-module-list::-webkit-scrollbar {
width: 6px;
}
.product-clone-page[data-tool="clone"] .clone-ai-module-list::-webkit-scrollbar-track {
border-radius: 999px;
background: #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-module-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #3d4552;
}
.product-clone-page[data-tool="clone"] .clone-ai-module-list button {
@@ -1981,11 +1957,8 @@
.product-clone-page[data-tool="clone"] .clone-ai-model-panel {
display: grid;
flex: 0 0 272px;
grid-template-rows: auto minmax(0, 1fr);
flex: 0 0 auto;
gap: 10px;
height: 272px;
min-height: 0;
border: 1px solid #303540;
border-radius: 14px;
background: #1c1f26;
@@ -2032,25 +2005,7 @@
}
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll {
min-height: 0;
overflow-y: auto;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: #3d4552 #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll::-webkit-scrollbar {
width: 6px;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll::-webkit-scrollbar-track {
border-radius: 999px;
background: #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-scroll::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #3d4552;
overflow: visible;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-scenes,
@@ -2223,16 +2178,12 @@
z-index: 30;
display: grid;
gap: 4px;
max-height: 150px;
overflow-y: auto;
border: 1px solid #303540;
border-radius: 8px;
background: #22252d;
background-image: none;
padding: 5px;
box-shadow: none;
scrollbar-width: thin;
scrollbar-color: #3d4552 #171a20;
transform-origin: top center;
animation: clone-ai-model-select-pop 160ms cubic-bezier(0.2, 0.82, 0.2, 1) both;
}
@@ -2244,20 +2195,6 @@
animation-name: clone-ai-model-select-pop-up;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-select__menu::-webkit-scrollbar {
width: 6px;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-select__menu::-webkit-scrollbar-track {
border-radius: 999px;
background: #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-select__menu::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #3d4552;
}
.product-clone-page[data-tool="clone"] .clone-ai-model-select__menu button {
display: flex;
align-items: center;
@@ -2351,31 +2288,14 @@
.product-clone-page[data-tool="clone"] .clone-ai-video-panel {
display: grid;
flex: 1 1 auto;
flex: 0 0 auto;
align-content: start;
gap: 12px;
min-height: 0;
overflow-y: auto;
overflow: visible;
border: 1px solid #303540;
border-radius: 14px;
background: #1c1f26;
padding: 12px;
scrollbar-width: thin;
scrollbar-color: #3d4552 #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-video-panel::-webkit-scrollbar {
width: 6px;
}
.product-clone-page[data-tool="clone"] .clone-ai-video-panel::-webkit-scrollbar-track {
border-radius: 999px;
background: #171a20;
}
.product-clone-page[data-tool="clone"] .clone-ai-video-panel::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #3d4552;
}
.product-clone-page[data-tool="clone"] .clone-ai-video-section {
@@ -8611,31 +8531,86 @@
transition: none;
}
.clone-ai-video-outfit-upload {
display: flex;
align-items: center;
gap: 10px;
position: relative;
display: grid;
gap: 8px;
margin-top: 6px;
}
.clone-ai-video-outfit-upload-btn {
padding: 7px 16px;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--bg-inset);
color: var(--fg-body);
display: flex;
width: 100%;
min-height: 118px;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1.5px dashed var(--ecm-line, var(--border-subtle));
border-radius: var(--ecm-radius-md, 14px);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 65%),
var(--ecm-inset, var(--bg-inset));
color: var(--ecm-text, var(--fg-body));
font-size: 13px;
font-weight: 850;
line-height: 1.35;
cursor: pointer;
transition: border-color 150ms, background 150ms;
text-align: center;
transition:
border-color 150ms,
background 150ms,
color 150ms,
transform 150ms;
}
.clone-ai-video-outfit-upload-btn::before {
display: grid;
width: 38px;
height: 38px;
place-items: center;
margin-bottom: 8px;
border: 1px solid rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.26);
border-radius: 999px;
background: rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.08);
color: var(--ecm-accent, var(--accent));
content: "+";
font-size: 24px;
font-weight: 800;
line-height: 1;
}
.clone-ai-video-outfit-upload-btn:hover {
border-color: var(--border-default);
background: var(--bg-hover);
border-color: rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.48);
background:
linear-gradient(180deg, rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.08), transparent 72%),
var(--ecm-inset-hover, var(--bg-hover));
color: var(--ecm-text, var(--fg-body));
}
.clone-ai-video-outfit-upload-btn:active {
transform: scale(0.99);
}
.clone-ai-video-outfit-info {
position: absolute;
top: 10px;
right: 10px;
max-width: calc(100% - 20px);
padding: 4px 8px;
border: 1px solid rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.28);
border-radius: 999px;
background: rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.1);
font-size: 12px;
color: var(--accent);
font-weight: 850;
color: var(--ecm-accent, var(--accent));
pointer-events: none;
}
.clone-ai-video-outfit-upload:has(.clone-ai-video-outfit-info) .clone-ai-video-outfit-upload-btn {
border-style: solid;
border-color: rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.38);
background:
linear-gradient(180deg, rgba(var(--ecm-accent-rgb, 0, 255, 136), 0.075), transparent 74%),
var(--ecm-inset, var(--bg-inset));
}
/* Ecommerce generation page SaaS polish: visual-only refinement for the product creation workspace. */
+2346 -15
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -11915,6 +11915,21 @@
gap: 6px;
}
.wb-composer__billing-estimate {
max-width: 138px;
padding: 6px 9px;
border: 2px solid #111;
background: #fffbe8;
color: #111;
font-size: 12px;
font-weight: 800;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 2px 2px 0 #111;
}
.wb-composer__send-primary {
display: inline-flex;
align-items: center;
+331 -14
View File
@@ -83,14 +83,15 @@
.mgs-brand-section h1 {
max-width: 9.6em;
margin: 0;
background: linear-gradient(135deg, var(--mgs-green), var(--mgs-mint), var(--mgs-blue));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
background: none;
color: #eef8f2;
font-size: clamp(30px, 2.35cqw, 50px);
font-weight: 950;
letter-spacing: 0;
line-height: 1.16;
text-shadow:
0 18px 54px rgba(0, 0, 0, 0.38),
0 0 34px rgba(0, 255, 136, 0.08);
}
.mgs-subtitle {
@@ -180,6 +181,10 @@
font-weight: 900;
}
.mgs-mode-icon .anticon {
font-size: 0.9em;
}
.mgs-mode-info {
min-width: 0;
}
@@ -591,6 +596,10 @@
box-shadow 200ms ease;
}
.mgs-img-cell .anticon {
font-size: 0.9em;
}
.mgs-img-cell:hover {
border-color: var(--mgs-border-hover);
transform: scale(1.02);
@@ -664,11 +673,9 @@
transform: scale(1.06);
}
.mgs-play-btn svg {
width: 34%;
height: 34%;
margin-left: 3px;
fill: var(--mgs-green);
.mgs-play-btn .anticon {
color: var(--mgs-green);
font-size: 46%;
}
.mgs-video-timeline {
@@ -900,11 +907,9 @@
background: rgba(168, 85, 247, 0.18);
}
.mgs-mini-play svg {
width: 34%;
height: 34%;
margin-left: 2px;
fill: var(--mgs-purple);
.mgs-mini-play .anticon {
color: var(--mgs-purple);
font-size: 54%;
}
.mgs-video-duration {
@@ -1003,3 +1008,315 @@
text-align: left;
}
}
/* Home landing responsive containment */
@media (max-width: 980px) {
.web-shell[data-view="home"] .omni-model-gen-showcase {
gap: 14px;
padding: 18px;
overflow-y: auto;
}
.web-shell[data-view="home"] .mgs-brand-section {
gap: 10px;
}
.web-shell[data-view="home"] .mgs-brand-section h1 {
font-size: clamp(28px, 6vw, 42px);
}
.web-shell[data-view="home"] .mgs-subtitle {
font-size: clamp(13px, 2.4vw, 16px);
line-height: 1.55;
}
.web-shell[data-view="home"] .mgs-mode-tabs {
gap: 10px;
}
.web-shell[data-view="home"] .mgs-mode-tab {
min-height: 92px;
padding: 12px;
}
.web-shell[data-view="home"] .mgs-input-card {
min-height: 420px;
}
}
@media (max-width: 560px) {
.web-shell[data-view="home"] .omni-model-gen-showcase {
gap: 12px;
padding: 14px;
}
.web-shell[data-view="home"] .mgs-mode-tabs {
grid-template-columns: 1fr;
}
.web-shell[data-view="home"] .mgs-mode-tab {
grid-template-columns: 42px minmax(0, 1fr);
justify-items: stretch;
min-height: 70px;
text-align: left;
}
.web-shell[data-view="home"] .mgs-mode-icon {
width: 42px;
height: 42px;
font-size: 20px;
}
.web-shell[data-view="home"] .mgs-mode-info p {
display: none;
}
.web-shell[data-view="home"] .mgs-workflow {
display: none;
}
.web-shell[data-view="home"] .mgs-input-card {
min-height: 360px;
}
.web-shell[data-view="home"] .mgs-output-cards {
gap: 10px;
}
.web-shell[data-view="home"] .mgs-out-card {
min-height: auto;
padding: 14px;
}
}
/* Homepage landing tune: calmer, responsive product showcase. */
.web-shell[data-view="home"] .omni-model-gen-showcase {
grid-template-columns: minmax(250px, 0.7fr) minmax(330px, 1fr) minmax(330px, 1fr);
gap: clamp(16px, 1.8vw, 28px);
padding: clamp(22px, 2.4vw, 38px);
background: transparent;
}
.web-shell[data-view="home"] .omni-model-gen-showcase::before {
background: radial-gradient(circle at 50% 0%, rgb(0 255 136 / 8%), transparent 34%);
opacity: 0.5;
}
.web-shell[data-view="home"] .omni-model-gen-showcase::after {
opacity: 0.1;
}
.web-shell[data-view="home"] .mgs-left-panel {
gap: clamp(14px, 1.6vw, 24px);
padding-block: 0;
}
.web-shell[data-view="home"] .mgs-brand-section {
gap: 14px;
}
.web-shell[data-view="home"] .mgs-brand-section h1 {
max-width: 8em;
font-size: clamp(32px, 3vw, 48px);
line-height: 1.12;
}
.web-shell[data-view="home"] .mgs-subtitle {
color: rgb(232 240 236 / 66%);
font-size: clamp(14px, 1.05vw, 16px);
line-height: 1.68;
}
.web-shell[data-view="home"] .mgs-mode-tabs {
gap: 10px;
}
.web-shell[data-view="home"] .mgs-mode-tab,
.web-shell[data-view="home"] .mgs-workflow,
.web-shell[data-view="home"] .mgs-input-card,
.web-shell[data-view="home"] .mgs-out-card {
border-color: rgb(255 255 255 / 10%);
border-radius: 14px;
background:
linear-gradient(180deg, rgb(255 255 255 / 6%), rgb(255 255 255 / 2.5%)),
rgb(8 13 12 / 76%);
box-shadow:
0 18px 44px rgb(0 0 0 / 22%),
inset 0 1px 0 rgb(255 255 255 / 7%);
}
.web-shell[data-view="home"] .mgs-mode-tab {
grid-template-columns: 42px minmax(0, 1fr);
min-height: 78px;
padding: 12px 14px;
}
.web-shell[data-view="home"] .mgs-mode-tab:hover {
transform: translateX(2px);
}
.web-shell[data-view="home"] .mgs-mode-icon {
width: 42px;
height: 42px;
border-radius: 12px;
background: rgb(255 255 255 / 5%);
color: rgb(214 255 236 / 82%);
font-size: 20px;
}
.web-shell[data-view="home"] .mgs-mode-info h3 {
font-size: clamp(15px, 1.05vw, 18px);
}
.web-shell[data-view="home"] .mgs-mode-info p {
color: rgb(232 240 236 / 46%);
font-size: clamp(12px, 0.82vw, 13px);
}
.web-shell[data-view="home"] .mgs-workflow {
padding: 14px;
}
.web-shell[data-view="home"] .mgs-workflow-title {
margin-bottom: 10px;
color: rgb(232 240 236 / 42%);
font-size: 11px;
letter-spacing: 0.04em;
}
.web-shell[data-view="home"] .mgs-workflow-steps {
flex-wrap: wrap;
gap: 8px;
}
.web-shell[data-view="home"] .mgs-workflow-steps > span {
gap: 8px;
}
.web-shell[data-view="home"] .mgs-wf-step {
min-height: 34px;
padding-inline: 10px;
font-size: 12px;
}
.web-shell[data-view="home"] .mgs-input-card {
align-self: center;
height: auto;
min-height: clamp(430px, 48dvh, 560px);
max-width: 100%;
padding: clamp(18px, 1.8vw, 26px);
}
.web-shell[data-view="home"] .mgs-card-mode-badge {
min-height: 36px;
padding-inline: 14px;
font-size: 13px;
}
.web-shell[data-view="home"] .mgs-card-status {
font-size: 12px;
}
.web-shell[data-view="home"] .mgs-prompt-input {
height: clamp(82px, 8vw, 112px);
font-size: 13px;
}
.web-shell[data-view="home"] .mgs-agent-result-text,
.web-shell[data-view="home"] .mgs-out-preview {
font-size: 13px;
line-height: 1.62;
}
.web-shell[data-view="home"] .mgs-right-panel {
justify-content: center;
}
.web-shell[data-view="home"] .mgs-output-cards {
gap: 12px;
}
.web-shell[data-view="home"] .mgs-out-card {
min-height: clamp(128px, 12dvh, 172px);
padding: 16px;
}
.web-shell[data-view="home"] .mgs-out-title {
font-size: clamp(15px, 1.08vw, 18px);
}
.web-shell[data-view="home"] .mgs-out-img-placeholder,
.web-shell[data-view="home"] .mgs-out-video-placeholder {
min-height: clamp(72px, 7dvh, 104px);
border-style: solid;
}
.web-shell[data-view="home"] .mgs-out-img-placeholder .anticon {
font-size: 0.9em;
}
@container (max-width: 1180px) {
.web-shell[data-view="home"] .omni-model-gen-showcase {
grid-template-columns: minmax(240px, 0.64fr) minmax(0, 1.36fr);
}
.web-shell[data-view="home"] .mgs-right-panel {
grid-column: 1 / -1;
}
.web-shell[data-view="home"] .mgs-output-cards {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.web-shell[data-view="home"] .omni-model-gen-showcase {
grid-template-columns: 1fr;
overflow: visible;
}
.web-shell[data-view="home"] .mgs-left-panel {
grid-template-rows: auto auto auto;
}
.web-shell[data-view="home"] .mgs-brand-section h1,
.web-shell[data-view="home"] .mgs-subtitle {
max-width: none;
}
.web-shell[data-view="home"] .mgs-mode-tabs {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.web-shell[data-view="home"] .mgs-mode-tab {
grid-template-columns: 38px minmax(0, 1fr);
min-height: 70px;
}
.web-shell[data-view="home"] .mgs-input-card {
min-height: 420px;
}
}
@media (max-width: 720px) {
.web-shell[data-view="home"] .omni-model-gen-showcase {
padding: 10px 0;
}
.web-shell[data-view="home"] .mgs-mode-tabs,
.web-shell[data-view="home"] .mgs-output-cards {
grid-template-columns: 1fr;
}
.web-shell[data-view="home"] .mgs-mode-info p {
display: block;
}
.web-shell[data-view="home"] .mgs-workflow {
display: none;
}
.web-shell[data-view="home"] .mgs-input-card {
min-height: 360px;
}
}
+311 -117
View File
@@ -1,6 +1,11 @@
.more-page-v2 {
--more-card-shadow: 0 18px 48px rgba(0, 0, 0, 0.24);
--more-card-glow: 0 0 0 1px rgba(255, 255, 255, 0.025), 0 18px 38px rgba(0, 0, 0, 0.16);
--more-card-shadow: 0 22px 54px rgba(0, 0, 0, 0.3);
--more-card-glow: 0 0 0 1px rgba(255, 255, 255, 0.035), 0 16px 34px rgba(0, 0, 0, 0.18);
--more-card-surface: rgba(19, 23, 24, 0.86);
--more-card-surface-strong: rgba(22, 27, 28, 0.94);
--more-card-border: rgba(255, 255, 255, 0.105);
--more-card-border-strong: rgba(var(--accent-rgb), 0.3);
--more-page-pad-x: clamp(18px, 2.3vw, 32px);
position: relative;
display: grid;
@@ -158,24 +163,24 @@
.more-page-v2__scroll {
overflow-y: auto;
padding: 26px 28px 68px;
padding: 28px var(--more-page-pad-x) 72px;
scrollbar-color: rgba(var(--accent-rgb), 0.26) transparent;
}
.more-page-v2__section {
margin-bottom: 30px;
margin-bottom: 34px;
}
.more-page-v2__section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 15px;
color: var(--fg-muted);
margin: 0 0 14px;
color: color-mix(in srgb, var(--fg-muted) 86%, var(--fg-body));
font-size: 12px;
font-weight: 800;
font-weight: 850;
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.055em;
}
.more-page-v2__section-title .anticon {
@@ -199,27 +204,31 @@
.more-page-v2__recent-row {
display: flex;
gap: 12px;
gap: 10px;
flex-wrap: wrap;
}
.more-page-v2__featured-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.more-card--featured {
display: flex;
align-items: flex-start;
gap: 18px;
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
justify-items: stretch;
gap: 12px;
min-height: 336px;
padding: 20px;
border-color: rgba(var(--accent-rgb), 0.18);
border-color: rgba(var(--accent-rgb), 0.2);
border-radius: var(--radius-xs, 8px);
background:
var(--card-gradient),
linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.012)),
var(--bg-panel);
radial-gradient(circle at 14% 4%, rgba(var(--accent-rgb), 0.12), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.016)),
var(--more-card-surface-strong);
box-shadow: var(--more-card-glow);
position: relative;
overflow: hidden;
@@ -230,42 +239,52 @@
position: absolute;
inset: 0;
background:
linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.035), transparent),
linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 34%);
opacity: 0.5;
linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.038), transparent),
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 34%);
opacity: 0.62;
pointer-events: none;
}
.more-card--featured:hover {
border-color: rgba(var(--accent-rgb), 0.45);
transform: translateY(-3px);
box-shadow: var(--more-card-shadow), 0 0 0 1px rgba(var(--accent-rgb), 0.1);
}
.more-card__featured-icon {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border: 1px solid rgba(var(--accent-rgb), 0.22);
border-radius: var(--radius-xs, 8px);
background:
linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.08)),
var(--bg-inset);
color: var(--accent);
font-size: 24px;
flex-shrink: 0;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
border-color: rgba(var(--accent-rgb), 0.46);
transform: translateY(-2px);
box-shadow: var(--more-card-shadow), 0 0 0 1px rgba(var(--accent-rgb), 0.12);
}
.more-card__featured-body {
display: flex;
flex-direction: column;
gap: 9px;
gap: 10px;
justify-self: stretch;
width: 100%;
height: 100%;
min-width: 0;
text-align: left;
}
.more-card--featured .more-card__preview {
width: 100%;
min-height: 0;
aspect-ratio: 4 / 3;
}
.more-card--featured .more-card__preview-frame img {
padding: 8px;
object-fit: contain;
}
.more-card--featured.more-card--no-preview {
min-height: 0;
}
.more-card--featured.more-card--no-preview .more-card__featured-body {
justify-content: flex-start;
}
.more-card--featured.more-card--no-preview .more-card__outcome {
margin-top: 4px;
}
.more-card__featured-kicker {
width: fit-content;
color: var(--accent);
@@ -277,14 +296,14 @@
.more-card__featured-body strong {
color: var(--fg-body);
font-size: 18px;
font-weight: 800;
font-size: 20px;
font-weight: 850;
line-height: 1.25;
}
.more-card__featured-desc {
font-size: 13px;
color: var(--fg-muted);
color: color-mix(in srgb, var(--fg-muted) 88%, var(--fg-body));
line-height: 1.5;
}
@@ -327,20 +346,23 @@
display: inline-flex;
align-items: center;
width: fit-content;
min-height: 28px;
margin-top: 0;
padding: 0 10px;
border: 1px solid rgba(var(--accent-rgb), 0.28);
min-height: 32px;
margin-top: auto;
padding: 0 12px;
border: 1px solid rgba(var(--accent-rgb), 0.34);
border-radius: var(--radius-xs, 8px);
background: rgba(var(--accent-rgb), 0.08);
background:
linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.08)),
rgba(var(--accent-rgb), 0.06);
font-size: 12px;
font-weight: 800;
font-weight: 850;
color: var(--accent) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.more-page-v2__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(236px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
@@ -350,18 +372,22 @@
align-content: start;
justify-items: start;
min-width: 0;
gap: 10px;
min-height: 392px;
gap: 12px;
padding: 18px;
border: 1px solid var(--border-weak);
border: 1px solid var(--more-card-border);
border-radius: var(--radius-xs, 8px);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.032), transparent 42%),
var(--bg-panel);
radial-gradient(circle at 12% 0%, rgba(var(--accent-rgb), 0.055), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 42%),
var(--more-card-surface);
color: var(--fg-body);
font: inherit;
text-align: left;
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.035),
0 1px 0 rgba(255, 255, 255, 0.02);
transition:
border-color 160ms ease,
background 160ms ease,
@@ -370,12 +396,19 @@
}
.more-card:hover {
border-color: rgba(var(--accent-rgb), 0.38);
border-color: var(--more-card-border-strong);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 46%),
var(--bg-hover, rgba(255, 255, 255, 0.03));
radial-gradient(circle at 12% 0%, rgba(var(--accent-rgb), 0.085), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.052), transparent 46%),
rgba(24, 29, 30, 0.94);
transform: translateY(-2px);
box-shadow: var(--more-card-glow), 0 10px 26px rgba(0, 0, 0, 0.16);
box-shadow: var(--more-card-glow), 0 14px 30px rgba(0, 0, 0, 0.18);
}
.more-card:active,
.more-page-v2__filters button:active,
.more-page-v2__empty-action:active {
transform: translateY(0);
}
.more-card--pending {
@@ -395,17 +428,20 @@
display: flex;
align-items: center;
gap: 10px;
min-width: 150px;
min-height: 54px;
padding: 10px 14px;
border-color: rgba(var(--accent-rgb), 0.14);
min-width: 164px;
min-height: 58px;
padding: 11px 14px;
border-color: rgba(var(--accent-rgb), 0.16);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.016)),
rgba(18, 23, 24, 0.88);
}
.more-card__icon {
display: grid;
display: none;
place-items: center;
width: 40px;
height: 40px;
width: 38px;
height: 38px;
border: 1px solid rgba(var(--accent-rgb), 0.16);
border-radius: var(--radius-xs, 8px);
background:
@@ -417,6 +453,7 @@
}
.more-card--recent .more-card__icon {
display: grid;
width: 30px;
height: 30px;
font-size: 14px;
@@ -437,15 +474,15 @@
.more-card strong {
max-width: 100%;
color: var(--fg-body);
font-size: 14px;
font-weight: 800;
line-height: 1.35;
font-size: 16px;
font-weight: 850;
line-height: 1.28;
}
.more-card__topline {
position: absolute;
top: 14px;
right: 14px;
top: 18px;
right: 18px;
display: inline-flex;
flex-wrap: wrap;
justify-content: flex-end;
@@ -472,9 +509,9 @@
position: relative;
display: block;
width: 100%;
min-height: 92px;
min-height: 104px;
overflow: hidden;
border: 1px solid rgba(var(--accent-rgb), 0.28);
border: 1px solid rgba(var(--accent-rgb), 0.24);
border-radius: 10px;
background:
linear-gradient(135deg, rgba(var(--accent-rgb), 0.1), transparent 34%),
@@ -483,8 +520,7 @@
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
inset 0 -1px 0 rgba(0, 0, 0, 0.34),
0 0 20px rgba(var(--accent-rgb), 0.08);
clip-path: polygon(0 10px, 10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%);
0 0 18px rgba(var(--accent-rgb), 0.07);
isolation: isolate;
}
@@ -506,7 +542,6 @@
inset: 5px;
z-index: 3;
border: 1px solid rgba(var(--accent-rgb), 0.16);
clip-path: polygon(0 8px, 8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%);
content: "";
pointer-events: none;
}
@@ -880,15 +915,102 @@
border-radius: 8px;
}
.more-card__preview {
position: relative;
display: block;
width: 100%;
aspect-ratio: 1.42 / 1;
overflow: visible;
isolation: isolate;
}
.more-card__preview-frame {
position: absolute;
inset: 0;
display: block;
overflow: hidden;
border: 1px solid rgba(var(--accent-rgb), 0.22);
border-radius: var(--radius-xs, 8px);
background:
radial-gradient(circle at 50% 42%, rgba(var(--accent-rgb), 0.12), transparent 56%),
linear-gradient(135deg, rgba(var(--accent-rgb), 0.08), transparent 34%),
var(--bg-inset);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.07),
0 0 18px rgba(var(--accent-rgb), 0.06);
}
.more-card__preview-frame::after {
position: absolute;
inset: 0;
z-index: 1;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 34%, rgba(0, 0, 0, 0.18)),
linear-gradient(90deg, rgba(255, 255, 255, 0.045), transparent 38%, rgba(255, 255, 255, 0.025));
content: "";
pointer-events: none;
}
.more-card__preview-frame img,
.more-card__preview-popover {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
padding: 6px;
transform: none;
transition:
filter 220ms ease;
}
.more-card:hover .more-card__preview-frame img {
filter: saturate(1.05) contrast(1.02);
}
.more-card__preview-popover {
position: absolute;
left: 50%;
bottom: calc(100% + 12px);
z-index: 20;
width: min(420px, calc(100vw - 48px));
height: auto;
max-height: min(360px, 58vh);
padding: 10px;
border: 1px solid rgba(var(--accent-rgb), 0.34);
border-radius: var(--radius-xs, 8px);
background:
radial-gradient(circle at 50% 20%, rgba(var(--accent-rgb), 0.12), transparent 52%),
rgba(10, 14, 14, 0.96);
box-shadow:
0 28px 68px rgba(0, 0, 0, 0.46),
0 0 0 1px rgba(255, 255, 255, 0.04);
opacity: 0;
pointer-events: none;
transform: translate(-50%, 8px) scale(0.96);
transform-origin: 50% 100%;
transition:
opacity 160ms ease,
transform 160ms ease;
}
.more-card__preview:hover .more-card__preview-popover {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
.more-card--featured .more-card__preview-popover {
display: none;
}
.more-card__desc {
color: var(--fg-muted);
color: color-mix(in srgb, var(--fg-muted) 88%, var(--fg-body));
font-size: 12.5px;
line-height: 1.5;
line-height: 1.55;
}
.more-card__use-case {
display: block;
min-height: 38px;
min-height: 50px;
color: color-mix(in srgb, var(--fg-muted) 78%, var(--fg-body));
font-size: 12px;
line-height: 1.55;
@@ -898,15 +1020,15 @@
display: inline-flex;
align-items: center;
width: fit-content;
min-height: 26px;
margin-top: 2px;
padding: 0 9px;
border: 1px solid rgba(255, 255, 255, 0.08);
min-height: 30px;
margin-top: auto;
padding: 0 10px;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: var(--radius-xs, 8px);
background: rgba(255, 255, 255, 0.035);
color: var(--fg-body);
font-size: 11px;
font-weight: 800;
font-weight: 850;
transition:
border-color 160ms ease,
background 160ms ease,
@@ -915,8 +1037,8 @@
}
.more-card:hover .more-card__action {
border-color: rgba(var(--accent-rgb), 0.28);
background: rgba(var(--accent-rgb), 0.08);
border-color: rgba(var(--accent-rgb), 0.32);
background: rgba(var(--accent-rgb), 0.1);
color: var(--accent);
transform: translateX(2px);
}
@@ -936,14 +1058,15 @@
.more-page-v2__empty {
display: grid;
justify-items: center;
gap: 10px;
min-height: 220px;
padding: 34px 20px;
border: 1px solid var(--border-weak);
gap: 12px;
min-height: 238px;
padding: 38px 22px;
border: 1px solid var(--more-card-border);
border-radius: var(--radius-xs, 8px);
background:
linear-gradient(180deg, rgba(var(--accent-rgb), 0.065), transparent 64%),
var(--bg-panel);
radial-gradient(circle at 50% 0%, rgba(var(--accent-rgb), 0.1), transparent 42%),
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 64%),
var(--more-card-surface);
color: var(--fg-muted);
text-align: center;
}
@@ -951,11 +1074,13 @@
.more-page-v2__empty-icon {
display: grid;
place-items: center;
width: 48px;
height: 48px;
width: 52px;
height: 52px;
border: 1px solid rgba(var(--accent-rgb), 0.22);
border-radius: var(--radius-xs, 8px);
background: rgba(var(--accent-rgb), 0.1);
background:
linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.08)),
rgba(var(--accent-rgb), 0.08);
color: var(--accent);
font-size: 20px;
}
@@ -978,12 +1103,14 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
min-height: 36px;
margin-top: 4px;
padding: 0 12px;
border: 1px solid rgba(var(--accent-rgb), 0.32);
padding: 0 14px;
border: 1px solid rgba(var(--accent-rgb), 0.36);
border-radius: var(--radius-xs, 8px);
background: rgba(var(--accent-rgb), 0.08);
background:
linear-gradient(180deg, rgba(var(--accent-rgb), 0.14), rgba(var(--accent-rgb), 0.08)),
rgba(var(--accent-rgb), 0.06);
color: var(--accent);
font: inherit;
font-size: 12px;
@@ -1013,6 +1140,7 @@
.more-page-v2__header {
grid-template-columns: minmax(180px, auto) minmax(0, 1fr);
gap: 14px;
}
.more-page-v2__filters {
@@ -1023,15 +1151,21 @@
@media (max-width: 860px) {
.more-page-v2 {
--more-page-pad-x: 16px;
padding-left: 0;
}
.more-page-v2__header {
grid-template-columns: minmax(0, 1fr);
padding: 14px 16px 12px;
padding: 16px 16px 14px;
gap: 12px;
}
.more-page-v2__header h1 {
font-size: 24px;
}
.more-page-v2__header-meta {
gap: 6px;
}
@@ -1047,13 +1181,22 @@
padding-right: 16px;
}
.more-page-v2__filters button {
min-height: 31px;
padding: 0 10px;
}
.more-page-v2__scroll {
padding: 16px 16px 48px;
padding: 18px 16px 52px;
}
.more-page-v2__section {
margin-bottom: 26px;
}
.more-page-v2__grid {
grid-template-columns: repeat(auto-fill, minmax(172px, 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
}
.more-page-v2__recent-row {
@@ -1063,23 +1206,24 @@
}
.more-page-v2__featured-grid {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.more-card--featured {
grid-template-columns: minmax(0, 1fr);
min-height: 0;
padding: 16px;
gap: 12px;
}
.more-card__featured-icon {
width: 42px;
height: 42px;
font-size: 20px;
.more-card__featured-body strong {
font-size: 16px;
}
.more-card__featured-body strong {
font-size: 15px;
.more-card--featured .more-card__preview {
width: 100%;
min-height: 176px;
}
.more-card__featured-kicker,
@@ -1097,8 +1241,13 @@
font-size: 10px;
}
.more-card__compare {
min-height: 82px;
.more-card__preview {
min-height: 190px;
}
.more-card {
min-height: 394px;
padding: 16px;
}
.more-card__topline {
@@ -1108,24 +1257,69 @@
}
.more-card__use-case {
min-height: 54px;
min-height: 46px;
}
}
@media (max-width: 520px) {
.more-page-v2__header {
gap: 10px;
padding-top: 14px;
}
.more-page-v2__header-meta {
overflow-x: auto;
flex-wrap: nowrap;
margin-right: -16px;
padding-right: 16px;
scrollbar-width: none;
}
.more-page-v2__header-meta::-webkit-scrollbar {
display: none;
}
.more-page-v2__grid {
grid-template-columns: 1fr;
}
.more-card {
gap: 9px;
.more-page-v2__featured-grid {
grid-template-columns: 1fr;
}
.more-card__compare {
min-height: 94px;
.more-page-v2__section-title {
margin-bottom: 12px;
}
.more-card--featured {
grid-template-columns: 1fr;
padding: 15px;
}
.more-card {
gap: 10px;
min-height: 0;
padding: 15px;
}
.more-card__preview {
min-height: 190px;
}
.more-card__use-case {
min-height: 0;
}
.more-card__action,
.more-card__cta {
min-height: 32px;
width: 100%;
justify-content: center;
}
}
@media (hover: none) {
.more-card__preview-popover {
display: none;
}
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -1620,12 +1620,23 @@
border: 0;
background: transparent;
cursor: default;
position: relative;
}
.script-eval-v5-illustration:hover {
background: transparent;
}
.script-eval-v5-illustration.is-dragging .script-eval-v5-illustration-hit {
background: var(--v5-green-deep);
outline: 2px dashed var(--v5-green);
transform: scale(1.02);
}
.script-eval-v5-illustration .script-eval-v5-upload-drop-overlay {
border-radius: 10px;
}
.script-eval-v5-illustration-hit {
display: inline-flex;
flex-direction: column;
+19
View File
@@ -299,6 +299,25 @@
gap: 10px;
padding: 32px;
text-align: center;
border-radius: 12px;
cursor: pointer;
transition: background 0.18s ease, outline 0.18s ease, transform 0.18s ease;
}
.studio-canvas-ghost:hover {
background: rgba(var(--accent-rgb), 0.05);
outline: 1px dashed rgba(var(--accent-rgb), 0.25);
}
.studio-canvas-ghost:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.studio-canvas-ghost.is-dragging {
background: rgba(var(--accent-rgb), 0.1);
outline: 2px dashed var(--accent);
transform: scale(1.02);
}
.studio-canvas-ghost__icon {
+869
View File
@@ -889,3 +889,872 @@
animation: none;
}
}
/* ===== Home toolbox polish and responsive hardening ===== */
.web-shell[data-view="home"] .omni-home__toolbox-page {
--toolbox-radius-card: 16px;
--toolbox-radius-inner: 12px;
background:
linear-gradient(180deg, #070b10 0%, #05080d 100%),
radial-gradient(ellipse 70% 48% at 58% 42%, rgba(0, 255, 136, 0.045) 0%, transparent 70%);
}
.web-shell[data-view="home"] .omni-home__toolbox-shell {
width: min(100%, 1440px);
margin-inline: auto;
padding: clamp(34px, 5vw, 64px) clamp(18px, 5vw, 72px);
align-items: center;
}
.web-shell[data-view="home"] .omni-home__toolbox-left {
width: clamp(300px, 28vw, 420px);
gap: 14px;
padding-top: 0;
}
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon {
width: 48px;
height: 48px;
border-radius: 13px;
box-shadow: 0 16px 32px rgba(0, 255, 136, 0.12);
}
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon .anticon {
font-size: 24px;
}
.web-shell[data-view="home"] .omni-home__toolbox-brand-text {
font-size: clamp(24px, 2.3vw, 32px);
}
.web-shell[data-view="home"] .omni-home__toolbox-title {
color: #f7fff9;
background: none;
-webkit-text-fill-color: currentColor;
font-size: clamp(32px, 3.4vw, 46px);
letter-spacing: 0;
}
.web-shell[data-view="home"] .omni-home__toolbox-subtitle {
color: rgba(232, 238, 236, 0.68);
font-size: clamp(15px, 1.18vw, 17px);
}
.web-shell[data-view="home"] .omni-home__toolbox-item,
.web-shell[data-view="home"] .omni-home__toolbox-workflow,
.web-shell[data-view="home"] .omni-home__toolbox-card {
border-color: rgba(255, 255, 255, 0.1);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.065), rgba(255, 255, 255, 0.028)),
rgba(10, 15, 16, 0.78);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.075),
0 18px 42px rgba(0, 0, 0, 0.2);
}
.web-shell[data-view="home"] .omni-home__toolbox-item {
border-radius: 14px;
padding: 14px 16px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item:hover {
border-color: rgba(0, 255, 136, 0.24);
transform: translateX(3px);
}
.web-shell[data-view="home"] .omni-home__toolbox-item-icon {
width: 42px;
height: 42px;
border-radius: 11px;
font-size: 22px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item-name {
font-size: 16px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item-desc {
font-size: 13px;
color: rgba(232, 238, 236, 0.48);
}
.web-shell[data-view="home"] .omni-home__toolbox-workflow {
margin-top: 4px;
border-radius: 14px;
padding: 15px 17px;
}
.web-shell[data-view="home"] .omni-home__toolbox-workflow-label {
margin-bottom: 10px;
font-size: 12px;
letter-spacing: 0.04em;
}
.web-shell[data-view="home"] .omni-home__toolbox-workflow-steps {
gap: 8px;
font-size: 13px;
}
.web-shell[data-view="home"] .omni-home__toolbox-grid {
gap: 14px;
min-height: clamp(500px, 48vw, 680px);
}
.web-shell[data-view="home"] .omni-home__toolbox-card {
border-radius: var(--toolbox-radius-card);
transform: translateZ(0);
}
.web-shell[data-view="home"] .omni-home__toolbox-card:hover {
transform: translateY(-4px);
border-color: rgba(0, 255, 136, 0.24);
box-shadow:
0 22px 54px rgba(0, 0, 0, 0.28),
0 0 0 1px rgba(0, 255, 136, 0.07);
}
.web-shell[data-view="home"] .omni-home__toolbox-card-header {
padding: 15px 16px 0;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-content {
padding: 10px 16px;
min-height: 0;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-footer {
padding: 7px 16px 14px;
}
.web-shell[data-view="home"] :is(.toolbox-card1-side, .toolbox-card3-side, .toolbox-card4-side),
.web-shell[data-view="home"] :is(.toolbox-card1-img, .toolbox-card3-portrait, .toolbox-card4-img),
.web-shell[data-view="home"] .toolbox-card2-frame {
border-radius: var(--toolbox-radius-inner);
}
@media (max-width: 1160px) {
.web-shell[data-view="home"] .omni-home__toolbox-shell {
gap: 22px;
}
.web-shell[data-view="home"] .omni-home__toolbox-left {
width: clamp(280px, 32vw, 360px);
}
.web-shell[data-view="home"] .omni-home__toolbox-grid {
min-height: clamp(460px, 58vw, 620px);
}
}
@media (max-width: 980px) {
.web-shell[data-view="home"] .omni-home__toolbox-page {
min-height: auto;
}
.web-shell[data-view="home"] .omni-home__toolbox-shell {
min-height: auto;
padding-block: clamp(42px, 7vw, 64px);
}
.web-shell[data-view="home"] .omni-home__toolbox-left {
width: min(100%, 760px);
}
.web-shell[data-view="home"] .omni-home__toolbox-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.web-shell[data-view="home"] .omni-home__toolbox-workflow {
width: 100%;
}
.web-shell[data-view="home"] .omni-home__toolbox-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
min-height: auto;
}
.web-shell[data-view="home"] .omni-home__toolbox-card {
min-height: clamp(230px, 34vw, 300px);
}
}
@media (max-width: 680px) {
.web-shell[data-view="home"] .omni-home__toolbox-shell {
padding-inline: 14px;
gap: 18px;
}
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon {
width: 42px;
height: 42px;
}
.web-shell[data-view="home"] .omni-home__toolbox-brand-text {
font-size: 22px;
}
.web-shell[data-view="home"] .omni-home__toolbox-title {
font-size: clamp(26px, 7vw, 34px);
}
.web-shell[data-view="home"] .omni-home__toolbox-list,
.web-shell[data-view="home"] .omni-home__toolbox-grid {
grid-template-columns: 1fr;
}
.web-shell[data-view="home"] .omni-home__toolbox-item {
align-items: center;
}
.web-shell[data-view="home"] .omni-home__toolbox-card {
min-height: 236px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-header {
align-items: flex-start;
gap: 10px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-tag {
flex-shrink: 0;
}
}
@media (max-width: 420px) {
.web-shell[data-view="home"] .omni-home__toolbox-workflow-steps {
align-items: flex-start;
}
.web-shell[data-view="home"] .omni-home__toolbox-workflow-arrow {
display: none;
}
.web-shell[data-view="home"] .omni-home__toolbox-card {
min-height: 220px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-footer {
gap: 5px;
}
}
/* Premium landing pass: keep toolbox content, align material with the home redesign. */
.web-shell[data-view="home"] .omni-home__toolbox-page {
--toolbox-surface: rgb(255 255 255 / 4.5%);
--toolbox-elevated: rgb(255 255 255 / 6%);
--toolbox-border-subtle: rgb(255 255 255 / 8%);
--toolbox-border-default: rgb(255 255 255 / 10%);
--toolbox-border-hover: rgb(0 255 136 / 28%);
--toolbox-text-primary: #f4f8f5;
--toolbox-text-secondary: rgb(232 240 236 / 66%);
--toolbox-text-tertiary: rgb(232 240 236 / 42%);
border-top-color: rgb(255 255 255 / 7%);
background:
radial-gradient(circle at 18% 20%, rgb(0 255 136 / 9%), transparent 31%),
radial-gradient(circle at 82% 70%, rgb(84 139 255 / 7%), transparent 30%),
linear-gradient(180deg, #050807 0%, #030504 100%);
}
.web-shell[data-view="home"] .omni-home__toolbox-page::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
background:
linear-gradient(90deg, rgb(255 255 255 / 2.3%) 1px, transparent 1px),
linear-gradient(180deg, rgb(255 255 255 / 2.3%) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(180deg, rgb(0 0 0 / 62%), rgb(0 0 0 / 20%));
pointer-events: none;
}
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon {
border: 1px solid rgb(0 255 136 / 28%);
background:
linear-gradient(145deg, rgb(0 255 136 / 22%), rgb(255 255 255 / 4%)),
rgb(7 17 15 / 90%);
color: var(--toolbox-green);
box-shadow:
0 16px 40px rgb(0 0 0 / 24%),
inset 0 1px 0 rgb(255 255 255 / 10%);
}
.web-shell[data-view="home"] .omni-home__toolbox-title {
background: none;
color: var(--toolbox-text-primary);
-webkit-text-fill-color: currentColor;
}
.web-shell[data-view="home"] .omni-home__toolbox-subtitle {
color: var(--toolbox-text-secondary);
}
.web-shell[data-view="home"] .omni-home__toolbox-item,
.web-shell[data-view="home"] .omni-home__toolbox-workflow,
.web-shell[data-view="home"] .omni-home__toolbox-card {
border-color: var(--toolbox-border-default);
border-radius: 18px;
background:
linear-gradient(180deg, rgb(255 255 255 / 6%), rgb(255 255 255 / 2.5%)),
rgb(8 13 12 / 78%);
box-shadow:
0 20px 50px rgb(0 0 0 / 24%),
inset 0 1px 0 rgb(255 255 255 / 7%);
backdrop-filter: blur(18px);
}
.web-shell[data-view="home"] .omni-home__toolbox-item:hover,
.web-shell[data-view="home"] .omni-home__toolbox-card:hover {
border-color: var(--toolbox-border-hover);
background:
linear-gradient(180deg, rgb(255 255 255 / 8%), rgb(255 255 255 / 3%)),
rgb(10 18 16 / 86%);
box-shadow:
0 26px 62px rgb(0 0 0 / 30%),
inset 0 1px 0 rgb(255 255 255 / 8%);
}
.web-shell[data-view="home"] .omni-home__toolbox-item-icon,
.web-shell[data-view="home"] .omni-home__toolbox-card-icon {
border-color: rgb(255 255 255 / 9%);
background: rgb(255 255 255 / 5%);
}
.web-shell[data-view="home"] .omni-home__toolbox-card-tag,
.web-shell[data-view="home"] .omni-home__toolbox-card-feat {
border-color: rgb(255 255 255 / 8%);
background: rgb(255 255 255 / 5%);
color: rgb(214 255 236 / 72%);
}
.web-shell[data-view="home"] .omni-home__toolbox-card {
overflow: hidden;
}
.web-shell[data-view="home"] .omni-home__toolbox-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background:
linear-gradient(180deg, rgb(255 255 255 / 4%), transparent 34%),
radial-gradient(circle at 50% 0%, rgb(0 255 136 / 7%), transparent 42%);
pointer-events: none;
}
.web-shell[data-view="home"] .omni-home__toolbox-card > * {
position: relative;
z-index: 1;
}
/* Final tune after homepage landing feedback */
.web-shell[data-view="home"] .omni-home__toolbox-shell {
width: min(100%, 1360px);
gap: clamp(20px, 2.6vw, 36px);
}
.web-shell[data-view="home"] .omni-home__toolbox-left {
width: clamp(300px, 26vw, 390px);
}
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon {
width: 46px;
height: 46px;
border-radius: 14px;
font-size: 21px;
}
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon .anticon {
font-size: 21px;
}
.web-shell[data-view="home"] .omni-home__toolbox-brand-text {
font-size: clamp(22px, 2vw, 28px);
}
.web-shell[data-view="home"] .omni-home__toolbox-title {
font-size: clamp(32px, 3vw, 44px);
line-height: 1.12;
}
.web-shell[data-view="home"] .omni-home__toolbox-subtitle {
max-width: 380px;
font-size: clamp(14px, 1.08vw, 16px);
line-height: 1.65;
}
.web-shell[data-view="home"] .omni-home__toolbox-item {
gap: 14px;
padding: 14px 16px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item-icon {
width: 40px;
height: 40px;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 12px;
color: rgb(214 255 236 / 82%);
font-size: 18px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item-icon .anticon {
font-size: 18px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item-name {
font-size: 15px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item-desc {
font-size: 12px;
line-height: 1.45;
}
.web-shell[data-view="home"] .omni-home__toolbox-grid {
gap: 14px;
min-height: clamp(500px, 45vw, 640px);
}
.web-shell[data-view="home"] .omni-home__toolbox-card-icon {
width: 30px;
height: 30px;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 10px;
color: rgb(214 255 236 / 82%);
font-size: 15px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-icon .anticon {
font-size: 15px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-title {
font-size: 14px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-tag {
font-size: 10px;
}
/* Homepage landing tune: make this section feel like one product story, not a separate template block. */
.web-shell[data-view="home"] .omni-home__toolbox-page {
min-height: clamp(660px, 86dvh, 820px);
}
.web-shell[data-view="home"] .omni-home__toolbox-shell {
display: grid;
grid-template-columns: minmax(300px, 0.78fr) minmax(620px, 1.22fr);
align-items: center;
width: min(100% - 56px, 1320px);
min-height: inherit;
margin-inline: auto;
padding: clamp(42px, 5.2vw, 72px) 0;
}
.web-shell[data-view="home"] .omni-home__toolbox-left {
width: auto;
max-width: 390px;
}
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon,
.web-shell[data-view="home"] .omni-home__toolbox-item-icon,
.web-shell[data-view="home"] .omni-home__toolbox-card-icon {
background:
linear-gradient(180deg, rgb(255 255 255 / 7%), rgb(255 255 255 / 3%)),
rgb(8 14 13 / 72%);
color: rgb(224 248 237 / 82%);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 8%),
0 12px 30px rgb(0 0 0 / 18%);
}
.web-shell[data-view="home"] .omni-home__toolbox-brand-icon {
border-color: rgb(255 255 255 / 10%);
}
.web-shell[data-view="home"] .omni-home__toolbox-list {
gap: 10px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item {
min-height: 72px;
}
.web-shell[data-view="home"] .omni-home__toolbox-workflow {
padding: 14px 16px;
}
.web-shell[data-view="home"] .omni-home__toolbox-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
min-height: clamp(520px, 42vw, 620px);
gap: 16px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card {
min-height: 0;
padding: clamp(16px, 1.4vw, 20px);
}
.web-shell[data-view="home"] .omni-home__toolbox-card-content {
min-height: clamp(170px, 13vw, 220px);
}
.web-shell[data-view="home"] .omni-home__toolbox-card-footer {
min-height: 28px;
align-items: center;
}
@media (max-width: 1080px) {
.web-shell[data-view="home"] .omni-home__toolbox-shell {
grid-template-columns: 1fr;
width: min(100% - 40px, 860px);
gap: 24px;
}
.web-shell[data-view="home"] .omni-home__toolbox-left {
max-width: none;
}
.web-shell[data-view="home"] .omni-home__toolbox-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.web-shell[data-view="home"] .omni-home__toolbox-page {
min-height: auto;
}
.web-shell[data-view="home"] .omni-home__toolbox-shell {
width: min(100% - 28px, 520px);
padding-block: 36px 44px;
}
.web-shell[data-view="home"] .omni-home__toolbox-list,
.web-shell[data-view="home"] .omni-home__toolbox-grid {
grid-template-columns: 1fr;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-content {
min-height: 176px;
}
}
/* Device-fit pass for the home landing toolbox section. */
@media (min-width: 1101px) {
.web-shell[data-view="home"] .omni-home__toolbox-page {
min-height: clamp(620px, 82dvh, 840px);
}
.web-shell[data-view="home"] .omni-home__toolbox-shell {
min-height: inherit;
padding-block: clamp(34px, 4.2dvh, 64px);
}
}
@media (min-width: 900px) and (max-width: 1080px) {
.web-shell[data-view="home"] .omni-home__toolbox-page {
min-height: clamp(620px, 84dvh, 760px);
}
.web-shell[data-view="home"] .omni-home__toolbox-shell {
grid-template-columns: minmax(250px, 0.68fr) minmax(0, 1.32fr);
width: min(100% - 48px, 980px);
min-height: inherit;
gap: 20px;
padding-block: 28px;
}
.web-shell[data-view="home"] .omni-home__toolbox-left {
max-width: none;
}
.web-shell[data-view="home"] .omni-home__toolbox-brand {
gap: 12px;
}
.web-shell[data-view="home"] .omni-home__toolbox-title {
font-size: clamp(30px, 3.6vw, 38px);
}
.web-shell[data-view="home"] .omni-home__toolbox-subtitle {
font-size: 13px;
line-height: 1.55;
}
.web-shell[data-view="home"] .omni-home__toolbox-list {
grid-template-columns: 1fr;
gap: 8px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item {
min-height: 60px;
padding: 10px 12px;
}
.web-shell[data-view="home"] .omni-home__toolbox-workflow {
display: none;
}
.web-shell[data-view="home"] .omni-home__toolbox-grid {
min-height: clamp(430px, 58dvh, 560px);
gap: 12px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card {
padding: 14px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-content {
min-height: clamp(138px, 16dvh, 180px);
}
}
@media (max-width: 520px) {
.web-shell[data-view="home"] .omni-home__toolbox-shell {
width: min(100% - 24px, 430px);
padding-block: 28px 34px;
}
.web-shell[data-view="home"] .omni-home__toolbox-title {
font-size: clamp(28px, 8.8vw, 34px);
}
.web-shell[data-view="home"] .omni-home__toolbox-subtitle,
.web-shell[data-view="home"] .omni-home__toolbox-item-desc {
font-size: 12px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item {
min-height: 58px;
padding: 10px 12px;
}
.web-shell[data-view="home"] .omni-home__toolbox-grid {
gap: 12px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card {
padding: 14px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-content {
min-height: 132px;
}
}
/* Device-fit refinement: toolbox keeps a product-card rhythm across tablet and mobile. */
@media (min-width: 700px) and (max-width: 899px) {
.web-shell[data-view="home"] .omni-home__toolbox-page {
min-height: auto;
}
.web-shell[data-view="home"] .omni-home__toolbox-shell {
grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.3fr);
width: min(100% - 40px, 820px);
gap: 16px;
min-height: 0;
padding-block: 24px;
}
.web-shell[data-view="home"] .omni-home__toolbox-left {
gap: 12px;
}
.web-shell[data-view="home"] .omni-home__toolbox-brand {
gap: 10px;
}
.web-shell[data-view="home"] .omni-home__toolbox-title {
font-size: clamp(28px, 4vw, 38px);
}
.web-shell[data-view="home"] .omni-home__toolbox-subtitle {
display: -webkit-box;
overflow: hidden;
font-size: 12px;
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.web-shell[data-view="home"] .omni-home__toolbox-list {
grid-template-columns: 1fr;
gap: 8px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item {
min-height: 54px;
gap: 8px;
padding: 8px 10px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item-icon {
width: 34px;
height: 34px;
font-size: 14px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item-name {
font-size: 13px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item-desc {
display: -webkit-box;
overflow: hidden;
font-size: 10px;
line-height: 1.3;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.web-shell[data-view="home"] .omni-home__toolbox-workflow {
display: none;
}
.web-shell[data-view="home"] .omni-home__toolbox-grid {
min-height: 0;
gap: 10px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card {
min-height: 178px;
padding: 0;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-header {
padding: 11px 12px 0;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-icon {
width: 28px;
height: 28px;
font-size: 13px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-title {
font-size: 12px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-tag {
padding: 3px 7px;
font-size: 9px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-content {
min-height: 98px;
padding: 6px 12px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-footer {
padding: 6px 12px 10px;
}
}
@media (max-width: 699px) {
.web-shell[data-view="home"] .omni-home__toolbox-page {
min-height: auto;
}
.web-shell[data-view="home"] .omni-home__toolbox-shell {
width: min(100% - 24px, 430px);
gap: 12px;
min-height: 0;
padding-block: 24px 30px;
}
.web-shell[data-view="home"] .omni-home__toolbox-left {
gap: 10px;
}
.web-shell[data-view="home"] .omni-home__toolbox-title {
font-size: clamp(24px, 7.4vw, 32px);
}
.web-shell[data-view="home"] .omni-home__toolbox-subtitle {
display: -webkit-box;
overflow: hidden;
font-size: 11px;
line-height: 1.4;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.web-shell[data-view="home"] .omni-home__toolbox-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item {
min-height: 50px;
gap: 7px;
padding: 8px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item-icon {
width: 28px;
height: 28px;
font-size: 12px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item-name {
font-size: 12px;
}
.web-shell[data-view="home"] .omni-home__toolbox-item-desc,
.web-shell[data-view="home"] .omni-home__toolbox-workflow,
.web-shell[data-view="home"] .omni-home__toolbox-card-feat,
.web-shell[data-view="home"] .omni-home__toolbox-card-feat-sep {
display: none;
}
.web-shell[data-view="home"] .omni-home__toolbox-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
min-height: 0;
}
.web-shell[data-view="home"] .omni-home__toolbox-card {
min-height: 144px;
border-radius: 14px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-header {
align-items: flex-start;
gap: 6px;
padding: 9px 9px 0;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-header-left {
gap: 6px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-icon {
width: 24px;
height: 24px;
border-radius: 7px;
font-size: 11px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-title {
font-size: 11px;
line-height: 1.2;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-tag {
padding: 2px 5px;
font-size: 8px;
line-height: 1.2;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-content {
min-height: 84px;
padding: 4px 8px;
}
.web-shell[data-view="home"] .omni-home__toolbox-card-footer {
min-height: 0;
padding: 0 9px 9px;
}
}
File diff suppressed because it is too large Load Diff
+84 -2
View File
@@ -144,6 +144,10 @@
}
.web-shell[data-ui-theme="dark-green"] .notification-center {
position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
isolation: isolate;
}
@@ -170,10 +174,15 @@
}
.web-shell[data-ui-theme="dark-green"] .notification-center__panel {
position: absolute;
display: flex;
flex-direction: column;
top: calc(100% + 12px);
right: -88px;
z-index: 1200;
width: min(420px, calc(100vw - 24px));
max-height: min(560px, calc(100vh - 92px));
height: auto;
max-height: min(460px, calc(100dvh - 84px));
border: 1px solid var(--dg-line);
border-radius: 16px;
background: #151719;
@@ -182,6 +191,17 @@
overflow: hidden;
}
.web-shell[data-ui-theme="dark-green"][data-view="home"] .notification-center__panel {
contain: layout paint;
width: min(380px, calc(100vw - 24px));
max-height: min(420px, calc(100dvh - 92px));
transform-origin: top right;
}
.web-shell[data-ui-theme="dark-green"][data-view="home"] .notification-center__list {
max-height: min(336px, calc(100dvh - 164px));
}
.web-shell[data-ui-theme="dark-green"] .notification-center__panel::before {
content: "";
position: absolute;
@@ -231,9 +251,12 @@
}
.web-shell[data-ui-theme="dark-green"] .notification-center__list {
max-height: min(486px, calc(100vh - 158px));
flex: 1 1 auto;
min-height: 0;
max-height: min(386px, calc(100dvh - 158px));
padding: 8px;
overflow-y: auto;
overscroll-behavior: contain;
}
.web-shell[data-ui-theme="dark-green"] .notification-center__item {
@@ -1794,6 +1817,14 @@
box-shadow: none;
}
.web-shell[data-ui-theme="dark-green"] .wb-composer__billing-estimate {
border: 1px solid var(--border-default);
border-radius: 999px;
background: var(--bg-elevated);
color: var(--fg-body);
box-shadow: none;
}
.web-shell[data-ui-theme="dark-green"] .wb-composer__send-primary:hover:not(:disabled) {
background: var(--accent-hover);
color: var(--dg-button-text);
@@ -10458,6 +10489,21 @@
gap: 6px;
}
.web-shell[data-ui-theme="dark-green"] .notification-center__panel {
right: clamp(-112px, -24vw, -92px);
width: min(360px, calc(100vw - 20px));
max-height: min(420px, calc(100dvh - 76px));
border-radius: 14px;
}
.web-shell[data-ui-theme="dark-green"] .notification-center__panel::before {
right: clamp(104px, 25vw, 124px);
}
.web-shell[data-ui-theme="dark-green"] .notification-center__list {
max-height: min(344px, calc(100dvh - 150px));
}
.web-shell[data-ui-theme="dark-green"] :is(.creator-button, .member-button) {
height: 32px;
padding: 0 10px;
@@ -10837,8 +10883,44 @@
}
@media (max-width: 900px) {
.web-shell[data-ui-theme="dark-green"] {
--dg-mobile-nav-height: 58px;
--dg-mobile-nav-gap: 12px;
--dg-mobile-nav-space: calc(var(--dg-mobile-nav-height) + var(--dg-mobile-nav-gap));
}
.web-shell[data-ui-theme="dark-green"] .web-topbar {
z-index: 72;
}
.web-shell[data-ui-theme="dark-green"] .web-shell__content,
.web-shell[data-ui-theme="dark-green"] .web-shell__page {
min-height: 0;
padding-bottom: 0;
}
.web-shell[data-ui-theme="dark-green"] .web-shell__page {
overflow: auto;
}
.web-shell[data-ui-theme="dark-green"]:not([data-view="home"]):not([data-view="login"]):not([data-view="workbench"]):not([data-view="agent"]):not([data-view="avatarConsole"]) .web-shell__page {
padding-top: var(--dg-mobile-nav-space);
}
.web-shell[data-ui-theme="dark-green"] .profile-popover {
position: fixed;
top: calc(56px + var(--dg-mobile-nav-space) + env(safe-area-inset-top, 0px));
right: 12px;
z-index: 120;
width: min(288px, calc(100vw - 24px));
max-height: calc(100svh - 56px - var(--dg-mobile-nav-space) - 24px);
overflow-y: auto;
transform-origin: top right;
}
.web-shell[data-ui-theme="dark-green"] .floating-nav {
top: calc(56px + env(safe-area-inset-top, 0px));
z-index: 50;
right: 12px;
bottom: auto;
left: 12px;
+2
View File
@@ -25,6 +25,7 @@ export type WebViewKey =
| "dialogGenerator"
| "communityReview"
| "communityCaseAdd"
| "betaApplications"
| "report"
| "providerHealth"
| "userAgreement"
@@ -73,6 +74,7 @@ export interface WebUserSession extends WebApiResultMeta {
enterpriseAdminUserId?: number | string | null;
balanceCents?: number;
enterpriseBalanceCents?: number;
maxConcurrency?: number;
activePackages?: Array<{
name: string;
expiresAt: string;
+4 -3
View File
@@ -40,6 +40,7 @@ export const ENTERPRISE_VIDEO_RESOLUTION_OPTIONS = [
export const ENTERPRISE_DEFAULT_VIDEO_MODEL = HAPPY_HORSE_UI_MODEL;
export const ENTERPRISE_DEFAULT_VIDEO_RESOLUTION = "1080P";
const CREDITS_PER_CNY = 100;
export interface EnterpriseVideoPricingInput {
model: string;
@@ -74,11 +75,11 @@ export function getEnterpriseVideoCreditRate(input: EnterpriseVideoPricingInput)
}
if (model.includes("vidu")) {
return resolution === "720P" ? 0.4 : 0.8;
return resolution === "720P" ? 0.6 : 1.0;
}
if (model.includes("pixverse")) {
return resolution === "720P" ? 0.4 : 0.8;
return resolution === "720P" ? 0.6 : 1.0;
}
if (model.includes("kling")) {
@@ -94,5 +95,5 @@ export function getEnterpriseVideoCreditRate(input: EnterpriseVideoPricingInput)
export function calculateEnterpriseVideoCredits(input: EnterpriseVideoPricingInput): number {
const duration = Math.max(1, Math.ceil(Number(input.durationSeconds) || 1));
return Number((getEnterpriseVideoCreditRate(input) * duration).toFixed(2));
return Number((getEnterpriseVideoCreditRate(input) * duration * CREDITS_PER_CNY).toFixed(2));
}
+19
View File
@@ -25,11 +25,30 @@ export function getImageQualityOptions(model: string): CanvasOption[] {
: imageQualityOptions.filter((option) => option.value !== "4K");
}
export function getImageQualityOptionsForContext(
model: string,
context?: { hasReferenceImages?: boolean; isGridMode?: boolean },
): CanvasOption[] {
const options = getImageQualityOptions(model);
const shouldLimitTo2K =
String(model || "").toLowerCase() === "wan2.7-image-pro" &&
(context?.hasReferenceImages || context?.isGridMode);
return shouldLimitTo2K ? options.filter((option) => option.value !== "4K") : options;
}
export function getDefaultImageQuality(model: string): string {
const options = getImageQualityOptions(model);
return options.some((option) => option.value === "2K") ? "2K" : options[0]?.value || "1K";
}
export function getDefaultImageQualityForContext(
model: string,
context?: { hasReferenceImages?: boolean; isGridMode?: boolean },
): string {
const options = getImageQualityOptionsForContext(model, context);
return options.some((option) => option.value === "2K") ? "2K" : options[0]?.value || "1K";
}
// ─── Video quality ────────────────────────────────────────────────────────────
function normalizeVideoModel(model: string): string {
+5 -3
View File
@@ -32,8 +32,10 @@ export interface TextTokenUsage {
totalTokens?: number;
}
export const TEXT_INPUT_CREDITS_PER_MILLION = 2;
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5;
const CREDITS_PER_CNY = 100;
export const TEXT_INPUT_CREDITS_PER_MILLION = 2 * CREDITS_PER_CNY;
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5 * CREDITS_PER_CNY;
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 90_000,
@@ -151,7 +153,7 @@ export function estimateTextTokenCredits(usage: TextTokenUsage): number {
}
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
const rule = "文本计费规则:输入 Token 每百万 2 积分,输出 Token 每百万 5 积分,实际以服务端结算为准。";
const rule = "文本计费规则:输入 Token 每百万 200 积分,输出 Token 每百万 500 积分,实际以服务端结算为准。";
if (!usage) return rule;
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));