diff --git a/docs/README.md b/docs/README.md index 51a5a57f2..38d97e042 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 10c4f2756..ae803daf9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.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/` 仍进入对应玩法工作台;桌面导航显示“创作 / 项目”且不显示“草稿”;移动端底部不显示“创作 / 项目”;页面不出现外站品牌或 `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 模型携带参考字段也会违反文档契约。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0e965d443..5b639514f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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` 撑开页面,看起来没有同样问题。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 70e692431..10efa6a22 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -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` 是画布项目入口。 ## 当前后端路线 diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md new file mode 100644 index 000000000..75445fab8 --- /dev/null +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -0,0 +1,191 @@ +# 创作主页与项目入口改版计划 + +日期:2026-06-18 + +## 背景 + +当前平台首页中的“创作”仍偏向站内 Tab 和创作入口列表,“草稿”也仍作为一级入口存在。图片画布项目已经成为独立项目资产,需要在桌面端给用户一个更明确的创作工具主页,并把项目管理提升为一级入口。 + +本轮改版参考外部创作工具首页的信息组织,但所有用户可见文案、测试命名和产品命名都使用“陶泥儿”。外部品牌只作为设计参考,不进入产品 UI、测试名或文档验收口径。 + +## 目标 + +- 新增桌面端独立 `/creation` 创作工具主页。 +- 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`。 +- 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口。 +- 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`。 +- `陶泥儿精选` 展示真实账号素材瀑布流,不使用 mock 素材。 +- 现有 `/creation/` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。 + +## 页面结构 + +### 顶部品牌区 + +- 标题:`陶泥儿 - 开启全民精品游戏创作` +- 主按钮:`开始创作` +- 社群入口:原地弹出“玩家社区”二维码弹窗,复用现有社区弹层能力,不跳转或切换到“我的 / 用户中心”。 +- 营销点:`登录即送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/` 工作台使用,但不作为本页精选区内容。 + +精选内容优先使用真实账号级素材和生成结果数据;未登录或账号素材为空时,可使用已公开作品中真实用户生成的图片作为瀑布流素材来源。两种来源都不使用 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/` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。 + +## 路由与导航 + +- 新增 `SelectionStage = 'creation-home'`。 +- `/creation` 映射到 `creation-home`。 +- `/creation/` 现有玩法工作台路由保持原有 stage 映射。 +- 桌面端点击“创作”时调用 `pushAppHistoryPath('/creation')`。 +- 桌面端原“草稿”入口改为“项目”,点击 `pushAppHistoryPath('/project')`。 +- 移动端导航列表不展示“创作”和“项目”。 +- 移动端不拦截用户直接访问 `/creation` 或 `/project`。 + +## 实施拆分 + +### 路由与导航 + +- 增加 `/creation` 路由映射和测试。 +- 调整桌面导航文案与点击行为。 +- 移动端导航移除“创作”和“项目”入口。 +- 保持现有 `/creation/` 工作台、生成页、结果页和运行态路由不回归。 + +### 创作主页 + +- 新增 `CreationLandingView`。 +- 实现首屏主视觉、九大功能区、最近项目区和陶泥儿精选素材瀑布流。 +- 桌面端复用现有平台壳层,保留上方栏和左侧导航栏。 +- 主页整体采用浅色布局,避免外站品牌文案。 +- 社群入口复用现有玩家社区弹层,点击后必须停留在 `/creation`。 +- 不在 UI 中放规则说明、实现说明或“暂不实装”提示。 +- 未登录时不渲染最近项目区;需要创建项目时走登录弹窗和登录后重试。 + +### 项目卡与封面复用 + +- 从 `ProjectGalleryView` 抽出项目封面模型或组件。 +- `/creation` 最近项目与 `/project` 共用同一封面逻辑。 +- 新建项目复用现有 `createEditorProject`、`listEditorProjects` 和 `/editor/canvas?projectid=xxx` 链路。 + +### 我的页 + +- 删除“项目 / 画布项目”快捷入口。 +- 常用功能区稳定为 4 项:泥点充值、兑换码、玩家社区、反馈与建议。 + +## 测试计划 + +单元和组件测试: + +- `appPageRoutes.test.ts`:覆盖 `/creation -> creation-home`,并确认现有 `/creation/` 不回归。 +- `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`。 diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 000000000..bd1bac462 --- /dev/null +++ b/src/App.test.tsx @@ -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: () =>
运行态
, +})); + +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(); + + 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(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 912b43631..237fafde3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = () => { diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx new file mode 100644 index 000000000..357decff8 --- /dev/null +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -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>; + +function createAuthValue(overrides: Partial = {}): 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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(); + }); +}); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx new file mode 100644 index 000000000..7c8eb9991 --- /dev/null +++ b/src/components/creation-home/CreationLandingView.tsx @@ -0,0 +1,621 @@ +import { + Box, + Code2, + Gamepad2, + Image as ImageIcon, + Layers3, + Megaphone, + Music, + Palette, + Plus, + Sparkles, + Wand2, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + createEditorProject, + listEditorProjects, + loadEditorAssetLibrary, + type EditorAssetLibrarySnapshot, + type EditorAssetSnapshot, + type EditorProjectSnapshot, +} from '../../services/image-editor/editorProjectClient'; +import { ApiClientError } from '../../services/apiClient'; +import { useAuthUi } from '../auth/AuthUiContext'; +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformEmptyState } from '../common/PlatformEmptyState'; +import { PlatformMediaFrame } from '../common/PlatformMediaFrame'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { ProjectCanvasCover } from '../project/ProjectCanvasCover'; +import { ResolvedAssetImage } from '../ResolvedAssetImage'; +import { + resolvePlatformWorkAuthorDisplayName, + resolvePlatformWorldCoverImage, + resolvePlatformWorldCoverSlides, + type PlatformPublicGalleryCard, +} from '../rpg-entry/rpgEntryWorldPresentation'; + +type CreationLandingViewProps = { + onOpenProject: (projectId: string) => void; + onOpenProjects: () => void; + onOpenCommunity?: () => void; + publicGalleryEntries?: PlatformPublicGalleryCard[]; +}; + +type ShowcaseTabId = + | 'packs' + | 'characters' + | 'ui' + | 'music' + | 'effects' + | 'scenes' + | 'marketing' + | 'games'; + +type CreationFeatureItem = { + title: string; + description: string; + icon: typeof Palette; +}; + +type ShowcaseAssetItem = { + id: string; + label: string; + imageSrc: string; + width?: number; + height?: number; + prompt: string; + author: string; + cost: string; +}; + +const CREATION_FEATURES: CreationFeatureItem[] = [ + { + title: '游戏视觉规范', + description: '轻松约束多类素材视觉一致性', + icon: Palette, + }, + { + title: '游戏角色', + description: '整套高完成度2D/3D角色素材及动画', + icon: Box, + }, + { + title: '游戏UI', + description: '轻松生成高一致性的UI素材', + icon: Layers3, + }, + { + title: '游戏音乐', + description: '轻松制作游戏背景音乐、音效', + icon: Music, + }, + { + title: '游戏特效', + description: '低成本生成精品游戏特效', + icon: Sparkles, + }, + { + title: '游戏场景', + description: '轻松生成各类游戏场景素材', + icon: ImageIcon, + }, + { + title: '游戏美宣', + description: '轻松生成游戏上架所需图片和视频', + icon: Megaphone, + }, + { + title: '游戏功能编码', + description: '自然语言驱动游戏生成', + icon: Code2, + }, + { + title: '一键导出', + description: '支持导出为微抖小游戏、Godot、Unity、UE工程', + icon: Gamepad2, + }, +]; + +const SHOWCASE_TABS: Array<{ id: ShowcaseTabId; label: string }> = [ + { id: 'packs', label: '素材包' }, + { id: 'characters', label: '角色' }, + { id: 'ui', label: 'UI' }, + { id: 'music', label: '音乐' }, + { id: 'effects', label: '特效' }, + { id: 'scenes', label: '场景' }, + { id: 'marketing', label: '美宣' }, + { id: 'games', label: '游戏' }, +]; + +function isUnauthorizedError(error: unknown) { + return error instanceof ApiClientError && error.status === 401; +} + +function formatProjectUpdatedAt(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); +} + +function formatAssetCost(asset: EditorAssetSnapshot) { + const value = (asset as Record)['priceMudPoints']; + return typeof value === 'number' && Number.isFinite(value) + ? `${value}泥点` + : '-'; +} + +function resolveAssetAuthor(asset: EditorAssetSnapshot) { + const value = (asset as Record)['authorName']; + return typeof value === 'string' && value.trim() ? value.trim() : '-'; +} + +function matchesShowcaseTab(asset: EditorAssetSnapshot, tab: ShowcaseTabId) { + const haystack = [ + asset.label, + asset.prompt, + asset.actualPrompt, + asset.model, + asset.provider, + asset.sourceType, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + if (tab === 'packs') { + return true; + } + if (tab === 'characters') { + return /角色|character/u.test(haystack); + } + if (tab === 'ui') { + return /\bui\b|界面|图标|icon/u.test(haystack); + } + if (tab === 'music') { + return /音乐|音效|audio|music|sound/u.test(haystack); + } + if (tab === 'marketing') { + return /美宣|宣传|poster|video|视频/u.test(haystack); + } + if (tab === 'effects') { + return /特效|effect|vfx/u.test(haystack); + } + if (tab === 'scenes') { + return /场景|scene|background/u.test(haystack); + } + if (tab === 'games') { + return /游戏|game/u.test(haystack); + } + return false; +} + +function toLibraryShowcaseItem(asset: EditorAssetSnapshot): ShowcaseAssetItem { + return { + id: `asset:${asset.assetId}`, + label: asset.label, + imageSrc: asset.imageSrc, + width: asset.width, + height: asset.height, + prompt: asset.prompt?.trim() || asset.actualPrompt?.trim() || '-', + author: resolveAssetAuthor(asset), + cost: formatAssetCost(asset), + }; +} + +function matchesPublicGalleryTab( + entry: PlatformPublicGalleryCard, + tab: ShowcaseTabId, +) { + if (tab === 'packs' || tab === 'games') { + return true; + } + + const themeTags = 'themeTags' in entry ? entry.themeTags : []; + const haystack = [ + entry.worldName, + entry.subtitle, + entry.summaryText, + entry.sourceType, + ...themeTags, + ] + .join(' ') + .toLowerCase(); + + if (tab === 'characters') { + return /角色|character|rpg|visual/u.test(haystack); + } + if (tab === 'ui') { + return /\bui\b|界面|图标|icon|puzzle/u.test(haystack); + } + if (tab === 'music') { + return /音乐|音效|audio|music|sound/u.test(haystack); + } + if (tab === 'marketing') { + return /美宣|宣传|poster|video|视觉|visual/u.test(haystack); + } + if (tab === 'effects') { + return /特效|effect|vfx/u.test(haystack); + } + if (tab === 'scenes') { + return /场景|scene|background|world|rpg/u.test(haystack); + } + return false; +} + +function toPublicGalleryShowcaseItems( + entries: PlatformPublicGalleryCard[], + tab: ShowcaseTabId, +): ShowcaseAssetItem[] { + const usedImageSrcs = new Set(); + const items: ShowcaseAssetItem[] = []; + + entries + .filter((entry) => matchesPublicGalleryTab(entry, tab)) + .forEach((entry) => { + const coverImageSrc = resolvePlatformWorldCoverImage(entry).trim(); + const imageCandidates = [ + ...resolvePlatformWorldCoverSlides(entry).map((slide) => + slide.imageSrc.trim(), + ), + coverImageSrc, + ].filter(Boolean); + + imageCandidates.forEach((imageSrc, index) => { + if (usedImageSrcs.has(imageSrc)) { + return; + } + usedImageSrcs.add(imageSrc); + items.push({ + id: `public:${entry.sourceType}:${entry.profileId}:${index}`, + label: index === 0 ? entry.worldName : `${entry.worldName} ${index + 1}`, + imageSrc, + width: index === 0 ? 720 : 640, + height: index === 0 ? 480 : 720, + prompt: entry.summaryText || entry.subtitle || '-', + author: resolvePlatformWorkAuthorDisplayName(entry), + cost: '-', + }); + }); + }); + + return items.slice(0, 36); +} + +export function CreationLandingView({ + onOpenProject, + onOpenProjects, + onOpenCommunity, + publicGalleryEntries = [], +}: CreationLandingViewProps) { + const authUi = useAuthUi(); + const isAuthenticated = Boolean(authUi?.user); + const [recentProjects, setRecentProjects] = useState( + [], + ); + const [isLoadingProjects, setIsLoadingProjects] = useState(false); + const [isCreatingProject, setIsCreatingProject] = useState(false); + const [projectError, setProjectError] = useState(null); + const [assetLibrary, setAssetLibrary] = + useState(null); + const [isLoadingAssets, setIsLoadingAssets] = useState(false); + const [assetError, setAssetError] = useState(null); + const [activeShowcaseTab, setActiveShowcaseTab] = + useState('packs'); + + const refreshRecentProjects = useCallback(() => { + if (!isAuthenticated) { + setRecentProjects([]); + setProjectError(null); + return; + } + setIsLoadingProjects(true); + setProjectError(null); + listEditorProjects() + .then((projects) => { + setRecentProjects( + [...projects] + .sort( + (left, right) => + new Date(right.updatedAt).getTime() - + new Date(left.updatedAt).getTime(), + ) + .slice(0, 4), + ); + }) + .catch((error: unknown) => { + if (isUnauthorizedError(error)) { + authUi?.openLoginModal(refreshRecentProjects); + return; + } + setProjectError( + error instanceof Error ? error.message : '读取最近项目失败', + ); + }) + .finally(() => setIsLoadingProjects(false)); + }, [authUi, isAuthenticated]); + + const refreshAssetLibrary = useCallback(() => { + if (!isAuthenticated) { + setAssetLibrary(null); + setAssetError(null); + return; + } + setIsLoadingAssets(true); + setAssetError(null); + loadEditorAssetLibrary() + .then(setAssetLibrary) + .catch((error: unknown) => { + if (isUnauthorizedError(error)) { + return; + } + setAssetError( + error instanceof Error ? error.message : '读取素材展示失败', + ); + }) + .finally(() => setIsLoadingAssets(false)); + }, [isAuthenticated]); + + useEffect(() => { + refreshRecentProjects(); + }, [refreshRecentProjects]); + + useEffect(() => { + refreshAssetLibrary(); + }, [refreshAssetLibrary]); + + const createProject = useCallback(() => { + if (authUi && !authUi.user) { + authUi.openLoginModal(createProject); + return; + } + setIsCreatingProject(true); + setProjectError(null); + createEditorProject() + .then((project) => onOpenProject(project.projectId)) + .catch((error: unknown) => { + if (isUnauthorizedError(error)) { + authUi?.openLoginModal(createProject); + return; + } + setProjectError( + error instanceof Error ? error.message : '创建项目失败', + ); + }) + .finally(() => setIsCreatingProject(false)); + }, [authUi, onOpenProject]); + + const showcaseItems = useMemo(() => { + const libraryItems = (assetLibrary?.assets ?? []) + .filter((asset) => matchesShowcaseTab(asset, activeShowcaseTab)) + .map(toLibraryShowcaseItem); + if (libraryItems.length > 0) { + return libraryItems.slice(0, 36); + } + return toPublicGalleryShowcaseItems(publicGalleryEntries, activeShowcaseTab); + }, [activeShowcaseTab, assetLibrary, publicGalleryEntries]); + + const isShowcaseLoading = isAuthenticated && isLoadingAssets; + const showcaseEmptyText = + publicGalleryEntries.length > 0 ? '暂无匹配素材' : '暂无素材'; + + const renderAssetShowcase = () => { + if (assetError) { + return ( + + {assetError} + + ); + } + if (isShowcaseLoading) { + return ( + + 正在读取素材 + + ); + } + if (showcaseItems.length === 0) { + return ( + + {showcaseEmptyText} + + ); + } + + return ( +
+ {showcaseItems.map((item) => ( +
+
+ +
+
+

{item.label}

+

{item.prompt}

+
+
+
作者
+
{item.author}
+
+
+
成本
+
{item.cost}
+
+
+
+
+ ))} +
+ ); + }; + + return ( +
+
+
+ 陶泥儿创作工具 +

陶泥儿 - 开启全民精品游戏创作

+

登录即送100泥点,可以免费制作50个素材

+
+ + + 开始创作 + + {onOpenCommunity ? ( + + 玩家社区 + + ) : null} +
+
+ +
+ +
+
+
+

创作工具

+
+
+
+ {CREATION_FEATURES.map((feature) => { + const Icon = feature.icon; + return ( +
+ + + +

{feature.title}

+

{feature.description}

+
+ ); + })} +
+
+ + {isAuthenticated ? ( +
+
+
+

最近项目

+
+ +
+ {projectError ? ( + + {projectError} + + ) : null} +
+ + {isLoadingProjects ? ( + + 正在读取项目 + + ) : ( + recentProjects.map((project) => ( + + )) + )} +
+
+ ) : null} + +
+
+
+

陶泥儿精选

+
+
+
+ {SHOWCASE_TABS.map((tab) => ( + + ))} +
+ {renderAssetShowcase()} +
+
+ ); +} + +export default CreationLandingView; diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 4e433785f..e52791288 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -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(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} ); - const creationStartContent = ( -
- - {renderCreationHubContent('start-only', '正在加载创作大厅...')} -
+ const creationStartContent = renderCreationHubContent( + 'start-only', + '正在加载创作大厅...', ); const draftHubContent = renderCreationHubContent( 'works-only', '正在加载草稿列表...', ); + const creationLandingContent = ( + }> + + + ); + const handlePlatformHomeTabChange = useCallback( + (tab: PlatformHomeTab) => { + platformBootstrap.setPlatformTab(tab); + if (selectionStage === 'creation-home' && tab !== 'create') { + setSelectionStage('platform'); + pushAppHistoryPath('/'); + } + }, + [platformBootstrap, selectionStage, setSelectionStage], + ); + const platformHomeView = ( + { + 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({ > }> { - setSelectionStage('image-editor'); - pushAppHistoryPath( - `/editor/canvas?projectid=${encodeURIComponent(projectId)}`, - ); - }} + onOpenProject={openEditorProject} /> )} + {selectionStage === 'creation-home' && ( + + {platformHomeView} + + )} {selectionStage === 'platform' && ( - { - 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} )} @@ -17713,6 +17766,22 @@ export function PlatformEntryFlowShellImpl({ overlayClassName={`platform-theme ${platformThemeClass} !items-center`} panelClassName="platform-remap-surface rounded-[1.5rem]" /> + {isCreationCommunityOpen ? ( + setIsCreationCommunityOpen(false)} + onCopyInvite={() => undefined} + onRedeemCodeChange={() => undefined} + onSubmitRedeemCode={() => undefined} + /> + ) : null} void; + setSelectionStage: ( + stage: SelectionStage, + options?: { path?: string }, + ) => void; initialPublicWorkCode?: string | null; hasSavedGame: boolean; savedSnapshot: HydratedSavedGameSnapshot | null; diff --git a/src/components/platform-entry/platformSelectionStageModel.ts b/src/components/platform-entry/platformSelectionStageModel.ts index 9f4bfc922..49ed1ad84 100644 --- a/src/components/platform-entry/platformSelectionStageModel.ts +++ b/src/components/platform-entry/platformSelectionStageModel.ts @@ -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, diff --git a/src/components/project/ProjectCanvasCover.tsx b/src/components/project/ProjectCanvasCover.tsx new file mode 100644 index 000000000..e598f3562 --- /dev/null +++ b/src/components/project/ProjectCanvasCover.tsx @@ -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) { + return classNames.filter(Boolean).join(' '); +} + +export function resolveProjectCanvasCoverLayers( + project: EditorProjectSnapshot, +): ProjectCanvasCoverLayer[] { + const resourcesById = new Map( + 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 ( + + ); + } + + 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 ( + + ); +} diff --git a/src/components/project/ProjectGalleryView.tsx b/src/components/project/ProjectGalleryView.tsx index 7a9266a59..b54d4c902 100644 --- a/src/components/project/ProjectGalleryView.tsx +++ b/src/components/project/ProjectGalleryView.tsx @@ -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( - 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 ; - } - - 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 ( - - ); -} - function formatProjectUpdatedAt(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) { diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 226fc2888..2947a75bd 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -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(); - render(); - - 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(); + + 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(); + + 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(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 587d77333..d98027908 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -782,6 +782,8 @@ function ProfileHomeViewHarness({ profileGenerationQueueStatus = null, profilePlayStats = null, isProfilePlayStatsOpen = false, + initialProfilePopupPanel = null, + onInitialProfilePopupPanelConsumed, }: { onRechargeSuccess?: () => void | Promise; profileDashboardOverrides?: Partial< @@ -793,6 +795,8 @@ function ProfileHomeViewHarness({ profileGenerationQueueStatus?: RpgEntryHomeViewProps['profileGenerationQueueStatus']; profilePlayStats?: ProfilePlayStatsResponse | null; isProfilePlayStatsOpen?: boolean; + initialProfilePopupPanel?: RpgEntryHomeViewProps['initialProfilePopupPanel']; + onInitialProfilePopupPanelConsumed?: RpgEntryHomeViewProps['onInitialProfilePopupPanelConsumed']; }) { return ( ); @@ -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( > @@ -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( + , + ); + + expect(await screen.findByRole('dialog', { name: '玩家社区' })).toBeTruthy(); + expect(onConsumed).toHaveBeenCalledTimes(1); + + rerender( + , + ); + rerender( + , + ); + + 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:
草稿内容
, @@ -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', () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index cd7aaba06..06408b9f5 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -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; 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( + 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( + const visibleMobileTabs = useMemo( () => isAuthenticated - ? ['home', 'category', 'create', 'saves', 'profile'] - : ['home', 'create', 'category'], + ? ['home', 'category', 'profile'] + : ['home', 'category', 'profile'], + [isAuthenticated], + ); + const visibleDesktopNavTabs = useMemo( + () => + 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> = + 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; 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')} /> - ; - 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({
- {visibleTabs.map((tab) => ( + {visibleMobileTabs.map((tab) => (