调整创作主页与项目入口
新增陶泥儿创作主页和用户素材瀑布流 新增项目入口并复用画布中心封面 保留创作主页左侧导航和上方栏 修复玩家社区在创作页原地弹窗 更新路由、测试和项目记忆文档
This commit is contained in:
@@ -25,6 +25,8 @@
|
||||
|
||||
`/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,第一版采用前端 JSZip 打包画布中有效图层引用的上传图、生成图和修改结果,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。
|
||||
|
||||
桌面端 `/creation` 创作工具主页、顶级“草稿”入口替换为“项目”、最近项目、新建项目和陶泥儿精选素材瀑布流的落地计划见 [【玩法创作】创作主页与项目入口改版计划-2026-06-18.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E4%B8%BB%E9%A1%B5%E4%B8%8E%E9%A1%B9%E7%9B%AE%E5%85%A5%E5%8F%A3%E6%94%B9%E7%89%88%E8%AE%A1%E5%88%92-2026-06-18.md)。
|
||||
|
||||
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
|
||||
|
||||
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
||||
|
||||
@@ -24,6 +24,14 @@
|
||||
- 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。
|
||||
|
||||
## 2026-06-18 `/creation` 独立为陶泥儿创作工具主页
|
||||
|
||||
- 背景:图片画布项目已经成为独立项目资产,旧“创作”站内 Tab 和一级“草稿”入口不能清晰表达桌面端创作工具主页与项目管理入口。
|
||||
- 决策:桌面端新增独立 `/creation` 创作工具主页;顶级“创作”入口跳转 `/creation`,原“草稿”入口替换为“项目”并跳转 `/project`。移动端隐藏“创作”和“项目”入口,但不拦截直接 URL。页面文案统一使用“陶泥儿”,外部品牌只作为设计参考,不进入用户可见 UI、测试名、产品文案或验收口径。`陶泥儿精选` 是用户素材瀑布流,不展示玩法入口列表;创作入口事实源继续来自 `/api/creation-entry/config`,项目入口通过 `createEditorProject` 和 `/editor/canvas?projectid=xxx` 链路进入画布。
|
||||
- 影响范围:平台入口导航、`SelectionStage` 路由、`/creation` 首页、`/project` 项目入口、`/editor/canvas` 项目打开链路、“我的”页快捷入口和创作入口相关测试。
|
||||
- 验证方式:`/creation` 解析为创作主页,`/creation/<play>` 仍进入对应玩法工作台;桌面导航显示“创作 / 项目”且不显示“草稿”;移动端底部不显示“创作 / 项目”;页面不出现外站品牌或 `Discord` 字样;新建项目进入 `/editor/canvas?projectid=xxx`。
|
||||
- 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-06-18 图片画布 Seedance 2.0 参考媒体提交边界
|
||||
|
||||
- 背景:`/editor/canvas` 生成视频需要严格对齐火山 Seedance 2.0 多模态参考输入;参考视频若继续走 Base64 / `data:video` 会超过请求体并被上游拒绝,参考音频单独输入和非 Seedance 模型携带参考字段也会违反文档契约。
|
||||
|
||||
@@ -2326,6 +2326,14 @@
|
||||
- 验证:`npm run test -- src/components/common/CreativeImageInputPanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
||||
- 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 项目画布跳转不要先写无参画布路由
|
||||
|
||||
- 现象:从 `/creation` 最近项目或 `/project` 项目卡进入画布时,浏览器先进入 `/editor/canvas`,随后再进入 `/editor/canvas?projectid=xxx`,导致返回来源页需要点两次。
|
||||
- 原因:`App` 传给平台壳的 `setSelectionStage` 会按 stage 自动 `pushAppHistoryPath(resolvePathForSelectionStage(stage))`;如果项目入口先 `setSelectionStage('image-editor')` 再写项目 URL,就会把无参数画布路由塞入 history。
|
||||
- 处理:项目入口必须先写入最终 `/editor/canvas?projectid=xxx`,再切 `image-editor` 阶段;`App` 的 stage setter 在当前位置已经解析为 `image-editor` 时不要再补写基础画布路由。
|
||||
- 验证:`npm run test -- src/App.test.tsx`;浏览器中从最近项目或项目页打开项目后,后退一次应直接回到 `/creation` 或 `/project`。
|
||||
- 关联:`src/App.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`。
|
||||
|
||||
## 统一创作页短表单软键盘打开不要露出黑底
|
||||
|
||||
- 现象:小程序 / H5 移动端点击拼图或敲木鱼创作输入框后,输入框和键盘之间出现一大片黑色区域;H5 还会明显弹一下。跳一跳因为按钮区用 `mt-auto` 撑开页面,看起来没有同样问题。
|
||||
|
||||
@@ -24,7 +24,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A
|
||||
- 小程序 WebView 外壳:`miniprogram/`。
|
||||
- 法律文本:`media/files/user_agreement.md`、`media/files/privacy_policy.md`、`media/files/disclaimer.md`。
|
||||
|
||||
移动端一级 Tab:`推荐 / 发现 / 创作 / 草稿 / 我的`。
|
||||
移动端一级 Tab:`推荐 / 发现 / 我的`。桌面端导航保留 `创作` 并新增 `项目`,其中 `/creation` 是独立创作工具主页,`/project` 是画布项目入口。
|
||||
|
||||
## 当前后端路线
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# 创作主页与项目入口改版计划
|
||||
|
||||
日期:2026-06-18
|
||||
|
||||
## 背景
|
||||
|
||||
当前平台首页中的“创作”仍偏向站内 Tab 和创作入口列表,“草稿”也仍作为一级入口存在。图片画布项目已经成为独立项目资产,需要在桌面端给用户一个更明确的创作工具主页,并把项目管理提升为一级入口。
|
||||
|
||||
本轮改版参考外部创作工具首页的信息组织,但所有用户可见文案、测试命名和产品命名都使用“陶泥儿”。外部品牌只作为设计参考,不进入产品 UI、测试名或文档验收口径。
|
||||
|
||||
## 目标
|
||||
|
||||
- 新增桌面端独立 `/creation` 创作工具主页。
|
||||
- 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`。
|
||||
- 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口。
|
||||
- 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`。
|
||||
- `陶泥儿精选` 展示真实账号素材瀑布流,不使用 mock 素材。
|
||||
- 现有 `/creation/<play>` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。
|
||||
|
||||
## 页面结构
|
||||
|
||||
### 顶部品牌区
|
||||
|
||||
- 标题:`陶泥儿 - 开启全民精品游戏创作`
|
||||
- 主按钮:`开始创作`
|
||||
- 社群入口:原地弹出“玩家社区”二维码弹窗,复用现有社区弹层能力,不跳转或切换到“我的 / 用户中心”。
|
||||
- 营销点:`登录即送100泥点,可以免费制作50个素材`
|
||||
|
||||
`开始创作` 与“新建项目”使用同一项目创建链路。用户已登录时调用现有 `createEditorProject`,成功后进入 `/editor/canvas?projectid=xxx`;未登录或登录过期时打开登录弹窗,并在登录后重试创建。
|
||||
|
||||
该区是页面第一屏的品牌与主操作区域,不使用外站品牌名。桌面端 `/creation` 继续保留现有平台左侧导航栏和上方栏,创作主页只替换中间内容区。
|
||||
|
||||
### 九大创作工具能力
|
||||
|
||||
创作主页展示九项能力,用于说明陶泥儿创作工具覆盖范围:
|
||||
|
||||
1. 游戏视觉规范:轻松约束多类素材视觉一致性。
|
||||
2. 游戏角色:整套高完成度 2D / 3D 角色素材及动画。
|
||||
3. 游戏 UI:轻松生成高一致性的 UI 素材。
|
||||
4. 游戏音乐:轻松制作游戏背景音乐、音效。
|
||||
5. 游戏特效:低成本生成精品游戏特效。
|
||||
6. 游戏场景:轻松生成各类游戏场景素材。
|
||||
7. 游戏美宣:轻松生成游戏上架所需图片和视频。
|
||||
8. 游戏功能编码:自然语言驱动游戏生成。
|
||||
9. 一键导出:支持一键导出为微抖小游戏、Godot、Unity、UE 工程。
|
||||
|
||||
### 最近项目
|
||||
|
||||
仅登录后展示。模块位置在“陶泥儿精选”上方。
|
||||
|
||||
- 标题:`最近项目`
|
||||
- 标题右侧:`查看全部`,跳转 `/project`
|
||||
- 左侧第一项:`新建项目`
|
||||
- 右侧项目卡:按 `updatedAt` 倒序展示最近 4 个项目。
|
||||
- 项目卡点击进入 `/editor/canvas?projectid=xxx`。
|
||||
- 项目卡点击必须直接写入最终 `/editor/canvas?projectid=xxx` 历史项,不允许先进入无参数 `/editor/canvas` 再二次跳转,否则浏览器返回需要点两次。
|
||||
- 项目缩略图复用 `/project` 项目卡的画布中心封面逻辑,避免两套封面算法。
|
||||
|
||||
未登录状态不展示最近项目区,避免空态文案占据首屏。
|
||||
|
||||
### 陶泥儿精选
|
||||
|
||||
`陶泥儿精选` 是页面底部的用户素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
|
||||
|
||||
精选内容优先使用真实账号级素材和生成结果数据;未登录或账号素材为空时,可使用已公开作品中真实用户生成的图片作为瀑布流素材来源。两种来源都不使用 mock。若现有数据缺少提示词、作者名或成本字段,v1 显示保守占位,不伪造内容。
|
||||
|
||||
Tab:
|
||||
|
||||
- 素材包
|
||||
- 角色
|
||||
- UI
|
||||
- 音乐
|
||||
- 特效
|
||||
- 场景
|
||||
- 美宣
|
||||
- 游戏
|
||||
|
||||
展示口径:
|
||||
|
||||
- 素材包:每个列表项展示同一规范图生成的规范图和相关素材组合。
|
||||
- 角色:每个列表项展示游戏角色图和该角色生成的角色动画素材组合。
|
||||
- UI:每个列表项展示游戏 UI 图和该 UI 图拆出的子素材组合。
|
||||
- 音乐:展示背景音乐和音效。
|
||||
- 美宣:展示美宣类图片和视频素材。
|
||||
- 特效、场景、游戏:v1 只在有真实数据时展示,无数据时不造假卡片。
|
||||
|
||||
每个列表项需要标注:
|
||||
|
||||
- 提示词
|
||||
- 作者名
|
||||
- 生成总成本,单位为泥点
|
||||
|
||||
数据来源:
|
||||
|
||||
- 图片类素材优先读取账号级素材库和已落库生成结果。
|
||||
- 未登录或账号素材为空时,读取已公开作品中的真实用户生成图片作为精选素材补充,但卡片仍按素材瀑布流呈现,不展示玩法入口卡。
|
||||
- 音乐、音效、视频类素材只展示已存在的真实记录。
|
||||
- 不为了填满展示区创建假素材、假作者、假泥点成本或假组合关系。
|
||||
- 暂无真实数据的 Tab 保留 Tab 入口,但内容区显示简洁空态。
|
||||
|
||||
## 数据与状态边界
|
||||
|
||||
- `/creation` 是创作工具主页,不承接正式玩法业务真相。
|
||||
- 创作入口配置继续从 `/api/creation-entry/config` 读取。
|
||||
- 最近项目继续使用编辑器项目接口:`listEditorProjects`、`createEditorProject` 和 `/editor/canvas?projectid=xxx`。
|
||||
- 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由。
|
||||
- 项目封面逻辑复用 `/project` 项目卡已有的画布中心缩略图算法。
|
||||
- `陶泥儿精选` 读取账号级素材库;若字段缺失,前端只显示空值或隐藏对应元信息,不在前端推导业务真相。
|
||||
- `/creation/<play>` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。
|
||||
|
||||
## 路由与导航
|
||||
|
||||
- 新增 `SelectionStage = 'creation-home'`。
|
||||
- `/creation` 映射到 `creation-home`。
|
||||
- `/creation/<play>` 现有玩法工作台路由保持原有 stage 映射。
|
||||
- 桌面端点击“创作”时调用 `pushAppHistoryPath('/creation')`。
|
||||
- 桌面端原“草稿”入口改为“项目”,点击 `pushAppHistoryPath('/project')`。
|
||||
- 移动端导航列表不展示“创作”和“项目”。
|
||||
- 移动端不拦截用户直接访问 `/creation` 或 `/project`。
|
||||
|
||||
## 实施拆分
|
||||
|
||||
### 路由与导航
|
||||
|
||||
- 增加 `/creation` 路由映射和测试。
|
||||
- 调整桌面导航文案与点击行为。
|
||||
- 移动端导航移除“创作”和“项目”入口。
|
||||
- 保持现有 `/creation/<play>` 工作台、生成页、结果页和运行态路由不回归。
|
||||
|
||||
### 创作主页
|
||||
|
||||
- 新增 `CreationLandingView`。
|
||||
- 实现首屏主视觉、九大功能区、最近项目区和陶泥儿精选素材瀑布流。
|
||||
- 桌面端复用现有平台壳层,保留上方栏和左侧导航栏。
|
||||
- 主页整体采用浅色布局,避免外站品牌文案。
|
||||
- 社群入口复用现有玩家社区弹层,点击后必须停留在 `/creation`。
|
||||
- 不在 UI 中放规则说明、实现说明或“暂不实装”提示。
|
||||
- 未登录时不渲染最近项目区;需要创建项目时走登录弹窗和登录后重试。
|
||||
|
||||
### 项目卡与封面复用
|
||||
|
||||
- 从 `ProjectGalleryView` 抽出项目封面模型或组件。
|
||||
- `/creation` 最近项目与 `/project` 共用同一封面逻辑。
|
||||
- 新建项目复用现有 `createEditorProject`、`listEditorProjects` 和 `/editor/canvas?projectid=xxx` 链路。
|
||||
|
||||
### 我的页
|
||||
|
||||
- 删除“项目 / 画布项目”快捷入口。
|
||||
- 常用功能区稳定为 4 项:泥点充值、兑换码、玩家社区、反馈与建议。
|
||||
|
||||
## 测试计划
|
||||
|
||||
单元和组件测试:
|
||||
|
||||
- `appPageRoutes.test.ts`:覆盖 `/creation -> creation-home`,并确认现有 `/creation/<play>` 不回归。
|
||||
- `RpgEntryHomeView.recharge.test.tsx`:桌面显示“创作”“项目”,不显示“草稿”;移动端不显示“创作”和“项目”;我的页不再出现项目快捷入口。
|
||||
- `CreationLandingView.test.tsx`:覆盖首屏主视觉、九大功能、最近项目、新建入口、查看全部、未登录隐藏最近项目、素材 Tab。
|
||||
- `PlatformEntryFlowShellImpl` 相关测试:点击“创作”进入 `/creation`,点击“项目”进入 `/project`,新建项目进入 `/editor/canvas?projectid=xxx`。
|
||||
- `ProjectGalleryView.test.tsx`:封面抽取后项目封面、重命名、删除和选择模式保持通过。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
npm run test -- src/routing/appPageRoutes.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
|
||||
npm run typecheck
|
||||
npm run check:encoding
|
||||
git diff --check
|
||||
```
|
||||
|
||||
浏览器回归:
|
||||
|
||||
- 桌面打开首页,点击“创作”进入 `/creation`。
|
||||
- `/creation` 显示 `陶泥儿 - 开启全民精品游戏创作` 和 `陶泥儿精选`。
|
||||
- 页面不出现外站品牌或 `Discord` 字样。
|
||||
- 未登录时最近项目区不展示,点击“开始创作”弹登录。
|
||||
- 登录后显示最近项目、新建项目、最多 4 个项目和“查看全部”。
|
||||
- 点击新建项目进入 `/editor/canvas?projectid=xxx`。
|
||||
- 桌面导航显示“项目”,不显示“草稿”。
|
||||
- 移动视口底部不显示“创作”和“项目”。
|
||||
- “我的”页没有项目快捷入口。
|
||||
- 陶泥儿精选素材瀑布流不出现 mock 素材、假作者或假泥点成本。
|
||||
- 陶泥儿精选素材卡按真实素材宽高呈现瀑布流,不退化为固定等高网格。
|
||||
- 陶泥儿精选不出现玩法入口卡;即使使用公开作品图片补充展示,也必须呈现为用户素材瀑布流。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不新增后端接口。
|
||||
- 不删除现有玩法草稿、作品架、生成恢复、发布或运行态能力。
|
||||
- 不恢复前端硬编码创作入口配置。
|
||||
- 不用 mock 素材填充用户素材展示。
|
||||
- 不在移动端禁止直接 URL 访问 `/creation` 或 `/project`。
|
||||
@@ -0,0 +1,66 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import App from './App';
|
||||
|
||||
vi.mock('./components/platform-entry/PlatformEntryFlowShell', async () => {
|
||||
const React = await import('react');
|
||||
type PlatformEntryFlowShellProps = ComponentProps<
|
||||
typeof import('./components/platform-entry/PlatformEntryFlowShell').PlatformEntryFlowShell
|
||||
>;
|
||||
|
||||
return {
|
||||
PlatformEntryFlowShell: ({ setSelectionStage }: PlatformEntryFlowShellProps) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
setSelectionStage('image-editor', {
|
||||
path: '/editor/canvas?projectid=project-from-test',
|
||||
});
|
||||
},
|
||||
},
|
||||
'打开最近项目',
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./RpgRuntimeApp', () => ({
|
||||
RpgRuntimeApp: () => <div>运行态</div>,
|
||||
}));
|
||||
|
||||
describe('App navigation history', () => {
|
||||
it('keeps project canvas navigation as one history entry with projectid', async () => {
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
const pushStateSpy = vi.spyOn(window.history, 'pushState');
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<App />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '打开最近项目' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/editor/canvas');
|
||||
expect(window.location.search).toBe('?projectid=project-from-test');
|
||||
});
|
||||
expect(pushStateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pushStateSpy).toHaveBeenCalledWith(
|
||||
null,
|
||||
'',
|
||||
'/editor/canvas?projectid=project-from-test',
|
||||
);
|
||||
|
||||
window.history.back();
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/creation');
|
||||
expect(window.location.search).toBe('');
|
||||
});
|
||||
|
||||
pushStateSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+7
-4
@@ -67,10 +67,13 @@ export default function App() {
|
||||
readPublicWorkCodeFromLocationSearch(window.location.search),
|
||||
);
|
||||
|
||||
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
||||
setRawSelectionStage(stage);
|
||||
pushAppHistoryPath(resolvePathForSelectionStage(stage));
|
||||
}, []);
|
||||
const setSelectionStage = useCallback(
|
||||
(stage: SelectionStage, options?: { path?: string }) => {
|
||||
setRawSelectionStage(stage);
|
||||
pushAppHistoryPath(options?.path ?? resolvePathForSelectionStage(stage));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const syncStageFromHistory = () => {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ContextType } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { CreationLandingView } from './CreationLandingView';
|
||||
|
||||
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
|
||||
|
||||
function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
|
||||
return {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
displayName: '测试玩家',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: vi.fn((action: () => void) => action()),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
musicVolume: 0.5,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', () => ({
|
||||
listEditorProjects: listEditorProjectsMock,
|
||||
createEditorProject: createEditorProjectMock,
|
||||
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
||||
}));
|
||||
|
||||
const projectItems = [
|
||||
{
|
||||
projectId: 'project-newest',
|
||||
title: '最新项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
projectId: 'project-old',
|
||||
title: '旧项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-17T10:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
type RenderCreationLandingOptions = {
|
||||
authValue?: AuthValue | null;
|
||||
publicGalleryEntries?: PlatformPublicGalleryCard[];
|
||||
};
|
||||
|
||||
function renderCreationLanding({
|
||||
authValue = createAuthValue(),
|
||||
publicGalleryEntries = [],
|
||||
}: RenderCreationLandingOptions = {}) {
|
||||
const result = render(
|
||||
<AuthUiContext.Provider value={authValue}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
onOpenCommunity={vi.fn()}
|
||||
publicGalleryEntries={publicGalleryEntries}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('CreationLandingView', () => {
|
||||
afterEach(() => {
|
||||
listEditorProjectsMock.mockReset();
|
||||
createEditorProjectMock.mockReset();
|
||||
loadEditorAssetLibraryMock.mockReset();
|
||||
});
|
||||
|
||||
it('renders the Taonier creation landing modules without external branding', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
|
||||
renderCreationLanding();
|
||||
|
||||
expect(
|
||||
screen.getByRole('main', { name: '陶泥儿创作主页' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
name: '陶泥儿 - 开启全民精品游戏创作',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('登录即送100泥点,可以免费制作50个素材')).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '创作工具' })).toBeTruthy();
|
||||
expect(screen.getByText('游戏视觉规范')).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '陶泥儿精选' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '素材包' })).toBeTruthy();
|
||||
expect(screen.queryByText('精选入口')).toBeNull();
|
||||
expect(screen.queryByText(/Meowa|Discord/u)).toBeNull();
|
||||
expect(await screen.findByText('暂无素材')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows recent projects only for logged in users', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenProject = vi.fn();
|
||||
const onOpenProjects = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<CreationLandingView
|
||||
onOpenProject={onOpenProject}
|
||||
onOpenProjects={onOpenProjects}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '最近项目' })).toBeTruthy();
|
||||
expect(screen.getByText('最新项目')).toBeTruthy();
|
||||
expect(screen.getByText('旧项目')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '查看全部' }));
|
||||
await user.click(screen.getByRole('button', { name: /最新项目/u }));
|
||||
|
||||
expect(onOpenProjects).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenProject).toHaveBeenCalledWith('project-newest');
|
||||
});
|
||||
|
||||
it('hides recent projects and opens login when an anonymous user starts creation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openLoginModal = vi.fn();
|
||||
|
||||
renderCreationLanding({
|
||||
authValue: createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('heading', { name: '最近项目' })).toBeNull();
|
||||
await user.click(screen.getByRole('button', { name: /开始创作/u }));
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(createEditorProjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a new project and opens it from the hero action', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenProject = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
createEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'created-project',
|
||||
title: '未命名画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<CreationLandingView
|
||||
onOpenProject={onOpenProject}
|
||||
onOpenProjects={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /开始创作/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onOpenProject).toHaveBeenCalledWith('created-project');
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the community entry from the hero without starting project creation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenCommunity = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
onOpenCommunity={onOpenCommunity}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '玩家社区' }));
|
||||
|
||||
expect(onOpenCommunity).toHaveBeenCalledTimes(1);
|
||||
expect(createEditorProjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders real account assets in showcase tabs without mock rows', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-character',
|
||||
folderId: 'folder-1',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
actualPrompt: null,
|
||||
model: 'character-model',
|
||||
provider: 'provider',
|
||||
taskId: 'task-1',
|
||||
authorName: '创作者A',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderCreationLanding();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '角色' }));
|
||||
|
||||
const assetCard = await screen.findByText('角色英雄');
|
||||
const card = assetCard.closest('.creation-landing__asset-card');
|
||||
expect(card).toBeTruthy();
|
||||
expect(within(card as HTMLElement).getByText('character hero prompt')).toBeTruthy();
|
||||
expect(within(card as HTMLElement).getByText('创作者A')).toBeTruthy();
|
||||
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses Taonier featured as the account asset waterfall instead of creation entries', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-pack-a',
|
||||
folderId: 'folder-1',
|
||||
label: '素材包 A',
|
||||
imageSrc: 'data:image/png;base64,a',
|
||||
width: 512,
|
||||
height: 640,
|
||||
sourceType: 'generated',
|
||||
prompt: '素材包提示词',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-pack-b',
|
||||
folderId: 'folder-1',
|
||||
label: '素材包 B',
|
||||
imageSrc: 'data:image/png;base64,b',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: '第二组素材',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = renderCreationLanding();
|
||||
|
||||
expect(await screen.findByText('素材包 A')).toBeTruthy();
|
||||
expect(screen.getByText('素材包 B')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByLabelText('用户素材瀑布流').className,
|
||||
).toContain('creation-landing__asset-waterfall');
|
||||
const firstPreview = container.querySelector(
|
||||
'.creation-landing__asset-preview',
|
||||
) as HTMLElement | null;
|
||||
expect(firstPreview?.style.aspectRatio).toBe('512 / 640');
|
||||
expect(screen.queryByText('经典 RPG 体验')).toBeNull();
|
||||
expect(screen.queryByText('拼图关卡创作')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to real public user assets for the featured waterfall', async () => {
|
||||
const publicAsset = {
|
||||
sourceType: 'puzzle',
|
||||
workId: 'work-public-asset',
|
||||
profileId: 'profile-public-asset',
|
||||
publicWorkCode: 'PZ-PUBLIC-ASSET',
|
||||
ownerUserId: 'owner-public-asset',
|
||||
authorDisplayName: '公开作者',
|
||||
worldName: '公开素材组合',
|
||||
subtitle: '公开素材副标题',
|
||||
summaryText: '公开用户生成素材提示词',
|
||||
coverImageSrc: 'https://assets.example.test/public-asset.png',
|
||||
coverSlides: [
|
||||
{
|
||||
id: 'slide-1',
|
||||
imageSrc: 'https://assets.example.test/public-asset-slide.png',
|
||||
label: '公开素材图',
|
||||
},
|
||||
],
|
||||
themeTags: ['素材包'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-06-18T09:00:00.000Z',
|
||||
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const { container } = renderCreationLanding({
|
||||
authValue: createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
}),
|
||||
publicGalleryEntries: [publicAsset],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('公开素材组合')).toBeTruthy();
|
||||
const card = screen
|
||||
.getByText('公开素材组合')
|
||||
.closest('.creation-landing__asset-card');
|
||||
expect(card).toBeTruthy();
|
||||
expect(
|
||||
within(card as HTMLElement).getByText('公开用户生成素材提示词'),
|
||||
).toBeTruthy();
|
||||
expect(within(card as HTMLElement).getByText('公开作者')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('.creation-landing__asset-waterfall'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('暂无素材')).toBeNull();
|
||||
expect(screen.queryByText('精选入口')).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
@@ -534,6 +534,7 @@ import {
|
||||
PlatformEntryHomeView,
|
||||
type PlatformHomeTab,
|
||||
} from './PlatformEntryHomeView';
|
||||
import { PlatformProfileReferralModal } from './PlatformProfileReferralModal';
|
||||
import { usePlatformDesktopLayout } from './platformEntryResponsive';
|
||||
import {
|
||||
buildCreationHubFallbackItems,
|
||||
@@ -1285,6 +1286,13 @@ const ProjectGalleryView = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const CreationLandingView = lazy(async () => {
|
||||
const module = await import('../creation-home/CreationLandingView');
|
||||
return {
|
||||
default: module.CreationLandingView,
|
||||
};
|
||||
});
|
||||
|
||||
const UnifiedCreationWorkspace = lazy(async () => {
|
||||
const module = await import('../unified-creation/UnifiedCreationWorkspace');
|
||||
return {
|
||||
@@ -1620,6 +1628,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
: 'platform-theme--light';
|
||||
const isDesktopLayout = usePlatformDesktopLayout();
|
||||
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
||||
const [isCreationCommunityOpen, setIsCreationCommunityOpen] =
|
||||
useState(false);
|
||||
const [draftGenerationPointNotice, setDraftGenerationPointNotice] =
|
||||
useState<DraftGenerationPointNotice | null>(null);
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||
@@ -3597,6 +3607,24 @@ export function PlatformEntryFlowShellImpl({
|
||||
]);
|
||||
|
||||
const openCreationTypePicker = useCallback(() => {
|
||||
setSelectionStage('creation-home');
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const openProjectGallery = useCallback(() => {
|
||||
setSelectionStage('project');
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const openCreationCommunity = useCallback(() => {
|
||||
setIsCreationCommunityOpen(true);
|
||||
}, []);
|
||||
|
||||
const openEditorProject = useCallback((projectId: string) => {
|
||||
setSelectionStage('image-editor', {
|
||||
path: `/editor/canvas?projectid=${encodeURIComponent(projectId)}`,
|
||||
});
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const openCreationTypeModal = useCallback(() => {
|
||||
if (!prepareCreationLaunch()) {
|
||||
return;
|
||||
}
|
||||
@@ -15129,29 +15157,164 @@ export function PlatformEntryFlowShellImpl({
|
||||
) : null}
|
||||
</Suspense>
|
||||
);
|
||||
const creationStartContent = (
|
||||
<div className="image-editor-creation-entry-stack">
|
||||
<button
|
||||
type="button"
|
||||
className="image-editor-creation-entry"
|
||||
onClick={() => setSelectionStage('image-editor')}
|
||||
aria-label="打开图片编辑器"
|
||||
>
|
||||
<span className="image-editor-creation-entry__icon">
|
||||
<ImageIcon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="image-editor-creation-entry__body">
|
||||
<span>图片编辑器</span>
|
||||
<span>画布工具</span>
|
||||
</span>
|
||||
</button>
|
||||
{renderCreationHubContent('start-only', '正在加载创作大厅...')}
|
||||
</div>
|
||||
const creationStartContent = renderCreationHubContent(
|
||||
'start-only',
|
||||
'正在加载创作大厅...',
|
||||
);
|
||||
const draftHubContent = renderCreationHubContent(
|
||||
'works-only',
|
||||
'正在加载草稿列表...',
|
||||
);
|
||||
const creationLandingContent = (
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载创作主页..." />}>
|
||||
<CreationLandingView
|
||||
onOpenProject={openEditorProject}
|
||||
onOpenProjects={openProjectGallery}
|
||||
onOpenCommunity={openCreationCommunity}
|
||||
publicGalleryEntries={[
|
||||
...featuredGalleryEntries,
|
||||
...latestGalleryEntries,
|
||||
]}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
const handlePlatformHomeTabChange = useCallback(
|
||||
(tab: PlatformHomeTab) => {
|
||||
platformBootstrap.setPlatformTab(tab);
|
||||
if (selectionStage === 'creation-home' && tab !== 'create') {
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath('/');
|
||||
}
|
||||
},
|
||||
[platformBootstrap, selectionStage, setSelectionStage],
|
||||
);
|
||||
const platformHomeView = (
|
||||
<PlatformEntryHomeView
|
||||
activeTab={
|
||||
selectionStage === 'creation-home'
|
||||
? 'create'
|
||||
: platformBootstrap.platformTab
|
||||
}
|
||||
onTabChange={handlePlatformHomeTabChange}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
saveEntries={platformBootstrap.saveEntries}
|
||||
saveError={platformBootstrap.saveError}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={latestGalleryEntries}
|
||||
myEntries={platformBootstrap.savedCustomWorldEntries}
|
||||
historyEntries={platformBootstrap.historyEntries}
|
||||
profileDashboard={platformBootstrap.profileDashboard}
|
||||
isLoadingPlatform={platformBootstrap.isLoadingPlatform}
|
||||
isLoadingDashboard={platformBootstrap.isLoadingDashboard}
|
||||
hasUnreadDraftUpdate={hasUnreadDraftUpdates}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
profileGenerationQueueStatus={profileExternalGenerationQueueStatus}
|
||||
isDesktopLayout={isDesktopLayout}
|
||||
isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey}
|
||||
platformError={
|
||||
platformBootstrap.isLoadingPlatform
|
||||
? null
|
||||
: (platformBootstrapErrorForDisplay ??
|
||||
sessionController.agentWorkspaceRestoreError)
|
||||
}
|
||||
dashboardError={
|
||||
platformBootstrap.isLoadingDashboard
|
||||
? null
|
||||
: platformBootstrap.dashboardError
|
||||
}
|
||||
createTabContent={
|
||||
selectionStage === 'creation-home'
|
||||
? creationLandingContent
|
||||
: creationStartContent
|
||||
}
|
||||
draftTabContent={draftHubContent}
|
||||
onContinueGame={handleContinueGame}
|
||||
onResumeSave={(entry) => {
|
||||
if (
|
||||
(entry.worldType ?? '').toLowerCase() === 'puzzle' ||
|
||||
entry.worldKey.startsWith('puzzle:')
|
||||
) {
|
||||
void resumePuzzleSaveArchive(entry);
|
||||
return;
|
||||
}
|
||||
void platformBootstrap.handleResumeSaveEntry(entry);
|
||||
}}
|
||||
onOpenCreateWorld={openCreationTypeModal}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={openPublicGalleryDetail}
|
||||
onOpenChildMotionDemo={startChildMotionDemo}
|
||||
onOpenBabyLoveDrawing={startBabyLoveDrawingRuntime}
|
||||
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
|
||||
recommendRuntimeContent={recommendRuntimeContent}
|
||||
activeRecommendEntryKey={activeRecommendEntryKey}
|
||||
isRecommendRuntimeReady={isActiveRecommendRuntimeReady}
|
||||
isStartingRecommendEntry={
|
||||
isStartingRecommendEntry ||
|
||||
isBigFishBusy ||
|
||||
(isPuzzleBusy &&
|
||||
!(activeRecommendRuntimeKind === 'puzzle' && puzzleRun)) ||
|
||||
(isPuzzleClearBusy &&
|
||||
!(
|
||||
activeRecommendRuntimeKind === 'puzzle-clear' &&
|
||||
puzzleClearRun
|
||||
)) ||
|
||||
isMatch3DBusy ||
|
||||
isSquareHoleBusy ||
|
||||
isVisualNovelBusy ||
|
||||
isWoodenFishBusy
|
||||
}
|
||||
recommendRuntimeError={activeRecommendRuntimeError}
|
||||
onSelectNextRecommendEntry={(activeEntryKey) =>
|
||||
selectAdjacentRecommendRuntimeEntry(1, activeEntryKey)
|
||||
}
|
||||
onSelectPreviousRecommendEntry={(activeEntryKey) =>
|
||||
selectAdjacentRecommendRuntimeEntry(-1, activeEntryKey)
|
||||
}
|
||||
onLikeRecommendEntry={(entry) => {
|
||||
likePublicWork(entry);
|
||||
}}
|
||||
onShareRecommendEntry={(entry) => {
|
||||
openRecommendShareModal(entry);
|
||||
}}
|
||||
onRemixRecommendEntry={(entry) => {
|
||||
remixPublicWork(entry);
|
||||
}}
|
||||
onOpenLibraryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.openLibraryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onDeleteLibraryEntry={(entry) => {
|
||||
handleDeleteLibraryEntry(entry);
|
||||
}}
|
||||
deletingLibraryEntryId={deletingCreationWorkId}
|
||||
onSearchPublicCode={(keyword) => {
|
||||
void handlePublicCodeSearch(keyword);
|
||||
}}
|
||||
isSearchingPublicCode={isSearchingPublicCode}
|
||||
profilePlayStats={profilePlayStats}
|
||||
isProfilePlayStatsOpen={isProfilePlayStatsOpen}
|
||||
isProfilePlayStatsLoading={isProfilePlayStatsLoading}
|
||||
profilePlayStatsError={profilePlayStatsError}
|
||||
onCloseProfilePlayStats={() => {
|
||||
setIsProfilePlayStatsOpen(false);
|
||||
}}
|
||||
onOpenPlayedWork={openPlayedWork}
|
||||
onOpenFeedback={openProfileFeedback}
|
||||
onOpenProjects={openProjectGallery}
|
||||
onOpenProfileDashboardCard={(cardKey) => {
|
||||
if (cardKey === 'playedWorks') {
|
||||
openProfilePlayedWorks();
|
||||
return;
|
||||
}
|
||||
if (platformBootstrap.dashboardError) {
|
||||
void platformBootstrap.refreshProfileDashboard();
|
||||
}
|
||||
}}
|
||||
onRechargeSuccess={platformBootstrap.refreshProfileDashboard}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -15181,16 +15344,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载项目..." />}>
|
||||
<ProjectGalleryView
|
||||
onOpenProject={(projectId) => {
|
||||
setSelectionStage('image-editor');
|
||||
pushAppHistoryPath(
|
||||
`/editor/canvas?projectid=${encodeURIComponent(projectId)}`,
|
||||
);
|
||||
}}
|
||||
onOpenProject={openEditorProject}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
{selectionStage === 'creation-home' && (
|
||||
<motion.div
|
||||
key="creation-home"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
{platformHomeView}
|
||||
</motion.div>
|
||||
)}
|
||||
{selectionStage === 'platform' && (
|
||||
<motion.div
|
||||
key="platform-home"
|
||||
@@ -15199,123 +15368,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
<PlatformEntryHomeView
|
||||
activeTab={platformBootstrap.platformTab}
|
||||
onTabChange={platformBootstrap.setPlatformTab}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
saveEntries={platformBootstrap.saveEntries}
|
||||
saveError={platformBootstrap.saveError}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={latestGalleryEntries}
|
||||
myEntries={platformBootstrap.savedCustomWorldEntries}
|
||||
historyEntries={platformBootstrap.historyEntries}
|
||||
profileDashboard={platformBootstrap.profileDashboard}
|
||||
isLoadingPlatform={platformBootstrap.isLoadingPlatform}
|
||||
isLoadingDashboard={platformBootstrap.isLoadingDashboard}
|
||||
hasUnreadDraftUpdate={hasUnreadDraftUpdates}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
profileGenerationQueueStatus={profileExternalGenerationQueueStatus}
|
||||
isDesktopLayout={isDesktopLayout}
|
||||
isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey}
|
||||
platformError={
|
||||
platformBootstrap.isLoadingPlatform
|
||||
? null
|
||||
: (platformBootstrapErrorForDisplay ??
|
||||
sessionController.agentWorkspaceRestoreError)
|
||||
}
|
||||
dashboardError={
|
||||
platformBootstrap.isLoadingDashboard
|
||||
? null
|
||||
: platformBootstrap.dashboardError
|
||||
}
|
||||
createTabContent={creationStartContent}
|
||||
draftTabContent={draftHubContent}
|
||||
onContinueGame={handleContinueGame}
|
||||
onResumeSave={(entry) => {
|
||||
if (
|
||||
(entry.worldType ?? '').toLowerCase() === 'puzzle' ||
|
||||
entry.worldKey.startsWith('puzzle:')
|
||||
) {
|
||||
void resumePuzzleSaveArchive(entry);
|
||||
return;
|
||||
}
|
||||
void platformBootstrap.handleResumeSaveEntry(entry);
|
||||
}}
|
||||
onOpenCreateWorld={openCreationTypePicker}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={openPublicGalleryDetail}
|
||||
onOpenChildMotionDemo={startChildMotionDemo}
|
||||
onOpenBabyLoveDrawing={startBabyLoveDrawingRuntime}
|
||||
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
|
||||
recommendRuntimeContent={recommendRuntimeContent}
|
||||
activeRecommendEntryKey={activeRecommendEntryKey}
|
||||
isRecommendRuntimeReady={isActiveRecommendRuntimeReady}
|
||||
isStartingRecommendEntry={
|
||||
isStartingRecommendEntry ||
|
||||
isBigFishBusy ||
|
||||
(isPuzzleBusy &&
|
||||
!(activeRecommendRuntimeKind === 'puzzle' && puzzleRun)) ||
|
||||
(isPuzzleClearBusy &&
|
||||
!(
|
||||
activeRecommendRuntimeKind === 'puzzle-clear' &&
|
||||
puzzleClearRun
|
||||
)) ||
|
||||
isMatch3DBusy ||
|
||||
isSquareHoleBusy ||
|
||||
isVisualNovelBusy ||
|
||||
isWoodenFishBusy
|
||||
}
|
||||
recommendRuntimeError={activeRecommendRuntimeError}
|
||||
onSelectNextRecommendEntry={(activeEntryKey) =>
|
||||
selectAdjacentRecommendRuntimeEntry(1, activeEntryKey)
|
||||
}
|
||||
onSelectPreviousRecommendEntry={(activeEntryKey) =>
|
||||
selectAdjacentRecommendRuntimeEntry(-1, activeEntryKey)
|
||||
}
|
||||
onLikeRecommendEntry={(entry) => {
|
||||
likePublicWork(entry);
|
||||
}}
|
||||
onShareRecommendEntry={(entry) => {
|
||||
openRecommendShareModal(entry);
|
||||
}}
|
||||
onRemixRecommendEntry={(entry) => {
|
||||
remixPublicWork(entry);
|
||||
}}
|
||||
onOpenLibraryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.openLibraryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onDeleteLibraryEntry={(entry) => {
|
||||
handleDeleteLibraryEntry(entry);
|
||||
}}
|
||||
deletingLibraryEntryId={deletingCreationWorkId}
|
||||
onSearchPublicCode={(keyword) => {
|
||||
void handlePublicCodeSearch(keyword);
|
||||
}}
|
||||
isSearchingPublicCode={isSearchingPublicCode}
|
||||
profilePlayStats={profilePlayStats}
|
||||
isProfilePlayStatsOpen={isProfilePlayStatsOpen}
|
||||
isProfilePlayStatsLoading={isProfilePlayStatsLoading}
|
||||
profilePlayStatsError={profilePlayStatsError}
|
||||
onCloseProfilePlayStats={() => {
|
||||
setIsProfilePlayStatsOpen(false);
|
||||
}}
|
||||
onOpenPlayedWork={openPlayedWork}
|
||||
onOpenFeedback={openProfileFeedback}
|
||||
onOpenProjects={() => setSelectionStage('project')}
|
||||
onOpenProfileDashboardCard={(cardKey) => {
|
||||
if (cardKey === 'playedWorks') {
|
||||
openProfilePlayedWorks();
|
||||
return;
|
||||
}
|
||||
if (platformBootstrap.dashboardError) {
|
||||
void platformBootstrap.refreshProfileDashboard();
|
||||
}
|
||||
}}
|
||||
onRechargeSuccess={platformBootstrap.refreshProfileDashboard}
|
||||
/>
|
||||
{platformHomeView}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -17713,6 +17766,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.5rem]"
|
||||
/>
|
||||
{isCreationCommunityOpen ? (
|
||||
<PlatformProfileReferralModal
|
||||
panel="community"
|
||||
center={null}
|
||||
isLoading={false}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode=""
|
||||
copyInviteState="idle"
|
||||
error={null}
|
||||
success={null}
|
||||
onClose={() => setIsCreationCommunityOpen(false)}
|
||||
onCopyInvite={() => undefined}
|
||||
onRedeemCodeChange={() => undefined}
|
||||
onSubmitRedeemCode={() => undefined}
|
||||
/>
|
||||
) : null}
|
||||
<PlatformAcknowledgeStatusDialog
|
||||
open={Boolean(workNotFoundRecoveryDialog)}
|
||||
status="error"
|
||||
|
||||
@@ -13,6 +13,7 @@ export type CustomWorldRuntimeLaunchOptions = {
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'creation-home'
|
||||
| 'project'
|
||||
| 'image-editor'
|
||||
| 'profile-feedback'
|
||||
@@ -85,7 +86,10 @@ export type SyncedAgentDraftResult = {
|
||||
|
||||
export type PlatformEntryFlowShellProps = {
|
||||
selectionStage: SelectionStage;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
setSelectionStage: (
|
||||
stage: SelectionStage,
|
||||
options?: { path?: string },
|
||||
) => void;
|
||||
initialPublicWorkCode?: string | null;
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SelectionStage } from './platformEntryTypes';
|
||||
|
||||
const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
|
||||
platform: true,
|
||||
'creation-home': true,
|
||||
project: true,
|
||||
'image-editor': true,
|
||||
'profile-feedback': false,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import type {
|
||||
EditorProjectLayerSnapshot,
|
||||
EditorProjectResourceSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
export const PROJECT_CANVAS_COVER_SIZE = { width: 320, height: 240 } as const;
|
||||
export const PROJECT_CANVAS_COVER_VIEWPORT_SIZE = {
|
||||
width: 900,
|
||||
height: 640,
|
||||
} as const;
|
||||
|
||||
export type ProjectCanvasCoverLayer = {
|
||||
layerId: string;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
export type ProjectCanvasCoverClassNames = {
|
||||
root?: string;
|
||||
empty?: string;
|
||||
layer?: string;
|
||||
image?: string;
|
||||
};
|
||||
|
||||
export type ProjectCanvasCoverProps = {
|
||||
project: EditorProjectSnapshot;
|
||||
classNames?: ProjectCanvasCoverClassNames;
|
||||
};
|
||||
|
||||
function numberFromLayer(
|
||||
layer: EditorProjectLayerSnapshot,
|
||||
key: string,
|
||||
fallback: number,
|
||||
) {
|
||||
const value = layer[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringFromLayer(layer: EditorProjectLayerSnapshot, key: string) {
|
||||
const value = layer[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isLayerHidden(layer: EditorProjectLayerSnapshot) {
|
||||
return layer.hidden === true;
|
||||
}
|
||||
|
||||
function joinClassNames(...classNames: Array<string | undefined>) {
|
||||
return classNames.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function resolveProjectCanvasCoverLayers(
|
||||
project: EditorProjectSnapshot,
|
||||
): ProjectCanvasCoverLayer[] {
|
||||
const resourcesById = new Map<string, EditorProjectResourceSnapshot>(
|
||||
project.resources.map((resource) => [resource.resourceId, resource]),
|
||||
);
|
||||
|
||||
return project.layers
|
||||
.filter((layer) => !isLayerHidden(layer))
|
||||
.map((layer) => {
|
||||
const resource = resourcesById.get(layer.resourceId);
|
||||
const imageSrc = stringFromLayer(layer, 'src') || resource?.imageSrc.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
layerId: layer.layerId,
|
||||
title: stringFromLayer(layer, 'title') || '画布图片',
|
||||
imageSrc,
|
||||
x: numberFromLayer(layer, 'x', 0),
|
||||
y: numberFromLayer(layer, 'y', 0),
|
||||
width: Math.max(1, numberFromLayer(layer, 'width', resource?.width ?? 320)),
|
||||
height: Math.max(1, numberFromLayer(layer, 'height', resource?.height ?? 320)),
|
||||
zIndex: numberFromLayer(layer, 'zIndex', 0),
|
||||
} satisfies ProjectCanvasCoverLayer;
|
||||
})
|
||||
.filter((layer): layer is ProjectCanvasCoverLayer => Boolean(layer))
|
||||
.sort((left, right) => left.zIndex - right.zIndex);
|
||||
}
|
||||
|
||||
export function ProjectCanvasCover({
|
||||
project,
|
||||
classNames,
|
||||
}: ProjectCanvasCoverProps) {
|
||||
const coverLayers = resolveProjectCanvasCoverLayers(project);
|
||||
if (!coverLayers.length) {
|
||||
return (
|
||||
<span
|
||||
className={joinClassNames(
|
||||
'project-gallery__preview-empty',
|
||||
classNames?.empty,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const safeScale =
|
||||
project.viewport.scale > 0 && Number.isFinite(project.viewport.scale)
|
||||
? project.viewport.scale
|
||||
: 1;
|
||||
const viewportCenterX =
|
||||
(PROJECT_CANVAS_COVER_VIEWPORT_SIZE.width / 2 - project.viewport.x) /
|
||||
safeScale;
|
||||
const viewportCenterY =
|
||||
(PROJECT_CANVAS_COVER_VIEWPORT_SIZE.height / 2 - project.viewport.y) /
|
||||
safeScale;
|
||||
const worldPreviewWidth = PROJECT_CANVAS_COVER_VIEWPORT_SIZE.width / safeScale;
|
||||
const worldPreviewHeight =
|
||||
PROJECT_CANVAS_COVER_VIEWPORT_SIZE.height / safeScale;
|
||||
const previewMinX = viewportCenterX - worldPreviewWidth / 2;
|
||||
const previewMinY = viewportCenterY - worldPreviewHeight / 2;
|
||||
const scale = Math.min(
|
||||
PROJECT_CANVAS_COVER_SIZE.width / worldPreviewWidth,
|
||||
PROJECT_CANVAS_COVER_SIZE.height / worldPreviewHeight,
|
||||
);
|
||||
const offsetX = -previewMinX * scale;
|
||||
const offsetY = -previewMinY * scale;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={joinClassNames('project-gallery__canvas-cover', classNames?.root)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{coverLayers.map((layer) => (
|
||||
<span
|
||||
key={layer.layerId}
|
||||
className={joinClassNames(
|
||||
'project-gallery__canvas-cover-layer',
|
||||
classNames?.layer,
|
||||
)}
|
||||
style={{
|
||||
left: offsetX + layer.x * scale,
|
||||
top: offsetY + layer.y * scale,
|
||||
width: layer.width * scale,
|
||||
height: layer.height * scale,
|
||||
zIndex: layer.zIndex,
|
||||
}}
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={layer.imageSrc}
|
||||
alt=""
|
||||
className={joinClassNames(
|
||||
'project-gallery__canvas-cover-image',
|
||||
classNames?.image,
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,9 @@ import {
|
||||
deleteEditorProject,
|
||||
listEditorProjects,
|
||||
renameEditorProject,
|
||||
type EditorProjectLayerSnapshot,
|
||||
type EditorProjectResourceSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
|
||||
@@ -33,9 +30,7 @@ import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
|
||||
const PROJECT_COVER_SIZE = { width: 320, height: 240 };
|
||||
const PROJECT_COVER_VIEWPORT_SIZE = { width: 900, height: 640 };
|
||||
import { ProjectCanvasCover } from './ProjectCanvasCover';
|
||||
|
||||
type ProjectGalleryViewProps = {
|
||||
onOpenProject: (projectId: string) => void;
|
||||
@@ -50,109 +45,6 @@ function isUnauthorizedError(error: unknown) {
|
||||
return error instanceof ApiClientError && error.status === 401;
|
||||
}
|
||||
|
||||
type ProjectCoverLayer = {
|
||||
layerId: string;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
function numberFromLayer(layer: EditorProjectLayerSnapshot, key: string, fallback: number) {
|
||||
const value = layer[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringFromLayer(layer: EditorProjectLayerSnapshot, key: string) {
|
||||
const value = layer[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isLayerHidden(layer: EditorProjectLayerSnapshot) {
|
||||
return layer.hidden === true;
|
||||
}
|
||||
|
||||
function resolveProjectCoverLayers(project: EditorProjectSnapshot): ProjectCoverLayer[] {
|
||||
const resourcesById = new Map<string, EditorProjectResourceSnapshot>(
|
||||
project.resources.map((resource) => [resource.resourceId, resource]),
|
||||
);
|
||||
|
||||
return project.layers
|
||||
.filter((layer) => !isLayerHidden(layer))
|
||||
.map((layer) => {
|
||||
const resource = resourcesById.get(layer.resourceId);
|
||||
const imageSrc = stringFromLayer(layer, 'src') || resource?.imageSrc.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
layerId: layer.layerId,
|
||||
title: stringFromLayer(layer, 'title') || '画布图片',
|
||||
imageSrc,
|
||||
x: numberFromLayer(layer, 'x', 0),
|
||||
y: numberFromLayer(layer, 'y', 0),
|
||||
width: Math.max(1, numberFromLayer(layer, 'width', resource?.width ?? 320)),
|
||||
height: Math.max(1, numberFromLayer(layer, 'height', resource?.height ?? 320)),
|
||||
zIndex: numberFromLayer(layer, 'zIndex', 0),
|
||||
} satisfies ProjectCoverLayer;
|
||||
})
|
||||
.filter((layer): layer is ProjectCoverLayer => Boolean(layer))
|
||||
.sort((left, right) => left.zIndex - right.zIndex);
|
||||
}
|
||||
|
||||
function ProjectCanvasCover({ project }: { project: EditorProjectSnapshot }) {
|
||||
const coverLayers = resolveProjectCoverLayers(project);
|
||||
if (!coverLayers.length) {
|
||||
return <span className="project-gallery__preview-empty" />;
|
||||
}
|
||||
|
||||
const safeScale =
|
||||
project.viewport.scale > 0 && Number.isFinite(project.viewport.scale)
|
||||
? project.viewport.scale
|
||||
: 1;
|
||||
const viewportCenterX =
|
||||
(PROJECT_COVER_VIEWPORT_SIZE.width / 2 - project.viewport.x) / safeScale;
|
||||
const viewportCenterY =
|
||||
(PROJECT_COVER_VIEWPORT_SIZE.height / 2 - project.viewport.y) / safeScale;
|
||||
const worldPreviewWidth = PROJECT_COVER_VIEWPORT_SIZE.width / safeScale;
|
||||
const worldPreviewHeight = PROJECT_COVER_VIEWPORT_SIZE.height / safeScale;
|
||||
const previewMinX = viewportCenterX - worldPreviewWidth / 2;
|
||||
const previewMinY = viewportCenterY - worldPreviewHeight / 2;
|
||||
const scale = Math.min(
|
||||
PROJECT_COVER_SIZE.width / worldPreviewWidth,
|
||||
PROJECT_COVER_SIZE.height / worldPreviewHeight,
|
||||
);
|
||||
const offsetX = -previewMinX * scale;
|
||||
const offsetY = -previewMinY * scale;
|
||||
|
||||
return (
|
||||
<span className="project-gallery__canvas-cover" aria-hidden="true">
|
||||
{coverLayers.map((layer) => (
|
||||
<span
|
||||
key={layer.layerId}
|
||||
className="project-gallery__canvas-cover-layer"
|
||||
style={{
|
||||
left: offsetX + layer.x * scale,
|
||||
top: offsetY + layer.y * scale,
|
||||
width: layer.width * scale,
|
||||
height: layer.height * scale,
|
||||
zIndex: layer.zIndex,
|
||||
}}
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={layer.imageSrc}
|
||||
alt=""
|
||||
className="project-gallery__canvas-cover-image"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatProjectUpdatedAt(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
|
||||
@@ -185,6 +185,7 @@ import {
|
||||
AuthUiContext,
|
||||
type PlatformSettingsSection,
|
||||
} from '../auth/AuthUiContext';
|
||||
import { PLATFORM_DESKTOP_LAYOUT_QUERY } from '../platform-entry/platformEntryResponsive';
|
||||
import {
|
||||
getUnifiedCreationSpec,
|
||||
type UnifiedCreationPlayId,
|
||||
@@ -2528,6 +2529,25 @@ function createAuthValue(
|
||||
};
|
||||
}
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
function mockDesktopPlatformLayout() {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === PLATFORM_DESKTOP_LAYOUT_QUERY,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function TestWrapper({
|
||||
withAuth = false,
|
||||
authValue,
|
||||
@@ -3779,6 +3799,11 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalMatchMedia,
|
||||
});
|
||||
});
|
||||
|
||||
test('create tab shows template tabs and embeds puzzle form by default', async () => {
|
||||
@@ -9087,18 +9112,63 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy
|
||||
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
|
||||
});
|
||||
|
||||
test('create tab does not render legacy gameplay creation entries', async () => {
|
||||
const user = userEvent.setup();
|
||||
test('creation home does not render legacy gameplay creation entries', async () => {
|
||||
mockDesktopPlatformLayout();
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
const { container } = render(<TestWrapper withAuth />);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
expect(
|
||||
await screen.findByRole('heading', {
|
||||
name: '陶泥儿 - 开启全民精品游戏创作',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(container.querySelector('.platform-desktop-topbar')).toBeTruthy();
|
||||
expect(container.querySelector('.platform-desktop-rail')).toBeTruthy();
|
||||
expect(screen.queryByText('选择创作类型')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
expect(createBigFishCreationSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('creation home community opens in place without switching to profile', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDesktopPlatformLayout();
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await screen.findByRole('heading', {
|
||||
name: '陶泥儿 - 开启全民精品游戏创作',
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '玩家社区' }));
|
||||
|
||||
expect(window.location.pathname).toBe('/creation');
|
||||
expect(window.location.search).toBe('');
|
||||
const dialog = await screen.findByRole('dialog', { name: '玩家社区' });
|
||||
expect(within(dialog).getByAltText('玩家社区微信群二维码')).toBeTruthy();
|
||||
expect(within(dialog).getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
|
||||
expect(screen.queryByText('陶泥号')).toBeNull();
|
||||
});
|
||||
|
||||
test('creation home desktop rail can return to platform tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDesktopPlatformLayout();
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
const { container } = render(<TestWrapper withAuth />);
|
||||
|
||||
await screen.findByRole('heading', {
|
||||
name: '陶泥儿 - 开启全民精品游戏创作',
|
||||
});
|
||||
const nav = container.querySelector('.platform-desktop-rail');
|
||||
expect(nav).toBeTruthy();
|
||||
|
||||
await user.click(within(nav as HTMLElement).getByRole('button', { name: '推荐' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('main', { name: '陶泥儿创作主页' })).toBeNull();
|
||||
});
|
||||
expect(window.location.pathname).toBe('/');
|
||||
});
|
||||
|
||||
test('embedded puzzle form timeout exits busy state and shows a readable error', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -782,6 +782,8 @@ function ProfileHomeViewHarness({
|
||||
profileGenerationQueueStatus = null,
|
||||
profilePlayStats = null,
|
||||
isProfilePlayStatsOpen = false,
|
||||
initialProfilePopupPanel = null,
|
||||
onInitialProfilePopupPanelConsumed,
|
||||
}: {
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
profileDashboardOverrides?: Partial<
|
||||
@@ -793,6 +795,8 @@ function ProfileHomeViewHarness({
|
||||
profileGenerationQueueStatus?: RpgEntryHomeViewProps['profileGenerationQueueStatus'];
|
||||
profilePlayStats?: ProfilePlayStatsResponse | null;
|
||||
isProfilePlayStatsOpen?: boolean;
|
||||
initialProfilePopupPanel?: RpgEntryHomeViewProps['initialProfilePopupPanel'];
|
||||
onInitialProfilePopupPanelConsumed?: RpgEntryHomeViewProps['onInitialProfilePopupPanelConsumed'];
|
||||
}) {
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
@@ -859,6 +863,8 @@ function ProfileHomeViewHarness({
|
||||
onRechargeSuccess={onRechargeSuccess}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
profileGenerationQueueStatus={profileGenerationQueueStatus}
|
||||
initialProfilePopupPanel={initialProfilePopupPanel}
|
||||
onInitialProfilePopupPanelConsumed={onInitialProfilePopupPanelConsumed}
|
||||
/>
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
@@ -875,6 +881,8 @@ function renderProfileView(
|
||||
profilePlayStats?: ProfilePlayStatsResponse | null;
|
||||
isProfilePlayStatsOpen?: boolean;
|
||||
profileGenerationQueueStatus?: RpgEntryHomeViewProps['profileGenerationQueueStatus'];
|
||||
initialProfilePopupPanel?: RpgEntryHomeViewProps['initialProfilePopupPanel'];
|
||||
onInitialProfilePopupPanelConsumed?: RpgEntryHomeViewProps['onInitialProfilePopupPanelConsumed'];
|
||||
} = {},
|
||||
) {
|
||||
return render(
|
||||
@@ -888,6 +896,10 @@ function renderProfileView(
|
||||
}
|
||||
profilePlayStats={profileStatsOptions.profilePlayStats}
|
||||
isProfilePlayStatsOpen={profileStatsOptions.isProfilePlayStatsOpen}
|
||||
initialProfilePopupPanel={profileStatsOptions.initialProfilePopupPanel}
|
||||
onInitialProfilePopupPanelConsumed={
|
||||
profileStatsOptions.onInitialProfilePopupPanelConsumed
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -998,6 +1010,9 @@ function renderLoggedInHomeView(
|
||||
| 'draftTabContent'
|
||||
| 'myEntries'
|
||||
| 'isLoadingPlatform'
|
||||
| 'isDesktopLayout'
|
||||
| 'onOpenCreateTypePicker'
|
||||
| 'onOpenProjects'
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
@@ -1032,6 +1047,7 @@ function renderLoggedInHomeView(
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab={overrides.activeTab ?? 'saves'}
|
||||
isDesktopLayout={overrides.isDesktopLayout}
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
@@ -1050,10 +1066,11 @@ function renderLoggedInHomeView(
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenCreateTypePicker={overrides.onOpenCreateTypePicker ?? vi.fn()}
|
||||
onOpenGalleryDetail={vi.fn()}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
onOpenProjects={overrides.onOpenProjects}
|
||||
hasUnreadDraftUpdate={overrides.hasUnreadDraftUpdate ?? false}
|
||||
draftTabContent={overrides.draftTabContent}
|
||||
/>
|
||||
@@ -2296,10 +2313,11 @@ test('profile recharge modal blocks tab navigation while virtual payment confirm
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '正在确认支付' })).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '创作' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
const nav = document.querySelector('.platform-bottom-nav');
|
||||
expect(nav).toBeTruthy();
|
||||
for (const navButton of within(nav as HTMLElement).getAllByRole('button')) {
|
||||
expect((navButton as HTMLButtonElement).disabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => {
|
||||
@@ -3064,6 +3082,10 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
}),
|
||||
).toBeTruthy();
|
||||
}
|
||||
expect(
|
||||
within(shortcutRegion).queryByRole('button', { name: /项目/u }),
|
||||
).toBeNull();
|
||||
expect(shortcutRegion.textContent).not.toContain('画布项目');
|
||||
expect(
|
||||
within(
|
||||
within(shortcutRegion).getByRole('button', { name: /反馈与建议/u }),
|
||||
@@ -3324,6 +3346,61 @@ test('profile community shortcut shows reward subtitle and invited users', async
|
||||
});
|
||||
});
|
||||
|
||||
test('profile community popup can be opened from external creation entry', async () => {
|
||||
const onConsumed = vi.fn();
|
||||
|
||||
renderProfileView(
|
||||
vi.fn(),
|
||||
{},
|
||||
{},
|
||||
0,
|
||||
{
|
||||
initialProfilePopupPanel: 'community',
|
||||
onInitialProfilePopupPanelConsumed: onConsumed,
|
||||
},
|
||||
);
|
||||
|
||||
const communityDialog = await screen.findByRole('dialog', {
|
||||
name: '玩家社区',
|
||||
});
|
||||
|
||||
expect(onConsumed).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
within(communityDialog).getByRole('button', { name: '关闭玩家社区' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByAltText('玩家社区微信群二维码')).toBeTruthy();
|
||||
expect(screen.getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile community popup external open intent can be reused after consume', async () => {
|
||||
const onConsumed = vi.fn();
|
||||
const { rerender } = render(
|
||||
<ProfileHomeViewHarness
|
||||
initialProfilePopupPanel="community"
|
||||
onInitialProfilePopupPanelConsumed={onConsumed}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '玩家社区' })).toBeTruthy();
|
||||
expect(onConsumed).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<ProfileHomeViewHarness
|
||||
initialProfilePopupPanel={null}
|
||||
onInitialProfilePopupPanelConsumed={onConsumed}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<ProfileHomeViewHarness
|
||||
initialProfilePopupPanel="community"
|
||||
onInitialProfilePopupPanelConsumed={onConsumed}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '玩家社区' })).toBeTruthy();
|
||||
expect(onConsumed).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('profile page hides legacy redeem invite secondary shortcut for fresh accounts', async () => {
|
||||
renderProfileView(vi.fn(), {});
|
||||
|
||||
@@ -3483,6 +3560,10 @@ test('profile page shows legal entries and hides archive shortcuts', async () =>
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /反馈/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(shortcutRegion).queryByRole('button', { name: /项目/u }),
|
||||
).toBeNull();
|
||||
expect(shortcutRegion.textContent).not.toContain('画布项目');
|
||||
const dailyTask = screen.getByRole('button', { name: /每日任务/u });
|
||||
expect(dailyTask).toBeTruthy();
|
||||
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
|
||||
@@ -3549,21 +3630,24 @@ test('logged out bottom nav turns active recommend tab into next action', () =>
|
||||
|
||||
expect(buttons.map((button) => button.textContent)).toEqual([
|
||||
'下一个',
|
||||
'创作',
|
||||
'发现',
|
||||
'我的',
|
||||
]);
|
||||
expect(buttons[0]?.querySelector('.lucide-chevron-down')).toBeTruthy();
|
||||
expect(buttons[1]?.querySelector('.lucide-sparkles')).toBeTruthy();
|
||||
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
|
||||
expect(buttons[1]?.querySelector('.lucide-compass')).toBeTruthy();
|
||||
expect(buttons[2]?.querySelector('.lucide-user-round')).toBeTruthy();
|
||||
expect(
|
||||
buttons[1]?.querySelector('.platform-bottom-nav__primary-action'),
|
||||
).toBeTruthy();
|
||||
within(nav as HTMLElement).queryByRole('button', { name: '创作' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(nav as HTMLElement).queryByRole('button', { name: '项目' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buttons[0]?.querySelector('.platform-bottom-nav__active-mark'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('logged in draft bottom tab shows unread marker', () => {
|
||||
test('logged in mobile bottom nav hides draft and project entries', () => {
|
||||
const { container } = renderLoggedInHomeView({
|
||||
hasUnreadDraftUpdate: true,
|
||||
draftTabContent: <div>草稿内容</div>,
|
||||
@@ -3571,11 +3655,74 @@ test('logged in draft bottom tab shows unread marker', () => {
|
||||
|
||||
const nav = container.querySelector('.platform-bottom-nav');
|
||||
expect(nav).toBeTruthy();
|
||||
const draftButton = within(nav as HTMLElement).getByRole('button', {
|
||||
name: '草稿,有新草稿',
|
||||
expect(
|
||||
within(nav as HTMLElement).getByRole('button', { name: '推荐' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(nav as HTMLElement).getByRole('button', { name: '发现' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(nav as HTMLElement).getByRole('button', { name: '我的' }),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
within(nav as HTMLElement).queryByRole('button', {
|
||||
name: '草稿,有新草稿',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(nav as HTMLElement).queryByRole('button', { name: '创作' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(nav as HTMLElement).queryByRole('button', { name: '项目' }),
|
||||
).toBeNull();
|
||||
expect(nav?.querySelector('.platform-nav-unread-dot')).toBeNull();
|
||||
});
|
||||
|
||||
test('desktop nav shows creation and projects without draft entry', async () => {
|
||||
const { container } = renderLoggedInHomeView({
|
||||
activeTab: 'profile',
|
||||
isDesktopLayout: true,
|
||||
hasUnreadDraftUpdate: true,
|
||||
});
|
||||
|
||||
expect(draftButton.querySelector('.platform-nav-unread-dot')).toBeTruthy();
|
||||
const nav = container.querySelector('.platform-desktop-rail');
|
||||
expect(nav).toBeTruthy();
|
||||
expect(
|
||||
within(nav as HTMLElement).getByRole('button', { name: '创作' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(nav as HTMLElement).getByRole('button', { name: '项目' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(nav as HTMLElement).queryByRole('button', {
|
||||
name: '草稿,有新草稿',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(nav as HTMLElement).queryByRole('button', { name: /^草稿$/u }),
|
||||
).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
test('desktop nav opens creation home and project gallery from dedicated entries', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenCreateTypePicker = vi.fn();
|
||||
const onOpenProjects = vi.fn();
|
||||
const { container } = renderLoggedInHomeView({
|
||||
activeTab: 'profile',
|
||||
isDesktopLayout: true,
|
||||
onOpenCreateTypePicker,
|
||||
onOpenProjects,
|
||||
});
|
||||
|
||||
const nav = container.querySelector('.platform-desktop-rail');
|
||||
expect(nav).toBeTruthy();
|
||||
await user.click(within(nav as HTMLElement).getByRole('button', { name: '创作' }));
|
||||
await user.click(within(nav as HTMLElement).getByRole('button', { name: '项目' }));
|
||||
|
||||
expect(onOpenCreateTypePicker).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenProjects).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('logged in saves tab shows loading state while fetching my entries', () => {
|
||||
|
||||
@@ -137,6 +137,7 @@ import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProf
|
||||
import type { ExternalGenerationQueueStatus } from '../platform-entry/platformExternalGenerationQueueStatusModel';
|
||||
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
|
||||
import {
|
||||
type ProfilePopupPanel,
|
||||
type RechargePaymentResult,
|
||||
usePlatformProfileCenterController,
|
||||
} from '../platform-entry/usePlatformProfileCenterController';
|
||||
@@ -222,6 +223,7 @@ export type PlatformHomeTab =
|
||||
| 'create'
|
||||
| 'saves'
|
||||
| 'profile';
|
||||
type PlatformNavTab = PlatformHomeTab | 'projects';
|
||||
export interface RpgEntryHomeViewProps {
|
||||
activeTab: PlatformHomeTab;
|
||||
isDesktopLayout?: boolean;
|
||||
@@ -279,6 +281,8 @@ export interface RpgEntryHomeViewProps {
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
profileTaskRefreshKey?: number;
|
||||
profileGenerationQueueStatus?: ExternalGenerationQueueStatus | null;
|
||||
initialProfilePopupPanel?: ProfilePopupPanel | null;
|
||||
onInitialProfilePopupPanelConsumed?: () => void;
|
||||
createTabContent?: ReactNode;
|
||||
draftTabContent?: ReactNode;
|
||||
hasUnreadDraftUpdate?: boolean;
|
||||
@@ -306,6 +310,13 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
|
||||
'saves',
|
||||
'profile',
|
||||
];
|
||||
const PLATFORM_DESKTOP_NAV_TABS: PlatformNavTab[] = [
|
||||
'home',
|
||||
'category',
|
||||
'create',
|
||||
'projects',
|
||||
'profile',
|
||||
];
|
||||
const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
const AVATAR_OUTPUT_SIZE = 256;
|
||||
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
@@ -1887,6 +1898,10 @@ function DesktopTabButton({
|
||||
);
|
||||
}
|
||||
|
||||
function isPlatformHomeTab(tab: PlatformNavTab): tab is PlatformHomeTab {
|
||||
return tab !== 'projects';
|
||||
}
|
||||
|
||||
function PlatformTabPanel({
|
||||
tab,
|
||||
activeTab,
|
||||
@@ -2575,6 +2590,8 @@ export function RpgEntryHomeView({
|
||||
onRechargeSuccess,
|
||||
profileTaskRefreshKey = 0,
|
||||
profileGenerationQueueStatus = null,
|
||||
initialProfilePopupPanel = null,
|
||||
onInitialProfilePopupPanelConsumed,
|
||||
createTabContent,
|
||||
draftTabContent,
|
||||
hasUnreadDraftUpdate = false,
|
||||
@@ -2701,6 +2718,30 @@ export function RpgEntryHomeView({
|
||||
requestLogin: () => authUi?.openLoginModal(),
|
||||
currentUser,
|
||||
});
|
||||
|
||||
const openedInitialProfilePopupPanelRef = useRef<ProfilePopupPanel | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!initialProfilePopupPanel) {
|
||||
openedInitialProfilePopupPanelRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeTab !== 'profile' ||
|
||||
openedInitialProfilePopupPanelRef.current === initialProfilePopupPanel
|
||||
) {
|
||||
return;
|
||||
}
|
||||
openedInitialProfilePopupPanelRef.current = initialProfilePopupPanel;
|
||||
openProfilePopupPanel(initialProfilePopupPanel);
|
||||
onInitialProfilePopupPanelConsumed?.();
|
||||
}, [
|
||||
activeTab,
|
||||
initialProfilePopupPanel,
|
||||
onInitialProfilePopupPanelConsumed,
|
||||
openProfilePopupPanel,
|
||||
]);
|
||||
const edutainmentEntryEnabled = isEdutainmentEntryEnabled();
|
||||
const [fallbackDesktopLayout] = useState(getInitialPlatformDesktopLayout);
|
||||
const isDesktopLayout = isDesktopLayoutProp ?? fallbackDesktopLayout;
|
||||
@@ -2834,11 +2875,18 @@ export function RpgEntryHomeView({
|
||||
hasManualCategoryTagSelectionRef.current = true;
|
||||
setSelectedCategoryTag(tag);
|
||||
}, []);
|
||||
const visibleTabs = useMemo<PlatformHomeTab[]>(
|
||||
const visibleMobileTabs = useMemo<PlatformHomeTab[]>(
|
||||
() =>
|
||||
isAuthenticated
|
||||
? ['home', 'category', 'create', 'saves', 'profile']
|
||||
: ['home', 'create', 'category'],
|
||||
? ['home', 'category', 'profile']
|
||||
: ['home', 'category', 'profile'],
|
||||
[isAuthenticated],
|
||||
);
|
||||
const visibleDesktopNavTabs = useMemo<PlatformNavTab[]>(
|
||||
() =>
|
||||
isAuthenticated
|
||||
? PLATFORM_DESKTOP_NAV_TABS
|
||||
: ['home', 'category', 'create', 'projects', 'profile'],
|
||||
[isAuthenticated],
|
||||
);
|
||||
const publicUserCode = buildPublicUserCode(authUi?.user);
|
||||
@@ -2856,37 +2904,44 @@ export function RpgEntryHomeView({
|
||||
() => buildProfileTaskCardSummary(taskCenter),
|
||||
[taskCenter],
|
||||
);
|
||||
const tabIcons: Record<
|
||||
PlatformHomeTab,
|
||||
ComponentType<{ className?: string }>
|
||||
> = isAuthenticated
|
||||
? {
|
||||
home: Sparkles,
|
||||
category: Compass,
|
||||
create: Sparkles,
|
||||
saves: Pencil,
|
||||
profile: UserRound,
|
||||
}
|
||||
: {
|
||||
home: Gamepad2,
|
||||
category: Compass,
|
||||
create: Sparkles,
|
||||
saves: Pencil,
|
||||
profile: UserRound,
|
||||
};
|
||||
const tabIcons: Record<PlatformNavTab, ComponentType<{ className?: string }>> =
|
||||
isAuthenticated
|
||||
? {
|
||||
home: Sparkles,
|
||||
category: Compass,
|
||||
create: Sparkles,
|
||||
projects: FolderKanban,
|
||||
saves: Pencil,
|
||||
profile: UserRound,
|
||||
}
|
||||
: {
|
||||
home: Gamepad2,
|
||||
category: Compass,
|
||||
create: Sparkles,
|
||||
projects: FolderKanban,
|
||||
saves: Pencil,
|
||||
profile: UserRound,
|
||||
};
|
||||
const tabLabels = {
|
||||
home: '推荐',
|
||||
category: '发现',
|
||||
create: '创作',
|
||||
projects: '项目',
|
||||
saves: '草稿',
|
||||
profile: '我的',
|
||||
} as const;
|
||||
} as const satisfies Record<PlatformNavTab, string>;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleTabs.includes(activeTab)) {
|
||||
if (!visibleMobileTabs.includes(activeTab) && !isDesktopLayout) {
|
||||
onTabChange(isAuthenticated ? 'home' : 'category');
|
||||
}
|
||||
}, [activeTab, isAuthenticated, onTabChange, visibleTabs]);
|
||||
}, [
|
||||
activeTab,
|
||||
isAuthenticated,
|
||||
isDesktopLayout,
|
||||
onTabChange,
|
||||
visibleMobileTabs,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -4382,12 +4437,6 @@ export function RpgEntryHomeView({
|
||||
imageSrc={profileCommunityImage}
|
||||
onClick={() => openProfilePopupPanel('community')}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="项目"
|
||||
subLabel="画布项目"
|
||||
icon={FolderKanban}
|
||||
onClick={onOpenProjects}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="反馈与建议"
|
||||
subLabel="帮我们优化产品"
|
||||
@@ -4733,8 +4782,9 @@ export function RpgEntryHomeView({
|
||||
saves: savesContent,
|
||||
profile: profileContent,
|
||||
} satisfies Record<PlatformHomeTab, ReactNode>;
|
||||
const tabPanels = PLATFORM_HOME_TABS.filter((tab) =>
|
||||
visibleTabs.includes(tab),
|
||||
const tabPanels = PLATFORM_HOME_TABS.filter(
|
||||
(tab) =>
|
||||
isDesktopLayout || visibleMobileTabs.includes(tab) || tab === activeTab,
|
||||
).map((tab) => {
|
||||
const shouldMountPanel = tab === activeTab || visitedTabs.has(tab);
|
||||
|
||||
@@ -4919,9 +4969,9 @@ export function RpgEntryHomeView({
|
||||
|
||||
<div className="platform-mobile-bottom-dock min-w-0 shrink-0">
|
||||
<div
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
className={`platform-bottom-nav grid ${visibleMobileTabs.length === 5 ? 'grid-cols-5' : visibleMobileTabs.length === 4 ? 'grid-cols-4' : visibleMobileTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
{visibleTabs.map((tab) => (
|
||||
{visibleMobileTabs.map((tab) => (
|
||||
<PlatformTabButton
|
||||
key={tab}
|
||||
active={activeTab === tab}
|
||||
@@ -5092,19 +5142,27 @@ export function RpgEntryHomeView({
|
||||
|
||||
<div className="mt-5 flex min-h-0 gap-5">
|
||||
<aside className="platform-desktop-rail flex w-[5.8rem] shrink-0 flex-col gap-3 p-3">
|
||||
{visibleTabs.map((tab) => (
|
||||
{visibleDesktopNavTabs.map((tab) => (
|
||||
<DesktopTabButton
|
||||
key={tab}
|
||||
active={activeTab === tab}
|
||||
active={isPlatformHomeTab(tab) && activeTab === tab}
|
||||
label={tabLabels[tab]}
|
||||
icon={tabIcons[tab]}
|
||||
emphasized={tab === 'create'}
|
||||
showDot={tab === 'saves' && hasUnreadDraftUpdate}
|
||||
showDot={false}
|
||||
disabled={isRechargePaymentConfirmationPending}
|
||||
onClick={() => {
|
||||
if (isRechargePaymentConfirmationPending) {
|
||||
return;
|
||||
}
|
||||
if (tab === 'create') {
|
||||
onOpenCreateTypePicker();
|
||||
return;
|
||||
}
|
||||
if (tab === 'projects') {
|
||||
onOpenProjects?.();
|
||||
return;
|
||||
}
|
||||
onTabChange(tab);
|
||||
}}
|
||||
/>
|
||||
|
||||
+374
@@ -3213,6 +3213,380 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
margin: 1rem 1.5rem 0;
|
||||
}
|
||||
|
||||
.creation-landing {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.creation-landing__hero,
|
||||
.creation-landing__section {
|
||||
width: min(980px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.creation-landing__hero {
|
||||
display: grid;
|
||||
gap: 1.35rem;
|
||||
padding: 3.2rem 0 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.creation-landing__hero-copy {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.82rem;
|
||||
}
|
||||
|
||||
.creation-landing__eyebrow {
|
||||
width: fit-content;
|
||||
color: #6b7280;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.creation-landing__hero h1 {
|
||||
margin: 0;
|
||||
max-width: 38rem;
|
||||
color: #111827;
|
||||
font-size: clamp(1.55rem, 3vw, 2.25rem);
|
||||
font-weight: 950;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.creation-landing__hero p {
|
||||
margin: 0;
|
||||
color: #4b5563;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.creation-landing__hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.65rem;
|
||||
padding-top: 0.08rem;
|
||||
}
|
||||
|
||||
.creation-landing__hero-board {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.creation-landing__hero-tile {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.55rem;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.creation-landing__hero-tile--main {
|
||||
left: 14%;
|
||||
top: 14%;
|
||||
width: 58%;
|
||||
aspect-ratio: 4 / 3;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(75, 181, 170, 0.24), transparent 52%),
|
||||
#ffffff;
|
||||
}
|
||||
|
||||
.creation-landing__hero-tile--side {
|
||||
right: 9%;
|
||||
top: 28%;
|
||||
width: 34%;
|
||||
aspect-ratio: 1;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(246, 171, 76, 0.24), transparent 56%),
|
||||
#ffffff;
|
||||
}
|
||||
|
||||
.creation-landing__hero-tile--small {
|
||||
left: 30%;
|
||||
bottom: 10%;
|
||||
width: 36%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(129, 140, 248, 0.2), transparent 56%),
|
||||
#ffffff;
|
||||
}
|
||||
|
||||
.creation-landing__section {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 0.95rem 0;
|
||||
}
|
||||
|
||||
.creation-landing__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.creation-landing__section-header h2 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 920;
|
||||
}
|
||||
|
||||
.creation-landing__link-button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
padding: 0.48rem 0.82rem;
|
||||
color: #238a82;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 840;
|
||||
}
|
||||
|
||||
.creation-landing__feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.creation-landing__feature {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"icon title"
|
||||
"icon desc";
|
||||
gap: 0.22rem 0.72rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 0.85rem;
|
||||
background: #ffffff;
|
||||
padding: 0.85rem;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.creation-landing__feature-icon {
|
||||
display: grid;
|
||||
grid-area: icon;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
place-items: center;
|
||||
border-radius: 0.5rem;
|
||||
background: #e6f7f5;
|
||||
color: #238a82;
|
||||
}
|
||||
|
||||
.creation-landing__feature h3,
|
||||
.creation-landing__asset-card h3 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 880;
|
||||
}
|
||||
|
||||
.creation-landing__feature h3 {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.creation-landing__feature p {
|
||||
grid-area: desc;
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.creation-landing__project-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(10rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.creation-landing__project-card {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.65rem;
|
||||
background: #ffffff;
|
||||
padding: 0.7rem;
|
||||
color: #111827;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.creation-landing__project-card:hover,
|
||||
.creation-landing__link-button:hover {
|
||||
border-color: rgba(35, 138, 130, 0.32);
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.creation-landing__project-card--new {
|
||||
place-items: center;
|
||||
min-height: 12rem;
|
||||
color: #238a82;
|
||||
font-weight: 880;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.creation-landing__project-card--new span {
|
||||
display: grid;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #e6f7f5;
|
||||
}
|
||||
|
||||
.creation-landing__project-cover {
|
||||
border-radius: 0.45rem;
|
||||
}
|
||||
|
||||
.creation-landing__project-meta {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.creation-landing__project-meta strong,
|
||||
.creation-landing__project-meta small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.creation-landing__project-meta strong {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.creation-landing__project-meta small {
|
||||
color: #64748b;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.creation-landing__tabs {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.creation-landing__tab {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
padding: 0.48rem 0.8rem;
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.creation-landing__tab--active {
|
||||
border-color: rgba(35, 138, 130, 0.32);
|
||||
background: #e6f7f5;
|
||||
color: #176c66;
|
||||
}
|
||||
|
||||
.creation-landing__asset-waterfall {
|
||||
column-count: 3;
|
||||
column-gap: 0.92rem;
|
||||
}
|
||||
|
||||
.creation-landing__asset-card {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin: 0 0 0.92rem;
|
||||
break-inside: avoid;
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 0.78rem;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.creation-landing__asset-preview {
|
||||
display: grid;
|
||||
min-height: 9.5rem;
|
||||
max-height: 23rem;
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
background: linear-gradient(180deg, #ffffff, #f8fafc);
|
||||
}
|
||||
|
||||
.creation-landing__asset-meta {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.creation-landing__asset-meta p {
|
||||
display: -webkit-box;
|
||||
min-height: 2.6em;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.creation-landing__asset-meta dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.creation-landing__asset-meta div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.creation-landing__asset-meta dt {
|
||||
color: #94a3b8;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.creation-landing__asset-meta dd {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 780;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.creation-landing__hero {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.creation-landing__feature-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.creation-landing__project-row {
|
||||
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||
}
|
||||
|
||||
.creation-landing__asset-waterfall {
|
||||
column-count: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.creation-landing__asset-waterfall {
|
||||
column-count: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image-canvas-editor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -31,12 +31,27 @@ describe('appPageRoutes', () => {
|
||||
expect(resolvePathForSelectionStage('image-editor')).toBe('/editor/canvas');
|
||||
});
|
||||
|
||||
it('preserves project id when navigating to an editor canvas project', () => {
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
|
||||
pushAppHistoryPath('/editor/canvas?projectid=project-from-route-test');
|
||||
|
||||
expect(window.location.pathname).toBe('/editor/canvas');
|
||||
expect(window.location.search).toBe('?projectid=project-from-route-test');
|
||||
});
|
||||
|
||||
it('resolves the project route', () => {
|
||||
expect(resolveSelectionStageFromPath('/project')).toBe('project');
|
||||
expect(resolveSelectionStageFromPath('/PROJECT/')).toBe('project');
|
||||
expect(resolvePathForSelectionStage('project')).toBe('/project');
|
||||
});
|
||||
|
||||
it('resolves the creation home route', () => {
|
||||
expect(resolveSelectionStageFromPath('/creation')).toBe('creation-home');
|
||||
expect(resolveSelectionStageFromPath('/CREATION/')).toBe('creation-home');
|
||||
expect(resolvePathForSelectionStage('creation-home')).toBe('/creation');
|
||||
});
|
||||
|
||||
it('resolves jump-hop creation, gallery and runtime routes', () => {
|
||||
expect(resolveSelectionStageFromPath('/creation/jump-hop')).toBe(
|
||||
'jump-hop-workspace',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const PUBLIC_WORK_QUERY_PARAM = 'work';
|
||||
|
||||
const STAGE_ROUTE_ENTRIES = [
|
||||
['platform', '/'],
|
||||
['creation-home', '/creation'],
|
||||
['project', '/project'],
|
||||
['image-editor', '/editor/canvas'],
|
||||
['profile-feedback', '/profile/feedback'],
|
||||
|
||||
Reference in New Issue
Block a user