调整创作主页与项目入口

新增陶泥儿创作主页和用户素材瀑布流

新增项目入口并复用画布中心封面

保留创作主页左侧导航和上方栏

修复玩家社区在创作页原地弹窗

更新路由、测试和项目记忆文档
This commit is contained in:
2026-06-19 10:47:31 +08:00
parent 160c96a014
commit 8857e36e1a
20 changed files with 2362 additions and 313 deletions
+2
View File
@@ -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`
+66
View File
@@ -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
View File
@@ -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>
);
}
+1 -109
View File
@@ -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', () => {
+95 -37
View File
@@ -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
View File
@@ -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;
+15
View File
@@ -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',
+1
View File
@@ -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'],