diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 7447fd68..03abcd1e 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,70 @@ --- +## 2026-05-24 创作 Tab banner 轮播只展示主题赛 + +- 背景:创作 Tab banner 曾经把后端入口配置里的默认活动横幅和两个主题赛一起轮播,导致首屏出现 58000 奖池活动卡,和当前只强调拼图 / 抓大鹅主题赛的产品口径不一致。 +- 决策:创作 Tab 首屏 banner 轮播只展示 `拼图主题创作赛` 与 `抓大鹅主题创作赛` 两张主题卡;后端返回的 `eventBanner` 仅作为开始时间、结束时间等公共字段来源,不再直接作为一张轮播卡渲染。banner 底部顺序固定为开始 / 结束时间条在上、分页点在下,且二者都在封面内容底部。 +- 影响范围:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`src/components/custom-world-home/CustomWorldCreationHub.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:`CustomWorldCreationHub.test.tsx` 应断言默认活动标题不出现在 start-only 创作页,且 `creation-event-banner__timebar` 位于 `creation-event-banner__pager` 前。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-24 创作 Tab 首屏字号收敛到普通 UI 档位 + +- 背景:创作 Tab 的右上角泥点胶囊、赛事 banner、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明曾经偏向展示级字号,和其它页面的常规 UI 字号不一致。 +- 决策:创作首屏优先使用 `11px` 到 `14px` 的普通 UI 字号档位;仅在数字本体或强调值上做局部加粗,不使用 `text-lg`、`text-xl` 或更大的展示级字号来撑首屏。 +- 影响范围:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、创作 Tab 相关测试、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:`CustomWorldCreationHub.test.tsx` 的字号快照测试和本地浏览器检查都应确认右上组件、banner、分类 Tab、模板卡标题 / 副标题 / 消耗说明没有回到大字号。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-24 草稿页未读点统一使用暖棕色 + +- 背景:草稿页底部 Tab 和作品架的未读点之前仍用固定红色和红色 glow,和平台暖白/陶土橙体系不一致,也会让草稿未读态显得像危险告警。 +- 决策:`platform-nav-unread-dot` 与 `creation-work-card__unread-dot` 统一改用平台暖棕色 token,并把 glow 也切到暖棕色,不再直接写红色 literal 或红色阴影。 +- 影响范围:`src/index.css`、草稿页底部导航、草稿页作品架、相关 CSS 回归测试。 +- 验证方式:`src/index.test.ts` 需要断言两个 unread dot block 都不再包含 `#b64a35` 或 `rgba(239, 68, 68, ...)`,并且仍引用 `--platform-unread-dot-fill` / `--platform-unread-dot-glow`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-24 创作 Tab 模板卡点击直达已有玩法入口表单 + +- 背景:创作 Tab 首屏需要对齐参考图,展示赛事 banner、玩法模板分类和两列模板卡;点击模板卡时,空白入口页会让用户多走一层,占位感也会让人误以为功能未接好。 +- 决策:`/creation/` 直达对应玩法已有的入口创作表单 stage,不再保留空白创作入口页。RPG、拼图、抓大鹅、汪汪声浪、敲木鱼、视觉小说、宝贝识物等都直接进入既有工作台,继续承接草稿恢复和后续编排。创作 Tab 首屏 banner 按参考图拆成右上泥点胶囊、主体宣传封面图文、底部开始/结束时间条和分页点;玩法模板卡使用独立 `creation-template-card` 白底信息区,不复用暗图蒙版 `platform-creation-reference-card`,确保标题、描述和“预计消耗 10-20 泥点”可见。 +- 影响范围:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、创作大厅交互测试与平台入口文档。 +- 验证方式:`npm test -- src/routing/appPageRoutes.test.ts`、`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t \"create tab opens match3d entry form from the template card|create tab opens puzzle entry form from the template card|create tab opens bark battle entry form from the template card\"`、`npm run typecheck`、`npm run check:encoding` 通过;创作卡片点击后应进入对应工作台,不再出现空白入口页。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-24 创作 Tab 顶栏余额与赛事奖池分离展示 + +- 背景:创作页顶部、banner 奖池和玩法卡消耗口径曾经混在一起,容易把活动奖池误认成账号余额,也让横向空间被外部边框和过大的卡片高度挤占。 +- 决策:移动端创作 Tab 顶栏与 `陶泥儿` 品牌同一行只显示真实账户泥点数,数据直接取 `profileDashboard.walletBalance`;banner 内只展示赛事奖池,新增拼图主题创作赛和抓大鹅主题创作赛,两个主题奖池各 `1000` 泥点数;玩法卡封面右下角固定展示 `10-20泥点数`,列表外框取消,卡片高度和横向间距一起收紧。 +- 影响范围:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、创作页相关测试和玩法链路文档。 +- 验证方式:移动端浏览器检查应看到创作顶栏余额、卡内分页点、内嵌横向 banner 和更紧凑的玩法卡;`CustomWorldCreationHub.test.tsx` 与 `RpgEntryHomeView.recharge.test.tsx` 的定向断言应保持通过。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-24 发现 / 创作 / 草稿三页去掉外层全局卡片壳 + +- 背景:发现 Tab、创作 Tab 和草稿 Tab 的页面根区原本都套着 `platform-page-stage`,导致全局内容卡片壳把横向空间吃掉,也让创作页和草稿页与发现页的频道标签 / 列表卡风格拉不开。 +- 决策:这三页的根内容区不再使用 `platform-page-stage` 作为外层全局卡片壳,只保留 `platform-remap-surface` 作为主题与输入框样式钩子;草稿页顶部 `全部 / 草稿 / 已发布` 切换复用发现页的 `platform-mobile-home-channel` 频道标签样式。 +- 影响范围:`src/components/custom-world-home/CustomWorldCreationHub.tsx`、`src/components/custom-world-home/CustomWorldWorkTabs.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/index.css`、相关创作 / 发现 / 草稿测试。 +- 验证方式:创作 Hub 和发现页定向测试通过;浏览器里这三页的根区不再出现 `platform-page-stage`,但仍保留 `platform-remap-surface` 命中。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-23 拼图生成页按后端真实进度推进阶段 + +- 背景:拼图生成页原先会按本地耗时自动推进步骤,容易在后端真实生成尚未完成时跳到后续阶段,导致页面状态和会话进度脱节。 +- 决策:拼图生成页的跨步骤推进只认后端会话 `progressPercent` 的真实里程碑,当前步骤内部再用本地耗时假进度平滑展示;总进度初始必须为 `0%`,之后按 `0-88`、`88-94`、`94-96`、`96-98` 的真实里程碑区间平滑推进。只要当前步骤生成内容未完成,就必须停留在当前步骤。页面只展示当前步骤标题和进度,不展示步骤详细描述。`生成拼图首图` 单独按 4 分钟估算,完整 AI 重绘路径约 448 秒;上传图且关闭 AI 重绘路径跳过首图生成,仍约 208 秒。 +- 影响范围:`src/services/miniGameDraftGenerationProgress.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/CustomWorldGenerationView.tsx`、拼图生成页相关测试与玩法链路文档。 +- 验证方式:拼图生成页恢复、轮询和测试都应以 `puzzleProgressPercent` 驱动阶段推进;`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts src/components/CustomWorldGenerationView.test.tsx`、`npm run typecheck`、`npm run check:encoding` 通过。 +- 关联文档:`docs/【玩法创作】拼图生成页进度口径-2026-05-23.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-23 所有玩法生成页统一圆环主视觉 + +- 背景:多个玩法生成页分别展示横向总进度条、步骤列表或三槽位列表,和最新参考图里的陶泥儿圆环等待态不一致,也让移动端信息密度偏高。 +- 决策:`media/create_bg_video.mp4` 作为固定全屏背景层循环静音播放,主进度统一改为居中大圆弧,正下方保留 90 度留空;生成页顶部只保留返回入口和状态胶囊,圆弧左右悬浮半透明“预计等待 / 已耗时”时间卡,下方保留半透明当前步骤单卡和当前作品信息卡。生成页不再列表展示每个步骤块,只显示当前步骤名称和当前步骤进度;圆弧和当前步骤卡不再被独立大面板嵌套出双层卡片感。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted`。顶部返回使用 `text-xs-sm`,右上状态使用 `11px-12px`,时间卡标签使用 `9px-10px`,时间值只展示纯时间,不重复拼“预计还需 / 已耗时”前缀;当前步骤标签使用 `10px-11px`,步骤名使用 `14px-15px`,步骤状态使用 `11px-12px`,底部玩法信息标题固定使用 `13px`,避免生成页 UI 字号大于其它页面。`CustomWorldGenerationView` 承接 RPG、拼图、抓大鹅、大鱼吃小鱼、方洞、跳一跳、敲木鱼、宝贝识物、视觉小说等共用生成页;汪汪声浪独立 `BarkBattleGeneratingView` 也对齐同一垂直布局。 +- 影响范围:`src/components/GenerationProgressHero.tsx`、`src/components/CustomWorldGenerationView.tsx`、`src/components/bark-battle-creation/BarkBattleGeneratingView.tsx` 和玩法链路文档。 +- 验证方式:执行 `npm run test -- src/components/CustomWorldGenerationView.test.tsx src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx`,并用桌面 / 移动端视口检查生成页只出现圆环和当前步骤卡。 +- 关联文档:`docs/【玩法创作】生成页圆环布局口径-2026-05-23.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-22 敲木鱼图片创作采用双图 image2 链路 - 背景:敲木鱼自定义题材只生成中央敲击物时,运行态缺少与新主题匹配的竖屏背景;若直接让背景 prompt 自由发挥,又容易把敲击物或木槌画进背景里。 @@ -238,10 +302,10 @@ - 验证方式:创作 Tab 中点击汪汪声浪后直接看到内嵌表单,不应再出现单独配置页;发布进入 runtime 后退出应回到创作页的汪汪声浪模板。 - 关联文档:`docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md`。 -## 2026-05-14 拼图与抓大鹅生成页移动端收口为等待与计时双栏 +## 2026-05-14 拼图与抓大鹅生成页移动端收口为等待与计时双栏(历史) - 背景:拼图与抓大鹅的草稿生成页在移动端同时展示“当前批次”“预计等待”“计时”时,模型执行视角过重,信息也显得散。 -- 决策:这两类轻量玩法的生成页隐藏“当前批次”模块,只保留“预计等待”和“计时”并排展示;生成步骤进入页面时按顺序从左侧滑入,强化推进感。 +- 决策:这两类轻量玩法的生成页隐藏“当前批次”模块,只保留“预计等待”和“计时”并排展示;生成步骤进入页面时按顺序从左侧滑入,强化推进感。2026-05-23 起已被“所有玩法生成页统一圆环主视觉”取代,步骤列表不再作为当前口径。 - 影响范围:`CustomWorldGenerationView`、拼图与抓大鹅创作入口调用处、移动端生成页体验文档。 - 验证方式:拼图与抓大鹅生成页在手机竖屏下只显示等待与计时双栏,步骤卡按顺序滑入;其它未传入隐藏参数的生成页继续保留原批次模块。 - 关联文档:`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。 @@ -342,7 +406,7 @@ - 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。 - 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。 - 2026-05-18 追加:为缩短首版草稿等待,`compile_puzzle_draft` 在首关命名和 `uiBackgroundPrompt` 稳定后并行启动首关关卡图生成与 UI 背景生成;上传主图且关闭 AI 重绘时,并行执行上传图持久化与 UI 背景生成。生成页预计完成时间按 5 分钟展示。 -- 2026-05-21 追加:拼图结果页独立“素材配置”Tab 已移除,UI spritesheet 与关卡纯背景收口到每关图片生成资产包。每次 `gpt-image-2` 预计 90 秒;草稿完整 AI 重绘路径约 298 秒,上传图且关闭 AI 重绘路径跳过首图生成约 208 秒。结果页关卡详情继续复用 `CreativeImageInputPanel`,本次上传/历史选择图优先成为主图卡片,正式图只作为无新参考图时的预览;仅有正式图时仍允许在画面描述框上传多张参考图。 +- 2026-05-21 追加:拼图结果页独立“素材配置”Tab 已移除,UI spritesheet 与关卡纯背景收口到每关图片生成资产包。每次 `gpt-image-2` 预计 90 秒;2026-05-24 起草稿首图生成单独按 4 分钟展示,草稿完整 AI 重绘路径约 448 秒,上传图且关闭 AI 重绘路径跳过首图生成约 208 秒。结果页关卡详情继续复用 `CreativeImageInputPanel`,本次上传/历史选择图优先成为主图卡片,正式图只作为无新参考图时的预览;仅有正式图时仍允许在画面描述框上传多张参考图。 - 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。 - 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 25c1b2df..2c209825 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -15,6 +15,46 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 创作卡片点击要直达已有入口表单,别再保留空白入口页 + +- 现象:创作 Tab 模板卡点击后如果仍然停留在创作大厅,或者先进入“X 创作入口”这种空白页,就会让用户多走一层,还可能被错误的 stage 白名单拉回平台。 +- 原因:`/creation/` 一度被接成空白创作入口页,导致 `SelectionStage`、`appPageRoutes` 和卡片点击分流被旧占位 stage 污染。 +- 处理:把 `/creation/` 重新指向已有入口表单 stage,例如 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`;平台壳层和测试同步清理空白入口页相关 helper。 +- 验证:点拼图 / 抓大鹅 / 汪汪声浪卡片后,应看到各自既有工作台内容,例如测试中的 `拼图工作区:missing-session`、`抓大鹅工作区:missing-session` 或 `汪汪声浪配置表单`,并且不再出现“X 创作入口”空白页。 +- 关联:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。 + +## 草稿页未读点不要继续用红色 literal + +- 现象:草稿页底部 Tab 和作品架的未读点视觉上仍像红点,或 glow 仍带红色阴影,和平台暖棕体系不一致。 +- 原因:`platform-nav-unread-dot`、`creation-work-card__unread-dot` 直接写了 `#b64a35` 和 `rgba(239, 68, 68, ...)`,没有收口到统一 token。 +- 处理:未读点颜色统一走 `--platform-unread-dot-fill` / `--platform-unread-dot-glow`,桌面/移动端共用同一口径;不要把红色 literal 再写回样式。 +- 验证:`src/index.test.ts` 断言两个 unread dot block 都只引用未读点 token,不再出现红色 literal 或红色 glow。 +- 关联:`src/index.css`、`src/index.test.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 创作 Tab 模板卡不要复用暗图蒙版参考卡样式 + +- 现象:创作 Tab 两列玩法卡上图能看到,但标题、描述或预计消耗泥点在白底信息区里看不见,或只剩泥点小图标。 +- 原因:旧 `platform-creation-reference-card` 是给暗图蒙版卡用的全局样式,会把卡片及全部子元素强制成白色文字;参考图要求的是“上图 + 下方白底信息区”,继续复用旧类会让白底上的文字消失。 +- 处理:创作 Tab 首屏模板卡使用独立 `creation-template-card`、`creation-template-card__body`、`creation-template-card__title`、`creation-template-card__subtitle` 和 `creation-template-card__cost` 结构,不挂 `platform-creation-reference-card`;旧弹层如果仍是暗图蒙版卡,可以继续保留旧类。 +- 验证:浏览器创作 Tab 中每张卡都应显示标题、描述和“预计消耗 10-20 泥点”;`npm test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx -t "creation start card renders reference-aligned banner and template metadata"` 应通过。 +- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`src/index.css`、`src/components/custom-world-home/CustomWorldCreationHub.test.tsx`。 + +## 创作首屏开放态卡片不要再显示左上状态标签 + +- 现象:创作 Tab 的开放态玩法卡左上角会重复显示“可创建”或“可创作”,视觉上比其它状态更吵,还会和封面图抢注意力。 +- 原因:卡片渲染层默认把 `badge` 当成所有状态都要展示的左上角标签,没有区分开放态与非开放态。 +- 处理:开放态卡片不渲染左上标签,仅保留标题、描述和右下角消耗信息;`敬请期待`、`即将开放` 等非开放态标签继续保留。 +- 验证:创作首屏 HTML 中不应包含 `可创建` / `可创作`,但仍应包含 `即将开放` 等非开放态状态。 +- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 发现 / 创作 / 草稿页不要把根内容区再包成全局卡片壳 + +- 现象:发现页、创作页或草稿页根区一旦套回 `platform-page-stage`,页面边缘会立刻变得更厚,频道标签、列表和模板卡的横向空间都被挤窄,看起来像回到了旧全局卡片壳。 +- 原因:`platform-page-stage` 本身是全局内容卡片壳,适合推荐页、我的页和其它页面,但这三页已经有自己的视觉结构;草稿页顶部筛选若继续用旧 `platform-tab`,还会和发现页频道标签不一致。 +- 处理:这三页的根内容区只保留 `platform-remap-surface`,不要再加 `platform-page-stage`;草稿页顶部筛选复用发现页的 `platform-mobile-home-channel` 与 `platform-mobile-home-channel--active`。 +- 验证:浏览器里这三页的根区应仍保留 `platform-remap-surface`,但不再出现 `platform-page-stage`;草稿页顶部筛选样式应和发现页频道标签一致。 +- 关联:`src/components/custom-world-home/CustomWorldCreationHub.tsx`、`src/components/custom-world-home/CustomWorldWorkTabs.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/index.css`。 + ## SpacetimeDB 入口迁移 helper 合并时不要只保留调用 - 现象:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 或 Jenkins `Genarrative-Stdb-Module-Build` 报 `E0425 cannot find function migrate_rpg_entry_from_old_hidden_default in this scope`,位置在 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 的默认入口配置播种流程。 @@ -1243,6 +1283,14 @@ - 验证:Jenkins 目标机日志不再出现 `unexpected argument '-y'`、`unknown command name for spacetimedb-update multicall binary`,后续应继续检查 `bin/current/spacetimedb-cli` 和 `bin/current/spacetimedb-standalone` 是否生成。 - 关联:`scripts/prepare-server-provision-tools.sh`、`jenkins/Jenkinsfile.production-server-provision`。 +## 清库重建后先查 schema 兼容再重启 + +- 现象:`npm run dev -- --clear-database --no-interactive` 之后,api-server 仍在 `GET /api/creation-entry/config` 或订阅恢复阶段报 `No such procedure` / schema guard 失败。 +- 原因:本地重建只会重发当前 `spacetime-module`,不会自动修正旧迁移 JSON 的字段兼容;如果 `migration.rs` 没把新字段补成 `None` / 默认值,清库后重建仍会卡在 schema 同步。 +- 处理:先让 `server-rs/crates/spacetime-module/src/migration.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 和生成绑定对齐,再执行清库重建。 +- 验证:`npm run check:spacetime-schema` 先通过,再重启 `npm run dev -- --clear-database --no-interactive`,最后检查 `/v1/ping`、`/healthz` 和 `GET /api/creation-entry/config`。 +- 关联:`server-rs/crates/spacetime-module/src/migration.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`scripts/dev.mjs`。 + ## QQ 浏览器发现页推荐封面全不显示先查 aspect-ratio 兜底 - 现象:发现页的“推荐”子频道作品卡标题、作者和数据正常,但所有封面图不显示,常见于 QQ 浏览器 / X5 等旧移动内核。 @@ -1251,13 +1299,13 @@ - 验证:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`。 - 关联:`src/index.css`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/rpgEntryWorldPresentation.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -## 生成中草稿刷新后不要只恢复作品架遮罩 +## 生成中草稿刷新后不要复用旧 updatedAt 当展示起点 -- 现象:拼图或抓大鹅草稿生成中刷新网页后,作品架卡片能显示等待遮罩,但点击卡片会走普通草稿恢复,可能进入空白结果页或未完成工作区。 -- 原因:前端只把内存 notice 当作“生成中点击恢复”的判断条件,没有把后端摘要里的 `generationStatus=generating` 纳入同一路径。 -- 处理:打开草稿时把持久化 `generationStatus=generating` 等同于生成中 notice,恢复对应玩法生成进度页;恢复计时使用作品摘要 `updatedAt` 推导 `startedAtMs`。 -- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`。 -- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 现象:拼图或抓大鹅草稿生成中刷新网页后,作品架卡片能显示等待遮罩,但进入生成页时总进度首帧直接跳到 80%+,看起来像已经跑了一大半。 +- 原因:前端只把持久化 `generationStatus=generating` 当作恢复生成页的条件,但恢复展示时仍沿用了作品摘要 `updatedAt` 作为伪 `startedAtMs`;同时拼图总进度又把后端 `progressPercent` 直接当作 floor,导致 `86%` 之类未到首个里程碑的会话一进页就抬到 80%+。 +- 处理:恢复生成中的草稿时,展示起点改用“进入生成页的当前时间”;`updatedAt` 只保留给作品架排序和摘要,不再参与生成页假进度起算。拼图总进度还要忽略 `88` 以下的后端进度 floor,拼图保留后端里程碑推进,抓大鹅等非拼图玩法则从 `0%` 平滑起步,避免刚进页就看到 4% / 88% / 80%+。 +- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`、`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts -t "match3d draft generation starts total progress from zero"`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`src/services/miniGameDraftGenerationProgress.ts`、`docs/【玩法创作】拼图生成页进度口径-2026-05-23.md`。 ## 汪汪声浪草稿试玩不要写正式 run @@ -1315,6 +1363,18 @@ - 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;契约测试应断言前端 JSON 自带的 `hitObjectAsset` 会被忽略,spacetime-client 定向测试应断言缺少服务端注入的真实 `hitObjectAsset` 时不能编译;浏览器 Network 中 generated 图片应先换签,签名 URL 指向已存在对象。 - 关联:`server-rs/crates/api-server/src/wooden_fish.rs`、`server-rs/crates/spacetime-client/src/wooden_fish.rs`、`src/components/ResolvedAssetImage.tsx`、`src/services/assetReadUrlService.ts`。 +## 生成页背景视频要固定全屏并显式触发播放 + +- 现象:生成页明明带了 `media/create_bg_video.mp4`,但移动端或某些内核里只看到静态首帧,或视频层跟着局部容器滚动,被白色面板压住后看起来像没加载。 +- 原因:仅靠 `autoPlay/loop/muted/playsInline` 并不稳定;视频如果仍挂在局部容器里,还会被页面面板和遮罩吞掉。某些浏览器初始化后也会停在 `paused=true`。 +- 处理:背景视频必须放到 `fixed inset-0` 的全屏底层容器里,外层页面用 `isolate` / 透明底控制叠层;挂载后显式尝试 `play()`,并在 `loadeddata`、`canplay` 和页面聚焦时再次触发,避免只停首帧。 +- 验证:移动端视口检查视频 `rect` 应覆盖整个视口,`paused` 应最终变为 `false`,`currentTime` 应持续前进。 +- 关联:`src/components/GenerationProgressHero.tsx`、`docs/【玩法创作】生成页圆环布局口径-2026-05-23.md`。 + +2026-05-24 补充:`GenerationPageBackdrop` 不要通过 portal 挂到 `document.body`。body 级 fixed 背景会逃离生成页自己的 stacking context,即使业务内容有局部 `z-10`,真实浏览器里也可能把整页 UI 压住。背景视频应作为生成页根容器子节点保留 `fixed inset-0 z-0`,生成页内容保持 `relative z-10`;相关测试应同时断言背景容器低层级、生成页根容器高层级,以及视频节点仍在生成页 DOM 内部。视觉调整时还要记住:空心圆环的中心块要抽掉,时间卡与总进度标题都应缩小,不要让生成页再回到“纯色底 + 大字号说明卡”的状态。顶部返回和右上状态也不能沿用 `text-lg` / `sm:text-2xl` 这类展示级字号;当前步骤名、步骤状态和底部玩法信息标题要维持普通 UI 字号档位,优先保持 `text-xs` 到 `text-sm` 区间。 + +2026-05-24 补充:生成页“预计等待 / 已耗时”卡片本身已经有标签,传给 `GenerationProgressHero` 的值只能是纯时间,例如 `4 分钟`、`1 分 15 秒`,不要再拼接“预计还需”或“已耗时”;两张时间卡也要和当前步骤卡一样保持半透明。拼图总进度初始帧必须允许显示 `0%`,不要再用 `Math.max(1, nextProgress)` 之类的保护把启动态抬到 `1%`。 + ## `dev:spacetime` 启动后 3101 又断开先查 publish 是否被 spacetime.json 干扰 - 现象:浏览器报 `Failed to initiate WebSocket connection`,目标为 `ws://127.0.0.1:3101/v1/database//subscribe`,端口检查发现 `3101` 没有长期监听;手动运行 `npm run dev:spacetime` 可看到 standalone 短暂启动后退出,发布阶段报 `No database target matches ''`。 diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 43bc2e03..00a2a4df 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -157,6 +157,9 @@ export interface AdminCreationEntryTypeConfigPayload { visible: boolean; open: boolean; sortOrder: number; + categoryId: string; + categoryLabel: string; + categorySortOrder: number; updatedAtMicros: number; } @@ -169,6 +172,9 @@ export interface AdminUpsertCreationEntryTypeConfigRequest { visible: boolean; open: boolean; sortOrder: number; + categoryId: string; + categoryLabel: string; + categorySortOrder: number; } export interface AdminUpsertProfileRedeemCodeRequest { diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx index 2b1b2995..fb817c65 100644 --- a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx @@ -27,6 +27,9 @@ export function AdminCreationEntrySwitchPage({ const [visible, setVisible] = useState(true); const [open, setOpen] = useState(true); const [sortOrder, setSortOrder] = useState('30'); + const [categoryId, setCategoryId] = useState('recent'); + const [categoryLabel, setCategoryLabel] = useState('最近创作'); + const [categorySortOrder, setCategorySortOrder] = useState('10'); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [listErrorMessage, setListErrorMessage] = useState(''); @@ -82,6 +85,9 @@ export function AdminCreationEntrySwitchPage({ visible, open, sortOrder: parseInteger(sortOrder), + categoryId: categoryId.trim(), + categoryLabel: categoryLabel.trim(), + categorySortOrder: parseInteger(categorySortOrder), }); const nextEntries = sortEntries(response.entries); setEntries(nextEntries); @@ -105,6 +111,9 @@ export function AdminCreationEntrySwitchPage({ setVisible(entry.visible); setOpen(entry.open); setSortOrder(String(entry.sortOrder)); + setCategoryId(entry.categoryId); + setCategoryLabel(entry.categoryLabel); + setCategorySortOrder(String(entry.categorySortOrder)); } return ( @@ -189,6 +198,32 @@ export function AdminCreationEntrySwitchPage({ /> +
+ + +
+ + + {errorMessage ? (
{errorMessage} @@ -211,6 +246,7 @@ export function AdminCreationEntrySwitchPage({ 入口 展示 开放 + 分类 排序 @@ -228,6 +264,7 @@ export function AdminCreationEntrySwitchPage({ {entry.visible ? '是' : '否'} {entry.open ? '是' : '否'} + {entry.categoryLabel || entry.categoryId} {entry.sortOrder} ))} diff --git a/docs/README.md b/docs/README.md index 36689c78..030744a9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,8 @@ - [运营查询](./operations/README.md):任务、领奖、钱包对账等后台核查查询。 - [PRD](./prd/README.md):产品需求与阶段计划;参考 MOKU / 幕间类 AI 文游的陶泥儿 `text-game` 模板口径见 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md),视觉小说模板 TXT 玩法平台化接入口径见 [AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md](./prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md),创意互动内容 Agent Phase 1 的 LangChain-Rust PoC、拼图闭环和并行任务拆分见 [CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md](./prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md),幸存者类模板闭环见 [AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md](./prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md),后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md),方洞挑战创作、发布与试玩闭环见 [AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md](./prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md),跳一跳俯视角玩法模板 PRD 见 [【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md](./prd/%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E8%B7%B3%E4%B8%80%E8%B7%B3%E4%BF%AF%E8%A7%86%E8%A7%92%E7%8E%A9%E6%B3%95%E6%A8%A1%E6%9D%BFPRD-2026-05-19.md)。 +拼图生成页步骤真进度、步骤内假进度和精简展示口径见 [【玩法创作】拼图生成页进度口径-2026-05-23.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E6%8B%BC%E5%9B%BE%E7%94%9F%E6%88%90%E9%A1%B5%E8%BF%9B%E5%BA%A6%E5%8F%A3%E5%BE%84-2026-05-23.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)。 SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。 diff --git a/docs/superpowers/plans/2026-05-24-create-tab-blank-entry-page.md b/docs/superpowers/plans/2026-05-24-create-tab-blank-entry-page.md new file mode 100644 index 00000000..582d8ed6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-create-tab-blank-entry-page.md @@ -0,0 +1,33 @@ +# 创作 Tab 入口表单回切记录 + +> 说明:本文件原本记录“空白入口页”实施计划。2026-05-24 已按产品反馈回切为点击玩法模板卡后直达既有入口创作表单,保留此文件作为历史回顾,避免后续误按旧计划继续实现空白页。 + +**Goal:** 创作 Tab 只展示赛事 banner、玩法模板分类和两列玩法卡;点击卡片后直接进入对应玩法已有的入口创作表单,而不是继续展示空白占位页。 + +**Architecture:** 保留现有创作大厅和入口配置事实源。创作大厅负责参考图 banner、分类 tabs 和入口卡片;平台壳层负责把卡片点击路由到对应玩法的既有入口表单 stage。入口表单继续承接各玩法自己的表单、草稿恢复和后续编排,不再多套一层空白入口页。 + +**Current Route Mapping:** + +- `/creation/rpg` -> `agent-workspace` +- `/creation/big-fish` -> `big-fish-agent-workspace` +- `/creation/match3d` -> `match3d-agent-workspace` +- `/creation/square-hole` -> `square-hole-agent-workspace` +- `/creation/jump-hop` -> `jump-hop-workspace` +- `/creation/wooden-fish` -> `wooden-fish-workspace` +- `/creation/puzzle` -> `puzzle-agent-workspace` +- `/creation/bark-battle` -> `bark-battle-workspace` +- `/creation/visual-novel` -> `visual-novel-agent-workspace` +- `/creation/baby-object-match` -> `baby-object-match-workspace` + +**Verification:** + +- `npm test -- src/routing/appPageRoutes.test.ts` +- `npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "create tab opens match3d entry form from the template card|create tab opens puzzle entry form from the template card|create tab opens bark battle entry form from the template card"` +- `npm run typecheck` +- `npm run check:encoding` + +**Notes:** + +- 不再新增或保留 `*-workspace-entry` 空白 stage。 +- `/creation/` 是用户可直达的入口表单 URL。 +- 旧的空白入口页方案已废弃;如需重新引入,必须先更新平台入口文档和本记录。 diff --git a/docs/superpowers/plans/2026-05-24-profile-tab-mobile-layout.md b/docs/superpowers/plans/2026-05-24-profile-tab-mobile-layout.md new file mode 100644 index 00000000..bd2864e6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-profile-tab-mobile-layout.md @@ -0,0 +1,59 @@ +# Profile Tab Mobile Layout Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Tighten the mobile `我的` Tab layout so typography, spacing, and fixed-height controls align with the rest of the platform UI and do not overlap the bottom dock. + +**Architecture:** Keep the existing `RpgEntryHomeView` structure and update only page-specific class names plus CSS rules. Treat `src/index.css` as the platform token surface; document the mobile profile Tab acceptance criteria in the existing product / play-flow docs instead of creating a parallel doc. + +**Tech Stack:** React, TypeScript, Tailwind utility classes, project CSS in `src/index.css`, Vitest, Vite local browser smoke. + +--- + +### Task 1: Add Mobile Profile Layout Assertions + +**Files:** +- Modify: `src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` + +- [ ] Add expectations in `mobile profile page matches the reference layout sections` that the profile page, header, stats grid, daily task card, shortcut grid, and bottom dock expose the stable class hooks used by the mobile CSS. + +- [ ] Run `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections"` and verify the test covers the hooks before CSS edits. + +### Task 2: Tighten Profile Tab Markup Hooks + +**Files:** +- Modify: `src/components/rpg-entry/RpgEntryHomeView.tsx` + +- [ ] Add focused class hooks for profile identity text, stat values, shortcut labels, membership copy, and daily task copy without changing user-facing Chinese text. + +- [ ] Keep the current page order: profile header, membership card, three stats, daily task, five shortcut buttons, settings, secondary shortcuts, legal strip. + +### Task 3: Implement Mobile CSS + +**Files:** +- Modify: `src/index.css` + +- [ ] Under the existing `@media (max-width: 639px)` block, reduce `我的` Tab fixed sizes to ordinary UI scale: profile title 16-17px, body copy 11-13px, stats values 13-14px, shortcut labels 11-12px. + +- [ ] Make narrow screens robust: stats grid uses three min-width-safe columns, shortcut grid becomes `repeat(5, minmax(0, 1fr))` above 360px and a stable `repeat(3, minmax(0, 1fr))` below 360px, identity text wraps safely, legal links wrap when needed. + +- [ ] Keep the bottom dock fixed but add enough profile page bottom padding so the final legal / secondary section can scroll above it. + +### Task 4: Update Docs + +**Files:** +- Modify: `docs/【项目基线】当前产品与工程约束-2026-05-15.md` +- Modify: `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` + +- [ ] Add the acceptance rule that mobile `我的` Tab typography stays within normal UI sizes and all functional blocks must scroll clear of the bottom dock on narrow screens. + +### Task 5: Verify + +**Files:** +- No new files. + +- [ ] Run `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections"`. + +- [ ] Run `npm run check:encoding`. + +- [ ] Run a local mobile viewport smoke check for the profile tab if the dev server starts cleanly. diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index a0cb47fe..9ebda83f 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -312,11 +312,15 @@ npm run check:server-rs-ddd - Rust 结构体:`CreationEntryConfig` - 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` +- 字段:`config_id`、`start_title`、`start_description`、`start_idle_badge`、`start_busy_badge`、`modal_title`、`modal_description`、`updated_at`、`event_title`、`event_description`、`event_cover_image_src`、`event_prize_pool_mud_points`、`event_starts_at_text`、`event_ends_at_text`。 +- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;运行态读取层再按 `module-runtime` 默认横幅归一,不覆盖后台已保存配置。 ### `creation_entry_type_config` - Rust 结构体:`CreationEntryTypeConfig` - 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` +- 字段:`id`、`title`、`subtitle`、`badge`、`image_src`、`visible`、`open`、`sort_order`、`updated_at`、`category_id`、`category_label`、`category_sort_order`。 +- 迁移兼容:旧迁移包缺少入口分类字段时,由 `migration.rs` 写入 `None` / `0` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费。 ### `custom_world_agent_message` @@ -639,6 +643,7 @@ npm run check:server-rs-ddd 拼图、自定义世界、抓大鹅、方洞挑战、视觉小说和大鱼吃小鱼的公开列表 HTTP 路由都从订阅 cache 读取公开 read model / view。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。 `GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。 +入口配置快照包含 start card、类型弹窗、活动横幅和入口类型列表;入口类型列表新增 `category_id`、`category_label`、`category_sort_order` 后,后台 upsert、`shared-contracts`、`module-runtime` 和 `spacetime-client` binding 必须同步,旧迁移 JSON 通过 `migration.rs` 默认值兼容。 RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`;历史 `custom-world` 路由仍是 RPG 的工程域与运行态源类型。入口熔断把 `/api/runtime/custom-world*`、`/api/story/*` 和 `/api/runtime/chat/*` 统一映射到 `rpg`,不要新增平行 `airp` 路由或用 `airp` 接管当前文字冒险链路。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index ad3b3c28..0ac84bb9 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -6,7 +6,7 @@ 创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。 -当前创作 Tab 固定为智能创作首页与模板入口,草稿 Tab 承接作品架。点击独立入口后应切换到对应内嵌创作表单或生成页;不要额外做一张平行配置页,除非玩法本身需要完整独立工作台。 +当前创作 Tab 只承载赛事 banner、玩法模板分类和两列模板卡;点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作 Tab 首屏。移动端创作 Tab 顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把活动奖池当作账号余额展示。首屏 banner 结构按参考图拆成横向可滑动赛事卡、主体宣传图文区、奖池胶囊、开始 / 结束时间条和卡片内分页点;轮播只保留 `拼图主题创作赛` 和 `抓大鹅主题创作赛`,两个主题赛事奖池均为 `1000` 泥点数。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作 Tab 根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作首屏字号需要对齐平台普通 UI 档位:顶栏泥点组件、banner 正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作 Tab 首屏内容。 `PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。 @@ -26,16 +26,20 @@ `api-server` 的 `generated_asset_sheets` 是当前通用系列素材图集模块:`n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。 +当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。 + ## 草稿与作品架 1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。 -2. 草稿 / 已发布状态尽量图标化,不使用大段状态文案。 -3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon,删除等破坏性动作继续收口到左滑或长按操作层。 +2. 草稿页顶部 `全部 / 草稿 / 已发布` 筛选与发现页 `推荐 / 今日 / 分类 / 排行` 频道标签复用同一选中 / 未选中视觉,即 `platform-mobile-home-channel` 与 `platform-mobile-home-channel--active`,不再使用旧 `platform-tab` 胶囊样式。 +3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作继续收口到左滑或长按操作层。 4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。 5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。 -6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用作品摘要 `updatedAt` 推导。 +6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用进入生成页的当前时间,作品摘要 `updatedAt` 只用于排序和摘要展示,不参与假进度起算。 7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 +发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、次级入口带和法律信息组织,但字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 + ## RPG / 自定义世界 当前 RPG 创作入口使用 `playId = rpg`,工程域和运行态源类型沿用历史 `custom-world`。默认入口状态为 `visible=true`、`open=true`,对外展示为“文字冒险”;`airp` 仍是独立的“AI RPG”占位入口,保持 `open=false`,不要把它当作当前 RPG 创作链路开放。 @@ -62,7 +66,7 @@ RPG 从作品架、广场详情或作品号搜索点击“启动”前,入口 RPG 运行态的战斗终局、继续冒险、继续探索和切场景都属于服务端 runtime 快照真相:`module-runtime-story` 必须在终局战斗 action 后调用 post-battle finalization,持久写入 `story_continue_adventure`、`deferredOptions`、`deferredRuntimeState.storyEngineMemory.currentSceneActState` 和清理后的战斗状态;`idle_travel_next_scene` / `camp_travel_home_scene` 必须由后端写入新的 `currentScenePreset`、`currentSceneActState`、`currentEncounter` 和 `runtimeStats.scenesTraveled`。前端只播放退场、进场和继续按钮表现,不能用默认 `观察/试探/调息` fallback 或本地动画假装推进剧情。旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId` 而没有 `connections`,后端生成战后旅行选项时必须兼容这些字段。 -RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 > 存档` 暴露为独立弹窗;“玩过”弹窗可以继续合并展示可继续存档,但不能成为唯一入口。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。 +RPG / 拼图等运行态存档选择入口统一在个人中心 `次级入口 > 存档` 和设置入口区保留为独立弹窗;“玩过”弹窗可以继续合并展示可继续存档,但不能成为唯一入口。移动端“我的”页的五项常用功能宫格只放泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,避免把存档挤入主宫格破坏参考图布局。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。 ## 拼图 @@ -77,9 +81,9 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 > - 图像输入复用 `CreativeImageInputPanel`。 - 结果页每关画面编辑复用 `CreativeImageInputPanel`;入口页和关卡画面只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`。结果页删除独立“素材配置”Tab,不再提供单独 UI 背景生成入口。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在正式图自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。用户在本次编辑中上传或选择历史图后,该图优先占据主图卡片,可删除、切换 AI 重绘,也可关闭 AI 重绘直用;仅有正式图预览时,画面描述框仍可上传多张参考图。关卡详情弹窗应使用加宽面板,关卡名称、画面图和画面描述合并在同一个纵向列表中,名称输入和画面编辑模块外层不再包独立 `platform-subpanel`;画面图卡仍必须保留稳定最小高度,避免弹窗内 `flex-1` 布局坍缩后只剩标题、描述输入和操作按钮。 - 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端校验 owner / bucket / kind / MIME / size 后签发 OSS 只读 URL 并下载为 VectorEngine `/v1/images/edits` 的 multipart `image` part。本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入;关闭 AI 重绘时,后端统一解析为首关或当前关卡正式图后再持久化,不调用第一段拼图首图生成。 -- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页进度不再按固定 5 分钟展示,而按实际开始时间和当前路径的分步骤预计时长推进;任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。 +- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡拼图画面、关卡画面参考图、UI spritesheet 和关卡背景图后再变为 `ready`;当前不自动生成背景音乐。生成页步骤推进必须跟随后端 session `progressPercent` 的真实里程碑:`88` 表示草稿编译完成并进入出图步骤,`94` 表示生成图已保存并进入 UI / 背景步骤,`96` 表示正式图与 UI 背景已确认并进入写入步骤,最终 action 成功或发布才进入完成态;每个步骤内部可以按实际等待时间使用假进度平滑推进,总进度按 `0-88`、`88-94`、`94-96`、`96-98` 的真实里程碑区间平滑推进。任一同步 action 回包到达时立即以真实完成/失败结果冻结进度。 - 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。 -- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次 `gpt-image-2` 调用的预期用时按 90 秒计算;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 90 秒、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 298 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。未收到 action 回包前,总进度仍最多停在 98%,但当预计写入时长耗尽且仍处于 `写入正式草稿` 时,该步骤自身应显示已完成,不能出现“进行中 100%”。 +- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_800_000ms`(30 分钟)且不自动重试。每次 `gpt-image-2` 调用的预期用时按 90 秒计算,但 `生成拼图首图` 单独按 4 分钟展示;完整 AI 重绘路径为 `编译首关草稿` 8 秒、`生成关卡名称` 10 秒、`生成拼图首图` 4 分钟、`生成关卡画面` 90 秒、`生成UI与背景` 90 秒、`写入正式草稿` 10 秒,合计约 448 秒。上传图且关闭 AI 重绘时必须跳过 `生成拼图首图`,直接进入 `生成关卡画面` 和 `生成UI与背景`,合计约 208 秒。生成页恢复时必须使用进入生成页的当前时间作为原始 `startedAtMs`;失败/完成态用 `finishedAtMs` 冻结耗时。未收到对应后端里程碑前,后续步骤保持待处理;即使当前步骤预计时长耗尽,也只能让当前步骤内部进度停在 `98%` 内,不能自动完成当前步骤或跳到后续步骤。生成页每个步骤只展示标题和进度,不展示步骤详细描述。 - 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。 - 拼图参考图 AI 重绘走 VectorEngine `/v1/images/edits`;无参考图时走 `/v1/images/generations`。两者模型都使用 `gpt-image-2`,参考图由后端作为 multipart `image` part 传入编辑接口。 - 每次新建关卡生成或重新生成关卡图都必须由 `api-server` 串起当前关卡资产包:AI 重绘开启时第一段沿用草稿生成第一关的拼图主图提示词配置和模型 / 尺寸 / 参考图规则生成 `coverImageSrc/coverAssetId` 作为关卡拼图画面和结果页预览图,提示词来源同样按显式画面描述、关卡画面描述、草稿摘要顺序回退,且固定要求输出画面比例为 `1:1`;上传图且关闭 AI 重绘时跳过这一段,把上传图或历史图持久化为 `sourceType=uploaded` 的正式候选。随后用正式候选图作为参考,`9:16` 生成完整拼图游戏关卡画面并写入 `levelSceneImageSrc/levelSceneImageObjectKey`,提示词必须要求道具按钮上不要显示次数标注,且返回按钮和设置按钮旁禁止标注文字;UI spritesheet 与关卡纯背景在关卡画面完成后并发生成,spritesheet 用 `1:1`、`1k` 先生成纯绿色绿幕背景图,后端上传 OSS 前必须把绿幕扣成透明 PNG,再写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,按钮顺序固定为返回、设置、下一关、提示、原图、冻结,按钮素材自身保留对应中文文字,返回和设置按钮不得额外生成白色外圈、白底圆环或浮雕外框;纯背景用 `9:16`、`1k` 写入 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,提示词必须包含“禁止在背景中出现人像或和拼图画面中主体一致的内容”。运行态不直接使用第二段完整关卡画面,但必须持久化它用于追踪和后续再生成。结果页局部关卡生成进度按 AI 重绘开启约 270 秒、关闭 AI 重绘约 180 秒展示。 @@ -232,10 +236,10 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 > - 后端裁决结果:后端根据 start run 与 finish 派生指标校验后的正式单局结果。 - 基础统计:只记录正式 `published` run 的开始、结算和派生指标,草稿试玩不写正式统计。 - 公开广场:统一读取 `bark_battle_gallery_view` 这类 read model,不再由前端自己拼公开列表。 -- 创作者信息:草稿架、已发布作品架、统一作品详情和公开广场都必须展示后端返回的 `authorDisplayName`,不得只在详情页内层可见。 +- 创作者信息:统一作品详情和公开广场都必须展示后端返回的 `authorDisplayName`,不得只在详情页内层可见;草稿 Tab 作品架遵循平台作品架统一口径,无论草稿 / 已发布都不外露作者信息。 - 拟声词:配置字段为 `onomatopoeia`。创作者未手动编辑时,前端根据主题 / 竞技背景描述、玩家形象描述和对手形象描述生成高能词池;创作者手动编辑后按自定义词池发布。默认词池只在命中狗相关主题时加入狗叫词,不能把非狗主题强行带回狗语义。 -当前入口默认开放:`visible=true`、`open=true`、`badge=可创建`,入口参考图使用 `/creation-type-references/bark-battle.webp`。创作入口使用 7 字段表单(作品标题、简介、主题 / 竞技背景描述 `themeDescription`、玩家形象描述、对手形象描述、拟声词、难度);提交后先进入 `bark-battle-generating` 独立生成页,自动生成玩家形象、对手形象和竞技背景三图。生成页即使部分槽位失败也要继续落到结果页,失败槽位保留错误态和单槽重试入口,不在生成页停留。结果页只保留单槽重试、重新生成和上传,不再展示一次生成按钮、音频配置入口、皮肤预设入口或排名配置。发布成功后先跳统一作品详情页 `/works/detail?work=BB-xxxxxxxx`,正式 `published` runtime 从作品详情页进入并必须使用真实麦克风;`draft` 可试玩,可使用 mock 输入,且不写正式统计。草稿与已发布作品在外部卡片、作品架和广场列表都展示创作者名称。 +当前入口默认开放:`visible=true`、`open=true`、`badge=可创建`,入口参考图使用 `/creation-type-references/bark-battle.webp`。创作入口使用 7 字段表单(作品标题、简介、主题 / 竞技背景描述 `themeDescription`、玩家形象描述、对手形象描述、拟声词、难度);提交后先进入 `bark-battle-generating` 独立生成页,自动生成玩家形象、对手形象和竞技背景三图。生成页即使部分槽位失败也要继续落到结果页,失败槽位保留错误态和单槽重试入口,不在生成页停留。结果页只保留单槽重试、重新生成和上传,不再展示一次生成按钮、音频配置入口、皮肤预设入口或排名配置。发布成功后先跳统一作品详情页 `/works/detail?work=BB-xxxxxxxx`,正式 `published` runtime 从作品详情页进入并必须使用真实麦克风;`draft` 可试玩,可使用 mock 输入,且不写正式统计。统一作品详情和广场列表展示创作者名称;草稿 Tab 作品架不外露创作者名称,已发布作品只在右上角常驻分享入口。 移动端创作 Tab 内嵌 Bark Battle 表单时,只保留外层 Tab 面板承担纵向滚动;表单自身移动端不再创建独立纵向滚动容器,底部“生成草稿”按钮作为普通表单尾部并保留 safe-area 底部间距,避免与最后一组输入框、移动端键盘或底部 TabBar 形成套滚动 / 遮挡。 @@ -243,7 +247,7 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 > - 创作 Tab 表单:填写作品标题、简介、主题 / 竞技背景描述、玩家形象描述、对手形象描述、拟声词和难度。拟声词支持换行、逗号、顿号、斜杠或竖线分隔;未手动编辑时随主题 / 形象描述自动重算,手动编辑后保持创作者自定义。 - 草稿编译:`POST /api/creation/bark-battle/drafts` 写入配置 JSON,返回包含 `draftId`、稳定 `workId`、`configVersion` 和 `rulesetVersion` 的草稿结果。 -- 生成页:`bark-battle-generating` 自动并行产出玩家形象、对手形象和竞技背景三图;前端按槽位实时显示生成中 / 已生成 / 失败状态,三图都走 Bark Battle 专用后端生图接口 `POST /api/creation/bark-battle/images/generate`,由后端按 `player-character`、`opponent-character`、`ui-background` 分别拼装正式提示词、写入 `generated-bark-battle-assets` 私有资产前缀并返回实际 prompt。玩家 / 对手形象提示词必须保持用户形象描述,不强行注入狗相关主体,并要求正面、单个完整形象和透明背景。部分失败也继续进入结果页。 +- 生成页:`bark-battle-generating` 自动并行产出玩家形象、对手形象和竞技背景三图;前端生成页 UI 和其它玩法保持同一圆环主视觉,`media/create_bg_video.mp4` 作为固定全屏页面背景层循环静音播放,主进度圆环居中展示总进度,只保留当前步骤名称和当前步骤进度,不再渲染三行槽位列表。视频层需要显式触发播放。三图都走 Bark Battle 专用后端生图接口 `POST /api/creation/bark-battle/images/generate`,由后端按 `player-character`、`opponent-character`、`ui-background` 分别拼装正式提示词、写入 `generated-bark-battle-assets` 私有资产前缀并返回实际 prompt。玩家 / 对手形象提示词必须保持用户形象描述,不强行注入狗相关主体,并要求正面、单个完整形象和透明背景。部分失败也继续进入结果页。 - 结果页:围绕三图槽位展示错误态与已生成结果,只保留单槽重试、重新生成和上传,不再提供一次生成按钮、音频配置入口或排名配置。 - 手动上传:结果页通过平台资产直传 `/api/assets/direct-upload-tickets` 与 `/api/assets/objects/confirm` 写入私有资产,再把返回的历史 generated 路径写回草稿配置。 - 发布:结果页确认后必须携带草稿返回的同一个 `workId` 和结果页最终 `publishedSnapshot` 调用 `POST /api/creation/bark-battle/works/publish`;SpacetimeDB 发布态的 `config_json` 必须使用该最终快照,works summary 若拿到 `publishedSnapshotJson` 也优先使用最终快照映射封面三图。发布成功后先进入统一作品详情页,再由详情页进入正式 runtime;缺少 `workId` 的旧草稿状态需要重新生成草稿。 diff --git a/docs/【玩法创作】拼图生成页进度口径-2026-05-23.md b/docs/【玩法创作】拼图生成页进度口径-2026-05-23.md new file mode 100644 index 00000000..bc0ae2a0 --- /dev/null +++ b/docs/【玩法创作】拼图生成页进度口径-2026-05-23.md @@ -0,0 +1,29 @@ +# 拼图生成页进度口径 + +更新时间:`2026-05-24` + +## 目标 + +拼图草稿生成页的步骤推进只跟随真实生成结果;步骤内部允许使用本地假进度增强等待反馈。未收到当前步骤完成信号前,生成页必须停留在当前步骤,不得按预计耗时自动跳到后续步骤。 + +## 落地口径 + +- 总进度和当前步骤内百分比可以按已耗时平滑增长,但进入生成页的初始帧必须从 `0%` 开始,非完成态最多停在 `98%`。 +- 未收到首个真实里程碑前,页面仍停留在当前步骤,总进度在 `0-88` 区间内平滑推进;收到 `88/94/96` 里程碑后,分别在 `88-94`、`94-96`、`96-98` 区间内推进,避免步骤不跳时总进度也停死。 +- 后端 `progressPercent` 低于 `88` 只作为当前会话状态记录,不得把生成页阶段推到首个图片里程碑;低于首个里程碑时页面仍按当前视图进入时间从 `0%` 平滑展示。 +- 步骤状态以真实阶段为准:`phase` / 后端会话进度 / 最终完成或失败回包才允许跨步。 +- 拼图生成页恢复持久化 `generationStatus=generating` 草稿时,展示进度使用“进入生成页的当前时间”作为 `startedAtMs`;不得再用作品摘要 `updatedAt` 推导展示起点,避免刷新后首帧直接跳到 `80%+`。 +- 拼图和抓大鹅等生成页从作品架 / 刷新恢复进入时,前端应把展示态生成状态重基准到进入页面的当前时间;后台 session 的 `progressPercent` 与历史里程碑只保留为状态事实,不得直接作为首帧总进度。 +- 当前步骤未完成时,后续步骤保持待处理;即使预计时间耗尽,也只能让当前步骤内部进度接近或达到上限,不能自动完成后续步骤。 +- 抓大鹅等非拼图小游戏的生成页也遵守初始帧 `0%`:没有后端资产计数时,当前步骤内假进度按玩法预计等待总时长从 `0` 平滑推进,不使用固定 `0.5` 这类常量起步。 +- 步骤卡片只展示标题和进度,不展示详细描述。 +- 生成拼图首图步骤按 4 分钟预估;完整 AI 重绘路径总预计时长为 448 秒,上传图且关闭 AI 重绘时跳过首图生成,仍为 208 秒。 + +## 验收 + +- `src/services/miniGameDraftGenerationProgress.test.ts` 覆盖拼图步骤不会单纯按时间推进。 +- `src/services/miniGameDraftGenerationProgress.test.ts` 覆盖后端 `progressPercent < 88` 时不会抬高进入生成页的初始总进度。 +- `src/services/miniGameDraftGenerationProgress.test.ts` 覆盖抓大鹅等非拼图生成页初始总进度为 `0%`。 +- `src/components/CustomWorldGenerationView.test.tsx` 覆盖步骤详情不在生成页渲染。 +- `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"` 覆盖刷新后继续生成中拼图 / 抓大鹅草稿不会继承旧 `updatedAt` 导致总进度首帧过高。 +- 文档主图谱的拼图章节同步保留该口径。 diff --git a/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md new file mode 100644 index 00000000..8b5730a9 --- /dev/null +++ b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md @@ -0,0 +1,28 @@ +# 生成页圆环布局口径 + +更新时间:`2026-05-24` + +## 目标 + +所有玩法的生成页统一收敛为参考图同款等待态:顶部只保留返回入口和生成状态胶囊,页面中段用大圆环展示总进度,圆环左右悬浮“预计等待 / 已耗时”,下方只保留当前步骤单卡和当前作品信息卡,不再渲染步骤列表块。 + +## 落地口径 + +- 共用生成页 `CustomWorldGenerationView` 的主进度条使用居中 SVG 大圆弧,默认保留正下方 90 度留空,`media/create_bg_video.mp4` 作为固定全屏背景层循环静音播放;圆弧覆盖在背景之上展示总进度。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。 +- 生成页背景视频必须留在生成页容器内部,直接作为 `fixed inset-0` 的底层背景,不要再通过 portal 挂到 `document.body`;页面根容器使用 `z-[1]`、背景容器使用 `z-0`,确保顶部导航、圆环和当前步骤卡都稳定覆盖在视频之上。 +- 预计等待 / 已耗时信息卡要压缩为更轻的半透明窄卡,标签使用 `9px-10px`,数值使用 `12px-13px`,字号对齐其他生成页 UI 的小字号,不再使用偏大的提示文本;卡片标题和时间值都居中显示,两个数值只展示时间本身,调用侧不要再拼接“预计还需”或“已耗时”前缀。圆环中心不再保留独立白底块,空心圆环只保留条状进度,圆弧半径继续加大,进度数字与“总进度”标题整体上移,靠近圆环上半区。 +- 顶部导航区采用“返回创作中心 / 状态胶囊”结构,返回按钮使用左箭头图标,字号使用 `text-xs-sm`,状态胶囊使用 `11px-12px`,展示 `素材生成中`、`草稿生成中` 等调用侧传入文案。 +- 圆弧区域不再包独立大卡片,左右悬浮信息卡只展示“预计等待”和“已耗时”;总进度数值放在圆弧内侧偏上的位置并保持更小字号。当前圆环外径以 `w-[min(35rem,94vw)] sm:w-[52rem]` 为基准,圆弧使用 `r=166`、`strokeWidth=18` 的 SVG 描边,不再使用 `conic-gradient + mask`,避免进度条边缘模糊。 +- 从作品架或刷新后的持久化生成中草稿进入生成页时,前端必须重置“展示态 startedAtMs”为进入生成页的当前时间;后端 `progressPercent` 只用于后续真实步骤推进,不得参与首帧总进度展示,避免恢复生成页首帧直接显示 `80%+`。 +- 生成页只展示半透明“当前步骤”单卡,卡片内只保留步骤名称、步骤状态、步骤进度条和轻量加载指示;“当前步骤”标签使用 `10px-11px`,步骤名称使用 `14px-15px`,状态使用 `11px-12px`,不再渲染步骤列表或步骤详情。 +- 当前作品信息放在圆角信息卡中,标题固定使用 `13px`;有结构化字段时以两列信息块展示,例如“题材 / 素材数量”,无结构化字段时才展示纯文本设定。 +- 汪汪声浪生成页 `BarkBattleGeneratingView` 也必须对齐同一垂直布局,不再继续展示三行槽位列表或左右分栏抢占主视觉。 +- 汪汪声浪的总进度按三槽位已完成数量换算;当前步骤只显示第一个未完成槽位的名称与进度。 + +## 验收 + +- `src/components/CustomWorldGenerationView.test.tsx` 覆盖圆环主视觉和单步卡片。 +- `src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx` 覆盖汪汪声浪生成页对齐后的圆环布局。 +- 两个生成页都应在测试里断言页面根容器层级高于背景视频容器,且背景视频确实是页面子节点,避免 portal 背景把业务 UI 压住。 +- 还应断言圆弧正下方留空、圆环中心没有独立底色块,时间卡和总进度字号缩小后仍能在桌面与移动端正常排版;同时断言时间卡 `text-center`、标题行 `justify-center`、总进度内容区上移到 `pt-[4%]`,圆弧 DOM 为 SVG,包含清晰的 track/fill circle 描边。 +- 页面在桌面和移动端都不应再出现生成步骤列表块,圆环和当前步骤卡不能被外层卡片嵌套出双层面板感。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index e19cc841..8ea8b30f 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -93,9 +93,11 @@ server-rs + Axum + SpacetimeDB 7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。 8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。 9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。 -10. “我的”页泥点、游戏时长、玩过三张统计卡只展示各自标签和值,内容居中且不换行,不在统计区底部展示“更新于”时间。 -11. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。 -11. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。 +10. 移动端“我的”页按参考图顺序组织为顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、次级入口带和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;存档和填邀请码保留在次级入口带,不挤入五宫格。 +11. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,内容不换行,不在统计区底部展示“更新于”时间,字号维持平台普通 UI 档位;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。 +12. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能、次级入口和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。 +13. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。 +14. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。 ## 文案与编码 diff --git a/media/create_bg_video.mp4 b/media/create_bg_video.mp4 new file mode 100644 index 00000000..d0c2095c Binary files /dev/null and b/media/create_bg_video.mp4 differ diff --git a/media/profile/_Image (1).png b/media/profile/_Image (1).png new file mode 100644 index 00000000..f86816b9 Binary files /dev/null and b/media/profile/_Image (1).png differ diff --git a/media/profile/_Image (2).png b/media/profile/_Image (2).png new file mode 100644 index 00000000..93dd45a3 Binary files /dev/null and b/media/profile/_Image (2).png differ diff --git a/media/profile/_Image (3).png b/media/profile/_Image (3).png new file mode 100644 index 00000000..33d971f2 Binary files /dev/null and b/media/profile/_Image (3).png differ diff --git a/media/profile/_Image (4).png b/media/profile/_Image (4).png new file mode 100644 index 00000000..7e62a727 Binary files /dev/null and b/media/profile/_Image (4).png differ diff --git a/media/profile/_Image (5).png b/media/profile/_Image (5).png new file mode 100644 index 00000000..deb8e0b5 Binary files /dev/null and b/media/profile/_Image (5).png differ diff --git a/media/profile/_Image (6).png b/media/profile/_Image (6).png new file mode 100644 index 00000000..d4d81c1d Binary files /dev/null and b/media/profile/_Image (6).png differ diff --git a/media/profile/_Image (7).png b/media/profile/_Image (7).png new file mode 100644 index 00000000..e806455b Binary files /dev/null and b/media/profile/_Image (7).png differ diff --git a/media/profile/_Image (8).png b/media/profile/_Image (8).png new file mode 100644 index 00000000..8348e99b Binary files /dev/null and b/media/profile/_Image (8).png differ diff --git a/media/profile/_Image (9).png b/media/profile/_Image (9).png new file mode 100644 index 00000000..4b22f1cf Binary files /dev/null and b/media/profile/_Image (9).png differ diff --git a/media/profile/_Image.png b/media/profile/_Image.png new file mode 100644 index 00000000..0b9d446c Binary files /dev/null and b/media/profile/_Image.png differ diff --git a/output/generation-page-mobile-check.png b/output/generation-page-mobile-check.png new file mode 100644 index 00000000..698572e9 Binary files /dev/null and b/output/generation-page-mobile-check.png differ diff --git a/output/rpg-profile-test-report.json b/output/rpg-profile-test-report.json new file mode 100644 index 00000000..485b4cec --- /dev/null +++ b/output/rpg-profile-test-report.json @@ -0,0 +1,652 @@ +{ + "numTotalTestSuites": 1, + "numPassedTestSuites": 1, + "numFailedTestSuites": 0, + "numPendingTestSuites": 0, + "numTotalTests": 58, + "numPassedTests": 50, + "numFailedTests": 8, + "numPendingTests": 0, + "numTodoTests": 0, + "startTime": 1779633396424, + "success": false, + "testResults": [ + { + "assertionResults": [ + { + "ancestorTitles": [ + "" + ], + "fullName": " opens wallet ledger modal from narrative coin card", + "status": "passed", + "title": "opens wallet ledger modal from narrative coin card", + "duration": 157, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal shows native qr code on desktop web by default", + "status": "passed", + "title": "profile recharge modal shows native qr code on desktop web by default", + "duration": 183, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal jumps to h5 payment on mobile web by default", + "status": "passed", + "title": "profile recharge modal jumps to h5 payment on mobile web by default", + "duration": 203, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal trusts per-product first bonus display after points recharge", + "status": "passed", + "title": "profile recharge modal trusts per-product first bonus display after points recharge", + "duration": 120, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal posts requestPayment params in mini program web-view", + "status": "passed", + "title": "profile recharge modal posts requestPayment params in mini program web-view", + "duration": 237, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal waits for paid confirmation before refreshing dashboard", + "status": "passed", + "title": "profile recharge modal waits for paid confirmation before refreshing dashboard", + "duration": 1059, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal loads wechat js sdk before mini program payment bridge", + "status": "passed", + "title": "profile recharge modal loads wechat js sdk before mini program payment bridge", + "duration": 374, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile recharge modal releases submitting state after cancelled wechat pay result", + "status": "passed", + "title": "profile recharge modal releases submitting state after cancelled wechat pay result", + "duration": 388, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile native qr confirmation refreshes only after server reports paid", + "status": "passed", + "title": "profile native qr confirmation refreshes only after server reports paid", + "duration": 421, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " non-wechat profile shows reward code instead of recharge entry", + "status": "failed", + "title": "non-wechat profile shows reward code instead of recharge entry", + "duration": 63, + "failureMessages": [ + "expected to be null" + ], + "location": { + "line": 1805, + "column": 5 + } + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile daily task shortcut opens task center and claims reward", + "status": "passed", + "title": "profile daily task shortcut opens task center and claims reward", + "duration": 392, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile total play time card always uses hours", + "status": "passed", + "title": "profile total play time card always uses hours", + "duration": 83, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile played works card shows count unit", + "status": "passed", + "title": "profile played works card shows count unit", + "duration": 73, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile stats cards are centered without update timestamp", + "status": "passed", + "title": "profile stats cards are centered without update timestamp", + "duration": 113, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " mobile profile page matches the reference layout sections", + "status": "failed", + "title": "mobile profile page matches the reference layout sections", + "duration": 1045, + "failureMessages": [ + "expected \"spy\" to be called 1 times, but got 0 times\n\nIgnored nodes: comments, script, style\n\u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m陶泥\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[0m儿\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0mGENARRATIVE\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m搜索\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m测\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m测试玩家\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m100001\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m陶泥\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[0m儿\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0mGENARRATIVE\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m陶泥\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[0m儿\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0mGENARRATIVE\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m邀请好友\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m双方得 30 泥点\u001b[0m\n \u001b[36m\u001b[39m\n\u001b[36m\u001b[39m" + ], + "location": { + "line": 37, + "column": 19 + } + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile redeem invite shortcut sits between invite and community for fresh accounts", + "status": "failed", + "title": "profile redeem invite shortcut sits between invite and community for fresh accounts", + "duration": 209, + "failureMessages": [ + "expected +0 to be truthy" + ], + "location": { + "line": 2058, + "column": 5 + } + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile redeem invite shortcut hides after redeemed or one day old", + "status": "passed", + "title": "profile redeem invite shortcut hides after redeemed or one day old", + "duration": 226, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " invite query opens login modal for logged out users", + "status": "passed", + "title": "invite query opens login modal for logged out users", + "duration": 20, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " invite query opens redeem modal directly for logged in users", + "status": "passed", + "title": "invite query opens redeem modal directly for logged in users", + "duration": 154, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile redeem invite modal reads query invite code after login", + "status": "passed", + "title": "profile redeem invite modal reads query invite code after login", + "duration": 71, + "failureMessages": [] + }, + { + "ancestorTitles": [ + "" + ], + "fullName": " profile redeem invite modal submits code and hides shortcut after success", + "status": "failed", + "title": "profile redeem invite modal submits code and hides shortcut after success", + "duration": 1054, + "failureMessages": [ + "Unable to find role=\"button\" and name `/填邀请码/u`\n\nIgnored nodes: comments, script, style\n\u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m陶泥\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[0m儿\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0mGENARRATIVE\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m陶泥\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[0m儿\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0mGENARRATIVE\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m陶泥\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[0m儿\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0mGENARRATIVE\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m搜索\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m
\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m测\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m测试玩家\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[0m100001\u001b[0m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m\u001b[39m\n \u001b[36m shouldRunTarget(target.lang)); + +if (selectedTargets.length === 0) { + console.error('[spacetime:generate] 没有需要生成的目标。'); + process.exit(1); +} + +await mkdir(tempRoot, {recursive: true}); + +for (const target of selectedTargets) { + const tempOutDir = path.join(tempRoot, target.tempName); + await recreateTempDir(tempOutDir); + + console.log(`[spacetime:generate] 生成 ${target.name} bindings 到短路径: ${tempOutDir}`); + await generateBindings(target, tempOutDir); + + const fileCount = await countFiles(tempOutDir); + if (fileCount === 0) { + throw new Error(`${target.name} bindings 未生成任何文件。`); + } + + console.log(`[spacetime:generate] 同步 ${fileCount} 个文件到 ${target.outDir}`); + await replaceGeneratedDir(tempOutDir, target.outDir); + await moveGeneratedEntryFile(target); +} + +await rm(tempRoot, {recursive: true, force: true}); +console.log('[spacetime:generate] bindings 生成完成。'); + +function shouldRunTarget(lang) { + if (args.has('--rust-only')) { + return lang === 'rust'; + } + + return true; +} + +function resolveTempRoot() { + if (process.env.GENARRATIVE_BINDGEN_TEMP_ROOT) { + return path.resolve(process.env.GENARRATIVE_BINDGEN_TEMP_ROOT); + } + + // Windows 下 SpacetimeDB CLI 2.1.0 会把所有生成文件路径一次性传给 formatter; + // Rust bindings 文件数较多,输出到仓库深目录时容易触发 CreateProcess 路径总长限制。 + if (process.platform === 'win32') { + return path.join(path.parse(REPO_ROOT).root, '.genarrative-bindgen'); + } + + return path.join(REPO_ROOT, 'tmp', 'spacetime-bindgen'); +} + +async function recreateTempDir(dir) { + assertInside(dir, tempRoot, '临时生成目录'); + await rm(dir, {recursive: true, force: true}); + await mkdir(dir, {recursive: true}); +} + +async function replaceGeneratedDir(fromDir, toDir) { + assertInside(toDir, REPO_ROOT, '仓库生成目录'); + await rm(toDir, {recursive: true, force: true}); + await mkdir(toDir, {recursive: true}); + const entries = await readdir(fromDir, {withFileTypes: true}); + + for (const entry of entries) { + await cp(path.join(fromDir, entry.name), path.join(toDir, entry.name), { + recursive: true, + force: true, + }); + } +} + +async function moveGeneratedEntryFile(target) { + if (!target.entryFile) { + return; + } + + assertInside(target.entryFile, REPO_ROOT, '生成入口文件'); + + const generatedEntryFile = + findExistingFile(path.join(target.outDir, 'module_bindings.rs')) ?? + findExistingFile(path.join(target.outDir, 'mod.rs')); + + if (!generatedEntryFile) { + throw new Error( + `${target.name} bindings 缺少入口文件: ${path.join(target.outDir, 'module_bindings.rs')} / ${path.join(target.outDir, 'mod.rs')}`, + ); + } + + await rm(target.entryFile, {force: true}); + await cp(generatedEntryFile, target.entryFile, {force: true}); + if (path.resolve(generatedEntryFile) !== path.resolve(target.entryFile)) { + await rm(generatedEntryFile, {force: true}); + } +} + +function findExistingFile(candidate) { + return existsSync(candidate) ? candidate : undefined; +} + +function assertInside(candidate, parent, label) { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`${label} 不在预期目录内: ${candidate}`); + } +} + +function assertSafeTempRoot(dir) { + const resolved = path.resolve(dir); + const parsed = path.parse(resolved); + const basename = path.basename(resolved).toLowerCase(); + + if (resolved === path.resolve(REPO_ROOT) || resolved === parsed.root) { + throw new Error(`临时根目录不允许指向仓库或磁盘根目录: ${resolved}`); + } + + if (!basename.includes('bindgen')) { + throw new Error(`临时根目录必须是明确的 bindings 生成目录: ${resolved}`); + } +} + +function buildGenerateArgs(target, outDir) { + const generateArgs = [ + 'generate', + '--no-config', + '--lang', + target.lang, + '--out-dir', + outDir, + '--module-path', + MODULE_PATH, + '--include-private', + '--yes', + ]; + + return generateArgs; +} + +async function generateBindings(target, outDir) { + const result = await run('spacetime', buildGenerateArgs(target, outDir), { + allowGeneratedFormatFailure: target.lang === 'rust', + }); + + if (result.generatedFormatFailed) { + // Windows 下 SpacetimeDB CLI 2.1.0 会把所有 Rust 文件一次性传给 formatter; + // 这里只接管“文件已生成但 CLI 格式化失败”的尾段,并仍然只同步生成目录。 + console.warn( + `[spacetime:generate] ${target.name} bindings 已生成,但 SpacetimeDB CLI 自带格式化失败;改用短路径分批 rustfmt。`, + ); + await formatRustBindings(outDir); + } +} + +async function formatRustBindings(outDir) { + const rustFiles = await collectRustFiles(outDir); + if (rustFiles.length === 0) { + throw new Error(`Rust bindings 未生成任何 .rs 文件,无法格式化: ${outDir}`); + } + + for (const chunk of chunkCommandArgs(rustFiles)) { + await run('rustfmt', ['--edition', '2024', ...chunk]); + } +} + +async function collectRustFiles(dir) { + const files = []; + const entries = await readdir(dir, {withFileTypes: true}); + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...(await collectRustFiles(entryPath))); + continue; + } + + if (entry.isFile() && entry.name.endsWith('.rs')) { + files.push(entryPath); + } + } + + return files; +} + +function chunkCommandArgs(argsToChunk) { + // Windows CreateProcess 受命令行长度限制;分批能避免 bindings 文件变多后再次失败。 + const maxCommandLineChars = process.platform === 'win32' ? 20_000 : 100_000; + const chunks = []; + let current = []; + let currentLength = 0; + + for (const arg of argsToChunk) { + const argLength = arg.length + 3; + if (current.length > 0 && currentLength + argLength > maxCommandLineChars) { + chunks.push(current); + current = []; + currentLength = 0; + } + + current.push(arg); + currentLength += argLength; + } + + if (current.length > 0) { + chunks.push(current); + } + + return chunks; +} + +function run(command, commandArgs, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, commandArgs, { + cwd: REPO_ROOT, + env: process.env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let output = ''; + + child.stdout.on('data', (chunk) => { + const text = chunk.toString(); + output += text; + process.stdout.write(text); + }); + + child.stderr.on('data', (chunk) => { + const text = chunk.toString(); + output += text; + process.stderr.write(text); + }); + + child.on('error', reject); + child.on('exit', (code, signal) => { + if (signal) { + reject(new Error(`${command} 被信号中断: ${signal}`)); + return; + } + + const generatedFormatFailed = output.includes('Could not format generated files'); + + if (generatedFormatFailed && options.allowGeneratedFormatFailure) { + console.warn( + `[spacetime:generate] ${command} generated files but formatting failed; continuing with validation.`, + ); + resolve({generatedFormatFailed}); + return; + } + + if (generatedFormatFailed) { + reject(new Error(`${command} generated files but formatting failed.`)); + return; + } + + if (code === 0) { + resolve({generatedFormatFailed: false}); + return; + } + + reject(new Error(`${command} 退出码: ${code ?? 'unknown'}`)); + }); + }); +} + +async function countFiles(dir) { + let count = 0; + const entries = await readdir(dir, {withFileTypes: true}); + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + count += await countFiles(entryPath); + continue; + } + + if (entry.isFile() || (await stat(entryPath)).isFile()) { + count += 1; + } + } + + return count; +} diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index cd72a6d0..80ab9045 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -251,6 +251,9 @@ fn map_admin_creation_entry_type_config( visible: entry.visible, open: entry.open, sort_order: entry.sort_order, + category_id: entry.category_id, + category_label: entry.category_label, + category_sort_order: entry.category_sort_order, updated_at_micros: entry.updated_at_micros, } } @@ -275,6 +278,9 @@ fn validate_admin_creation_entry_config( visible: payload.visible, open: payload.open, sort_order: payload.sort_order, + category_id: payload.category_id.trim().to_string(), + category_label: payload.category_label.trim().to_string(), + category_sort_order: payload.category_sort_order, }) } diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index fda55d57..166225d1 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -143,6 +143,15 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes title: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(), description: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(), }, + event_banner: module_runtime::CreationEntryEventBannerSnapshot { + title: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_TITLE.to_string(), + description: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION.to_string(), + cover_image_src: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC + .to_string(), + prize_pool_mud_points: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS, + starts_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string(), + ends_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string(), + }, creation_types: module_runtime::default_creation_entry_type_snapshots(0), updated_at_micros: 0, }) @@ -259,5 +268,8 @@ mod tests { assert!(baby_object_match.open); assert_eq!(baby_object_match.badge, "\u{53ef}\u{521b}\u{5efa}"); assert_eq!(baby_object_match.sort_order, 90); + assert_eq!(baby_object_match.category_id, "character"); + assert_eq!(baby_object_match.category_label, "\u{89d2}\u{8272}\u{521b}\u{4f5c}"); + assert_eq!(baby_object_match.category_sort_order, 40); } } diff --git a/server-rs/crates/api-server/src/match3d/tests.rs b/server-rs/crates/api-server/src/match3d/tests.rs index 905a1697..425fcb69 100644 --- a/server-rs/crates/api-server/src/match3d/tests.rs +++ b/server-rs/crates/api-server/src/match3d/tests.rs @@ -874,6 +874,7 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() { container_image_object_key: None, status: "image_ready".to_string(), error: None, + ..Default::default() }); let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓"); generated_asset.image_src = @@ -1062,6 +1063,7 @@ fn match3d_background_asset_requires_background_and_container_images() { container_image_object_key: None, status: "image_ready".to_string(), error: None, + ..Default::default() }; let with_container = Match3DGeneratedBackgroundAsset { container_prompt: Some("果园容器".to_string()), @@ -1108,6 +1110,7 @@ fn match3d_default_cover_prefers_generated_container_ui_image() { container_image_object_key: None, status: "image_ready".to_string(), error: None, + ..Default::default() }), status: "image_ready".to_string(), error: None, @@ -1169,7 +1172,7 @@ fn match3d_cover_reference_prompt_marks_reference_images() { #[test] fn match3d_cover_edit_prompt_preserves_uploaded_image() { - let prompt = build_match3d_cover_edit_prompt("水果封面"); + let prompt = build_match3d_cover_uploaded_reference_prompt("水果封面"); assert!(prompt.contains("上传的封面图作为第一优先级")); assert!(prompt.contains("保留主图的主体、构图、视角和主要配色")); @@ -1212,6 +1215,7 @@ fn match3d_fallback_work_profile_keeps_generated_background_asset() { ), status: "image_ready".to_string(), error: None, + ..Default::default() }), status: "image_ready".to_string(), error: None, @@ -1349,6 +1353,7 @@ fn match3d_agent_session_response_hydrates_persisted_ui_assets() { ), status: "image_ready".to_string(), error: None, + ..Default::default() }), status: "image_ready".to_string(), error: None, @@ -1424,6 +1429,7 @@ fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydr ), status: "image_ready".to_string(), error: None, + ..Default::default() }), status: "image_ready".to_string(), error: None, @@ -1807,6 +1813,7 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() { ), status: "image_ready".to_string(), error: None, + ..Default::default() }), ..test_match3d_generated_item_asset(1, "草莓") }]; diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 6c6d1c60..bfda79d0 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -516,6 +516,10 @@ impl AppState { visible: enabled, open: enabled, sort_order: i32::try_from(config.creation_types.len()).unwrap_or(i32::MAX), + category_id: module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID.to_string(), + category_label: module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL + .to_string(), + category_sort_order: 0, updated_at_micros: 0, }, ); diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index dec7f729..2d997da6 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -10,8 +10,8 @@ use crate::domain::*; use crate::errors::RuntimeProfileFieldError; use crate::format_utc_micros; use shared_contracts::creation_entry_config::{ - CreationEntryConfigResponse, CreationEntryStartCardResponse, CreationEntryTypeModalResponse, - CreationEntryTypeResponse, + CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse, + CreationEntryTypeModalResponse, CreationEntryTypeResponse, }; pub fn build_creation_entry_config_response( @@ -28,6 +28,14 @@ pub fn build_creation_entry_config_response( title: snapshot.type_modal.title, description: snapshot.type_modal.description, }, + event_banner: CreationEntryEventBannerResponse { + title: snapshot.event_banner.title, + description: snapshot.event_banner.description, + cover_image_src: snapshot.event_banner.cover_image_src, + prize_pool_mud_points: snapshot.event_banner.prize_pool_mud_points, + starts_at_text: snapshot.event_banner.starts_at_text, + ends_at_text: snapshot.event_banner.ends_at_text, + }, creation_types: snapshot .creation_types .into_iter() @@ -40,6 +48,9 @@ pub fn build_creation_entry_config_response( visible: item.visible, open: item.open, sort_order: item.sort_order, + category_id: item.category_id, + category_label: item.category_label, + category_sort_order: item.category_sort_order, updated_at_micros: item.updated_at_micros, }) .collect(), @@ -59,6 +70,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 10, + "recent", + "最近创作", + 10, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -70,6 +84,9 @@ pub fn default_creation_entry_type_snapshots( false, true, 20, + "recommended", + "热门推荐", + 20, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -81,6 +98,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 30, + "recent", + "最近创作", + 10, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -92,6 +112,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 40, + "recent", + "最近创作", + 10, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -103,6 +126,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 45, + "recommended", + "热门推荐", + 20, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -114,6 +140,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 47, + "festival", + "节日主题", + 30, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -125,6 +154,9 @@ pub fn default_creation_entry_type_snapshots( false, true, 50, + "material", + "材质工艺", + 60, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -136,6 +168,9 @@ pub fn default_creation_entry_type_snapshots( true, false, 60, + "scene", + "生活场景", + 50, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -147,6 +182,9 @@ pub fn default_creation_entry_type_snapshots( true, false, 70, + "character", + "角色创作", + 40, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -158,6 +196,9 @@ pub fn default_creation_entry_type_snapshots( false, true, 80, + "recommended", + "热门推荐", + 20, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -169,6 +210,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 85, + "recommended", + "热门推荐", + 20, updated_at_micros, ), build_default_creation_entry_type_snapshot( @@ -180,6 +224,9 @@ pub fn default_creation_entry_type_snapshots( true, true, 90, + "character", + "角色创作", + 40, updated_at_micros, ), ] @@ -195,6 +242,9 @@ fn build_default_creation_entry_type_snapshot( visible: bool, open: bool, sort_order: i32, + category_id: &str, + category_label: &str, + category_sort_order: i32, updated_at_micros: i64, ) -> CreationEntryTypeSnapshot { CreationEntryTypeSnapshot { @@ -206,6 +256,9 @@ fn build_default_creation_entry_type_snapshot( visible, open, sort_order, + category_id: category_id.to_string(), + category_label: category_label.to_string(), + category_sort_order, updated_at_micros, } } diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 4d1da0bc..bc7dff76 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -50,6 +50,15 @@ pub const DEFAULT_CREATION_ENTRY_START_IDLE_BADGE: &str = "模板 Tab"; pub const DEFAULT_CREATION_ENTRY_START_BUSY_BADGE: &str = "正在开启"; pub const DEFAULT_CREATION_ENTRY_MODAL_TITLE: &str = "选择创作类型"; pub const DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION: &str = "先选玩法类型,再进入对应创作工作台。"; +pub const DEFAULT_CREATION_ENTRY_CATEGORY_ID: &str = "recent"; +pub const DEFAULT_CREATION_ENTRY_CATEGORY_LABEL: &str = "最近创作"; +pub const DEFAULT_CREATION_ENTRY_EVENT_TITLE: &str = "主题创作赛"; +pub const DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION: &str = "用温暖的色彩,捏出秋天的故事。"; +pub const DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC: &str = + "/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png"; +pub const DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS: u64 = 58_000; +pub const DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT: &str = "2024.10.20 10:00"; +pub const DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT: &str = "2024.11.20 23:59"; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -67,6 +76,17 @@ pub struct CreationEntryTypeModalSnapshot { pub description: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreationEntryEventBannerSnapshot { + pub title: String, + pub description: String, + pub cover_image_src: String, + pub prize_pool_mud_points: u64, + pub starts_at_text: String, + pub ends_at_text: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CreationEntryTypeSnapshot { @@ -78,6 +98,9 @@ pub struct CreationEntryTypeSnapshot { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, pub updated_at_micros: i64, } @@ -87,6 +110,7 @@ pub struct CreationEntryConfigSnapshot { pub config_id: String, pub start_card: CreationEntryStartCardSnapshot, pub type_modal: CreationEntryTypeModalSnapshot, + pub event_banner: CreationEntryEventBannerSnapshot, pub creation_types: Vec, pub updated_at_micros: i64, } @@ -102,6 +126,9 @@ pub struct CreationEntryTypeAdminUpsertInput { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 5bbcd1b8..596ef888 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -230,6 +230,9 @@ mod tests { assert!(baby_object_match.open); assert_eq!(baby_object_match.badge, "可创建"); assert_eq!(baby_object_match.sort_order, 90); + assert_eq!(baby_object_match.category_id, "character"); + assert_eq!(baby_object_match.category_label, "角色创作"); + assert_eq!(baby_object_match.category_sort_order, 40); assert_eq!( baby_object_match.image_src, "/child-motion-demo/picture-book-grass-stage.png" @@ -250,6 +253,8 @@ mod tests { assert!(rpg.open); assert_eq!(rpg.badge, "可创建"); assert_eq!(rpg.sort_order, 10); + assert_eq!(rpg.category_id, "recent"); + assert_eq!(rpg.category_label, "最近创作"); assert_eq!(rpg.image_src, "/creation-type-references/rpg.webp"); } diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 8d0cb19e..b776f28c 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -30,6 +30,9 @@ pub struct AdminCreationEntryTypeConfigPayload { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, pub updated_at_micros: i64, } @@ -45,6 +48,9 @@ pub struct AdminUpsertCreationEntryTypeConfigRequest { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] diff --git a/server-rs/crates/shared-contracts/src/creation_entry_config.rs b/server-rs/crates/shared-contracts/src/creation_entry_config.rs index 1cebef29..c6664cbb 100644 --- a/server-rs/crates/shared-contracts/src/creation_entry_config.rs +++ b/server-rs/crates/shared-contracts/src/creation_entry_config.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; pub struct CreationEntryConfigResponse { pub start_card: CreationEntryStartCardResponse, pub type_modal: CreationEntryTypeModalResponse, + pub event_banner: CreationEntryEventBannerResponse, pub creation_types: Vec, } @@ -24,6 +25,17 @@ pub struct CreationEntryTypeModalResponse { pub description: String, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreationEntryEventBannerResponse { + pub title: String, + pub description: String, + pub cover_image_src: String, + pub prize_pool_mud_points: u64, + pub starts_at_text: String, + pub ends_at_text: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CreationEntryTypeResponse { @@ -35,5 +47,8 @@ pub struct CreationEntryTypeResponse { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime.rs b/server-rs/crates/spacetime-client/src/mapper/runtime.rs index 1a96e2c2..af558240 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime.rs @@ -11,6 +11,9 @@ impl From for CreationEntryTy visible: input.visible, open: input.open, sort_order: input.sort_order, + category_id: input.category_id, + category_label: input.category_label, + category_sort_order: input.category_sort_order, } } } @@ -151,6 +154,29 @@ pub(crate) fn build_creation_entry_config_record_from_rows( title: header.modal_title, description: header.modal_description, }, + event_banner: module_runtime::CreationEntryEventBannerSnapshot { + title: creation_entry_text_or_default( + header.event_title, + module_runtime::DEFAULT_CREATION_ENTRY_EVENT_TITLE, + ), + description: creation_entry_text_or_default( + header.event_description, + module_runtime::DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION, + ), + cover_image_src: creation_entry_text_or_default( + header.event_cover_image_src, + module_runtime::DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC, + ), + prize_pool_mud_points: header.event_prize_pool_mud_points, + starts_at_text: creation_entry_text_or_default( + header.event_starts_at_text, + module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT, + ), + ends_at_text: creation_entry_text_or_default( + header.event_ends_at_text, + module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT, + ), + }, creation_types: creation_types .into_iter() .map(|item| module_runtime::CreationEntryTypeSnapshot { @@ -162,6 +188,15 @@ pub(crate) fn build_creation_entry_config_record_from_rows( visible: item.visible, open: item.open, sort_order: item.sort_order, + category_id: creation_entry_text_or_default( + item.category_id, + module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID, + ), + category_label: creation_entry_text_or_default( + item.category_label, + module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL, + ), + category_sort_order: item.category_sort_order, updated_at_micros: item.updated_at.to_micros_since_unix_epoch(), }) .collect(), @@ -185,6 +220,14 @@ fn map_creation_entry_config_snapshot( title: snapshot.type_modal.title, description: snapshot.type_modal.description, }, + event_banner: module_runtime::CreationEntryEventBannerSnapshot { + title: snapshot.event_banner.title, + description: snapshot.event_banner.description, + cover_image_src: snapshot.event_banner.cover_image_src, + prize_pool_mud_points: snapshot.event_banner.prize_pool_mud_points, + starts_at_text: snapshot.event_banner.starts_at_text, + ends_at_text: snapshot.event_banner.ends_at_text, + }, creation_types: snapshot .creation_types .into_iter() @@ -197,6 +240,9 @@ fn map_creation_entry_config_snapshot( visible: item.visible, open: item.open, sort_order: item.sort_order, + category_id: item.category_id, + category_label: item.category_label, + category_sort_order: item.category_sort_order, updated_at_micros: item.updated_at_micros, }) .collect(), @@ -204,6 +250,13 @@ fn map_creation_entry_config_snapshot( } } +fn creation_entry_text_or_default(value: Option, default_value: &str) -> String { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| default_value.to_string()) +} + pub(crate) fn map_runtime_setting_procedure_result( result: RuntimeSettingProcedureResult, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 5f9f3392..3aa8dc89 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -234,6 +234,7 @@ pub mod creation_entry_config_procedure_result_type; pub mod creation_entry_config_snapshot_type; pub mod creation_entry_config_table; pub mod creation_entry_config_type; +pub mod creation_entry_event_banner_snapshot_type; pub mod creation_entry_start_card_snapshot_type; pub mod creation_entry_type_admin_upsert_input_type; pub mod creation_entry_type_config_table; @@ -1262,6 +1263,7 @@ pub use creation_entry_config_procedure_result_type::CreationEntryConfigProcedur pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot; pub use creation_entry_config_table::*; pub use creation_entry_config_type::CreationEntryConfig; +pub use creation_entry_event_banner_snapshot_type::CreationEntryEventBannerSnapshot; pub use creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot; pub use creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput; pub use creation_entry_type_config_table::*; @@ -3285,10 +3287,6 @@ impl __sdk::DbUpdate for DbUpdate { &self.visual_novel_work_profile, ) .with_updates_by_pk(|row| &row.profile_id); - diff.bark_battle_gallery_view = cache.apply_diff_to_table::( - "bark_battle_gallery_view", - &self.bark_battle_gallery_view, - ); diff.wooden_fish_agent_session = cache .apply_diff_to_table::( "wooden_fish_agent_session", @@ -3310,6 +3308,10 @@ impl __sdk::DbUpdate for DbUpdate { &self.wooden_fish_work_profile, ) .with_updates_by_pk(|row| &row.profile_id); + diff.bark_battle_gallery_view = cache.apply_diff_to_table::( + "bark_battle_gallery_view", + &self.bark_battle_gallery_view, + ); diff.big_fish_gallery_view = cache.apply_diff_to_table::( "big_fish_gallery_view", &self.big_fish_gallery_view, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs index 424caf89..0679a45a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_snapshot_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::creation_entry_event_banner_snapshot_type::CreationEntryEventBannerSnapshot; use super::creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot; use super::creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot; use super::creation_entry_type_snapshot_type::CreationEntryTypeSnapshot; @@ -14,6 +15,7 @@ pub struct CreationEntryConfigSnapshot { pub config_id: String, pub start_card: CreationEntryStartCardSnapshot, pub type_modal: CreationEntryTypeModalSnapshot, + pub event_banner: CreationEntryEventBannerSnapshot, pub creation_types: Vec, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_type.rs index e06f35f4..c3234d1d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_config_type.rs @@ -15,6 +15,12 @@ pub struct CreationEntryConfig { pub modal_title: String, pub modal_description: String, pub updated_at: __sdk::Timestamp, + pub event_title: Option, + pub event_description: Option, + pub event_cover_image_src: Option, + pub event_prize_pool_mud_points: u64, + pub event_starts_at_text: Option, + pub event_ends_at_text: Option, } impl __sdk::InModule for CreationEntryConfig { @@ -33,6 +39,12 @@ pub struct CreationEntryConfigCols { pub modal_title: __sdk::__query_builder::Col, pub modal_description: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub event_title: __sdk::__query_builder::Col>, + pub event_description: __sdk::__query_builder::Col>, + pub event_cover_image_src: __sdk::__query_builder::Col>, + pub event_prize_pool_mud_points: __sdk::__query_builder::Col, + pub event_starts_at_text: __sdk::__query_builder::Col>, + pub event_ends_at_text: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for CreationEntryConfig { @@ -47,6 +59,21 @@ impl __sdk::__query_builder::HasCols for CreationEntryConfig { modal_title: __sdk::__query_builder::Col::new(table_name, "modal_title"), modal_description: __sdk::__query_builder::Col::new(table_name, "modal_description"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + event_title: __sdk::__query_builder::Col::new(table_name, "event_title"), + event_description: __sdk::__query_builder::Col::new(table_name, "event_description"), + event_cover_image_src: __sdk::__query_builder::Col::new( + table_name, + "event_cover_image_src", + ), + event_prize_pool_mud_points: __sdk::__query_builder::Col::new( + table_name, + "event_prize_pool_mud_points", + ), + event_starts_at_text: __sdk::__query_builder::Col::new( + table_name, + "event_starts_at_text", + ), + event_ends_at_text: __sdk::__query_builder::Col::new(table_name, "event_ends_at_text"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_event_banner_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_event_banner_snapshot_type.rs new file mode 100644 index 00000000..9d60ef30 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_event_banner_snapshot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreationEntryEventBannerSnapshot { + pub title: String, + pub description: String, + pub cover_image_src: String, + pub prize_pool_mud_points: u64, + pub starts_at_text: String, + pub ends_at_text: String, +} + +impl __sdk::InModule for CreationEntryEventBannerSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_admin_upsert_input_type.rs index b2e7eccc..ae561402 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_admin_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_admin_upsert_input_type.rs @@ -15,6 +15,9 @@ pub struct CreationEntryTypeAdminUpsertInput { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, } impl __sdk::InModule for CreationEntryTypeAdminUpsertInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_type.rs index c6290d03..6fa8c5ed 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_config_type.rs @@ -16,6 +16,9 @@ pub struct CreationEntryTypeConfig { pub open: bool, pub sort_order: i32, pub updated_at: __sdk::Timestamp, + pub category_id: Option, + pub category_label: Option, + pub category_sort_order: i32, } impl __sdk::InModule for CreationEntryTypeConfig { @@ -35,6 +38,9 @@ pub struct CreationEntryTypeConfigCols { pub open: __sdk::__query_builder::Col, pub sort_order: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub category_id: __sdk::__query_builder::Col>, + pub category_label: __sdk::__query_builder::Col>, + pub category_sort_order: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig { @@ -50,6 +56,12 @@ impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig { open: __sdk::__query_builder::Col::new(table_name, "open"), sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + category_id: __sdk::__query_builder::Col::new(table_name, "category_id"), + category_label: __sdk::__query_builder::Col::new(table_name, "category_label"), + category_sort_order: __sdk::__query_builder::Col::new( + table_name, + "category_sort_order", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_snapshot_type.rs index 814edd78..51d87596 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/creation_entry_type_snapshot_type.rs @@ -15,6 +15,9 @@ pub struct CreationEntryTypeSnapshot { pub visible: bool, pub open: bool, pub sort_order: i32, + pub category_id: String, + pub category_label: String, + pub category_sort_order: i32, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index c2b2bc4b..a591d1fd 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1159,6 +1159,43 @@ where fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value { let mut next_value = value.clone(); + if table_name == "creation_entry_config" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:入口活动横幅字段晚于创作入口配置表加入,旧迁移包按运行态默认横幅兼容。 + object + .entry("event_title".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("event_description".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("event_cover_image_src".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("event_prize_pool_mud_points".to_string()) + .or_insert_with(|| serde_json::Value::from(58_000)); + object + .entry("event_starts_at_text".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("event_ends_at_text".to_string()) + .or_insert(serde_json::Value::Null); + } + } + if table_name == "creation_entry_type_config" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:入口分类字段晚于入口类型配置表加入,旧迁移包按未分类兼容。 + object + .entry("category_id".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("category_label".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("category_sort_order".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + } + } if table_name == "user_account" { if let Some(object) = next_value.as_object_mut() { // 中文注释:头像字段晚于认证拆表加入,旧迁移包按未设置头像兼容。 diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs index 0ce27567..3ee5e0cf 100644 --- a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -11,6 +11,18 @@ pub struct CreationEntryConfig { pub(crate) modal_title: String, pub(crate) modal_description: String, pub(crate) updated_at: Timestamp, + #[default(None::)] + pub(crate) event_title: Option, + #[default(None::)] + pub(crate) event_description: Option, + #[default(None::)] + pub(crate) event_cover_image_src: Option, + #[default(DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS)] + pub(crate) event_prize_pool_mud_points: u64, + #[default(None::)] + pub(crate) event_starts_at_text: Option, + #[default(None::)] + pub(crate) event_ends_at_text: Option, } #[spacetimedb::table( @@ -28,6 +40,12 @@ pub struct CreationEntryTypeConfig { pub(crate) open: bool, pub(crate) sort_order: i32, pub(crate) updated_at: Timestamp, + #[default(None::)] + pub(crate) category_id: Option, + #[default(None::)] + pub(crate) category_label: Option, + #[default(0)] + pub(crate) category_sort_order: i32, } #[spacetimedb::procedure] @@ -88,6 +106,9 @@ fn upsert_creation_entry_type_config_in_tx( open: input.open, sort_order: input.sort_order, updated_at: now, + category_id: Some(normalize_category_id(&input.category_id)), + category_label: Some(normalize_category_label(&input.category_label)), + category_sort_order: input.category_sort_order, }; if ctx.db.creation_entry_type_config().id().find(&id).is_some() { ctx.db.creation_entry_type_config().id().update(row); @@ -120,6 +141,9 @@ fn get_or_seed_creation_entry_config_snapshot( visible: row.visible, open: row.open, sort_order: row.sort_order, + category_id: normalize_optional_category_id(row.category_id.as_deref()), + category_label: normalize_optional_category_label(row.category_label.as_deref()), + category_sort_order: row.category_sort_order, updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), }) .collect::>(); @@ -141,6 +165,29 @@ fn get_or_seed_creation_entry_config_snapshot( title: header.modal_title, description: header.modal_description, }, + event_banner: CreationEntryEventBannerSnapshot { + title: normalize_optional_text( + header.event_title.as_deref(), + DEFAULT_CREATION_ENTRY_EVENT_TITLE, + ), + description: normalize_optional_text( + header.event_description.as_deref(), + DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION, + ), + cover_image_src: normalize_optional_text( + header.event_cover_image_src.as_deref(), + DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC, + ), + prize_pool_mud_points: header.event_prize_pool_mud_points, + starts_at_text: normalize_optional_text( + header.event_starts_at_text.as_deref(), + DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT, + ), + ends_at_text: normalize_optional_text( + header.event_ends_at_text.as_deref(), + DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT, + ), + }, creation_types, updated_at_micros: header.updated_at.to_micros_since_unix_epoch(), }) @@ -164,6 +211,12 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { modal_title: DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(), modal_description: DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(), updated_at: now, + event_title: Some(DEFAULT_CREATION_ENTRY_EVENT_TITLE.to_string()), + event_description: Some(DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION.to_string()), + event_cover_image_src: Some(DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC.to_string()), + event_prize_pool_mud_points: DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS, + event_starts_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string()), + event_ends_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string()), }); } @@ -348,6 +401,43 @@ fn default_creation_entry_type_configs(now: Timestamp) -> Vec String { + let normalized = value.trim(); + if normalized.is_empty() { + DEFAULT_CREATION_ENTRY_CATEGORY_ID.to_string() + } else { + normalized.to_string() + } +} + +fn normalize_category_label(value: &str) -> String { + let normalized = value.trim(); + if normalized.is_empty() { + DEFAULT_CREATION_ENTRY_CATEGORY_LABEL.to_string() + } else { + normalized.to_string() + } +} + +fn normalize_optional_category_id(value: Option<&str>) -> String { + normalize_optional_text(value, DEFAULT_CREATION_ENTRY_CATEGORY_ID) +} + +fn normalize_optional_category_label(value: Option<&str>) -> String { + normalize_optional_text(value, DEFAULT_CREATION_ENTRY_CATEGORY_LABEL) +} + +fn normalize_optional_text(value: Option<&str>, fallback: &str) -> String { + value + .map(str::trim) + .filter(|normalized| !normalized.is_empty()) + .unwrap_or(fallback) + .to_string() +} diff --git a/src/components/CustomWorldGenerationView.test.tsx b/src/components/CustomWorldGenerationView.test.tsx index 0e589b13..82ee365c 100644 --- a/src/components/CustomWorldGenerationView.test.tsx +++ b/src/components/CustomWorldGenerationView.test.tsx @@ -52,9 +52,9 @@ function createProgress( describe('CustomWorldGenerationView', () => { test.each(['拼图草稿生成进度', '抓大鹅草稿生成进度'])( - 'hides batch module and keeps wait/timer in one row for %s', + 'renders the circular hero and only the current step summary for %s', (progressTitle) => { - render( + const { container } = render( { onBack={() => {}} onEditSetting={() => {}} onRetry={() => {}} + backLabel="返回创作中心" settingDescription={null} settingActionLabel={null} progressTitle={progressTitle} />, ); + expect(container.firstChild).toBeTruthy(); + expect((container.firstChild as HTMLElement).className).toContain( + 'z-[1]', + ); + + const pageVideo = screen.getByTestId( + 'generation-page-background-video', + ) as HTMLVideoElement; + expect(pageVideo.parentElement?.className).toContain('z-0'); + expect(pageVideo.parentElement?.className).toContain('bg-transparent'); + expect(pageVideo.parentElement?.className).not.toContain('bg-[#fff4ea]'); + expect((container.firstChild as HTMLElement).contains(pageVideo)).toBe( + true, + ); + expect(pageVideo.autoplay).toBe(true); + expect(pageVideo.loop).toBe(true); + expect(pageVideo.muted).toBe(true); + expect(pageVideo.playsInline).toBe(true); + expect(pageVideo.getAttribute('preload')).toBe('auto'); + expect( + document.querySelector( + 'video[data-testid="generation-page-background-video"] source[type="video/mp4"]', + ), + ).toBeTruthy(); + expect( + screen.getByRole('button', { name: '返回创作中心' }), + ).toBeTruthy(); + expect( + screen.getByRole('button', { name: '返回创作中心' }).className, + ).toContain('text-xs'); + expect(screen.getByText('世界建设中')).toBeTruthy(); + expect(screen.getByText('世界建设中').className).toContain('text-xs'); + expect(screen.getByTestId('generation-hero-wait-card').className).toContain( + 'text-center', + ); + expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain( + 'text-center', + ); + expect(screen.getByTestId('generation-hero-wait-card').className).toContain( + 'bg-white/58', + ); + expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain( + 'bg-white/58', + ); + expect(screen.getByText('预计等待').className).toContain('text-[9px]'); + expect(screen.getByText('已耗时').className).toContain('text-[9px]'); + expect(screen.getByText('预计等待').parentElement?.className).toContain( + 'justify-center', + ); + expect(screen.getByText('已耗时').parentElement?.className).toContain( + 'justify-center', + ); + expect(screen.getByText('1 分 15 秒')).toBeTruthy(); + expect(screen.getByText('2 分 5 秒')).toBeTruthy(); + expect(screen.queryByText('预计还需 1 分 15 秒')).toBeNull(); + expect(screen.queryByText('已耗时 2 分 5 秒')).toBeNull(); + expect(screen.queryByText('计时')).toBeNull(); + expect(screen.getByTestId('generation-hero-progress-content').className).toContain( + 'justify-start', + ); + expect(screen.getByTestId('generation-hero-progress-content').className).toContain( + 'pt-[4%]', + ); + expect(screen.getByText('总进度').className).toContain('text-[9px]'); + expect(screen.getByText('42%').className).toContain('text-[1.15rem]'); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .className, + ).toContain('w-[min(35rem,94vw)]'); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .className, + ).toContain('sm:w-[52rem]'); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .getAttribute('data-ring-start-degrees'), + ).toBe('225'); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .getAttribute('data-ring-sweep-degrees'), + ).toBe('270'); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .getAttribute('data-ring-gap-degrees'), + ).toBe('90'); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .getAttribute('data-ring-fill-degrees'), + ).toBe('113'); + expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe( + 'svg', + ); + expect( + screen + .getByTestId('generation-hero-progress-ring') + .getAttribute('viewBox'), + ).toBe('0 0 400 400'); + expect( + screen + .getByTestId('generation-hero-progress-ring-track') + .getAttribute('r'), + ).toBe('166'); + expect( + screen + .getByTestId('generation-hero-progress-ring-track') + .getAttribute('stroke-width'), + ).toBe('18'); + expect( + screen + .getByTestId('generation-hero-progress-ring-fill') + .getAttribute('stroke-dasharray'), + ).toMatch(/^328\.\d{2} 1043\.\d{2}$/u); + expect( + screen.getByRole('progressbar', { name: progressTitle }), + ).toBeTruthy(); + expect( + screen + .getByRole('progressbar', { name: progressTitle }) + .getAttribute('aria-valuenow'), + ).toBe('42'); + expect(screen.getByText('当前步骤')).toBeTruthy(); + expect(screen.getByText('当前步骤').className).toContain('text-[10px]'); + expect(screen.getByText('编译草稿')).toBeTruthy(); + expect(screen.getByText('编译草稿').className).toContain('text-[14px]'); + expect(screen.getByText('进行中 50%')).toBeTruthy(); + expect(screen.getByText('进行中 50%').className).toContain('text-[11px]'); + expect( + screen.getByTestId('generation-current-step-card').className, + ).toContain('bg-white/58'); + expect( + screen.getByRole('progressbar', { name: '编译草稿 进度' }), + ).toBeTruthy(); + expect(screen.queryByText('收集设定')).toBeNull(); + expect(screen.queryByText('写回结果')).toBeNull(); expect(screen.queryByText('当前批次')).toBeNull(); - expect(screen.getByText('预计等待')).toBeTruthy(); - expect(screen.getByText('计时')).toBeTruthy(); - - const statsNode = screen - .getByText('预计等待') - .closest('.custom-world-generation-stats'); - expect(statsNode?.className).toContain( - 'custom-world-generation-stats--two-column', - ); - expect(statsNode?.getAttribute('style')).toContain( - 'grid-template-columns: repeat(2, minmax(0, 1fr))', - ); - - const stepNodes = [ - screen.getByText('收集设定'), - screen.getByText('编译草稿'), - screen.getByText('写回结果'), - ].map((node) => node.closest('.custom-world-generation-step')); - - expect(stepNodes.every(Boolean)).toBe(true); - expect(stepNodes[0]?.getAttribute('style')).toContain( - '--generation-step-delay: 0ms', - ); - expect(stepNodes[1]?.getAttribute('style')).toContain( - '--generation-step-delay: 90ms', - ); - expect(stepNodes[2]?.getAttribute('style')).toContain( - '--generation-step-delay: 180ms', - ); + expect(screen.queryByText('正在整理当前设定步骤')).toBeNull(); }, ); - test('keeps batch module for other generation pages', () => { + test('keeps the setting information panel as compact information cards', () => { render( {}} onEditSetting={() => {}} onRetry={() => {}} + backLabel="返回创作中心" settingDescription={null} settingActionLabel={null} + settingTitle="当前大鱼吃小鱼信息" progressTitle="大鱼吃小鱼草稿生成进度" />, ); - expect(screen.getByText('当前批次')).toBeTruthy(); - expect( - screen - .getByText('预计等待') - .closest('.custom-world-generation-stats') - ?.className, - ).not.toContain('custom-world-generation-stats--two-column'); + expect(screen.getByText('当前大鱼吃小鱼信息')).toBeTruthy(); + expect(screen.getByText('当前大鱼吃小鱼信息').className).toContain('text-[13px]'); + expect(screen.getByText('题材')).toBeTruthy(); + expect(screen.getByText('题材').className).toContain('text-[9px]'); + expect(screen.getByText('火锅')).toBeTruthy(); + expect(screen.getByText('火锅').className).toContain('text-[13px]'); + expect(screen.getByText('素材数量')).toBeTruthy(); + expect(screen.getByText('20 种素材')).toBeTruthy(); + expect(screen.queryByText('大鱼吃小鱼题材')).toBeNull(); + expect(screen.getByTestId('generation-page-background-video')).toBeTruthy(); }); }); diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index b540a4f0..9a00c842 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -1,9 +1,12 @@ -import { motion } from 'motion/react'; -import type { CSSProperties } from 'react'; -import { useEffect, useState } from 'react'; +import { ArrowLeft } from 'lucide-react'; import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime'; import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress'; +import { + GenerationCurrentStepCard, + GenerationPageBackdrop, + GenerationProgressHero, +} from './GenerationProgressHero'; interface CustomWorldGenerationViewProps { settingText: string; @@ -81,6 +84,19 @@ function getStepStatusLabel(step: { status: string }) { return '待处理'; } +function resolveCurrentGenerationStep( + progress: CustomWorldGenerationProgress | null, +) { + const steps = progress?.steps ?? []; + return ( + steps.find((step) => step.status === 'active') ?? + steps[progress?.activeStepIndex ?? -1] ?? + steps.find((step) => step.status === 'pending') ?? + steps.at(-1) ?? + null + ); +} + function buildFallbackRenderKey( value: string | null | undefined, fallback: string, @@ -89,49 +105,6 @@ function buildFallbackRenderKey( return normalizedValue ? normalizedValue : fallback; } -function useIsMobileGenerationLayout() { - const [isMobile, setIsMobile] = useState(() => { - if ( - typeof window === 'undefined' || - typeof window.matchMedia !== 'function' - ) { - return false; - } - - return window.matchMedia('(max-width: 639px)').matches; - }); - - useEffect(() => { - if ( - typeof window === 'undefined' || - typeof window.matchMedia !== 'function' - ) { - return undefined; - } - - const mediaQuery = window.matchMedia('(max-width: 639px)'); - const syncMobileLayout = () => { - setIsMobile(mediaQuery.matches); - }; - - syncMobileLayout(); - - if (typeof mediaQuery.addEventListener === 'function') { - mediaQuery.addEventListener('change', syncMobileLayout); - return () => { - mediaQuery.removeEventListener('change', syncMobileLayout); - }; - } - - mediaQuery.addListener(syncMobileLayout); - return () => { - mediaQuery.removeListener(syncMobileLayout); - }; - }, []); - - return isMobile; -} - export function CustomWorldGenerationView({ settingText, anchorEntries = [], @@ -155,42 +128,47 @@ export function CustomWorldGenerationView({ structuredEmptyText = '正在整理当前设定结构,请稍后。', hideBatchModule = false, }: CustomWorldGenerationViewProps) { - const isMobileGenerationLayout = useIsMobileGenerationLayout(); + void hideBatchModule; const progressValue = getProgressPercentage(progress); - const steps = progress?.steps ?? []; + const currentStep = resolveCurrentGenerationStep(progress); + const currentStepProgress = currentStep + ? getStepProgressPercentage(currentStep) + : progressValue; + const currentStepLabel = currentStep?.label ?? progress?.phaseLabel ?? '准备生成'; + const currentStepStatusLabel = currentStep + ? getStepStatusLabel(currentStep) + : isGenerating + ? '进行中' + : '待处理'; const hasStructuredAnchors = anchorEntries.length > 0; // 允许不同生成场景按需隐藏第二模块的说明和次级返回动作。 const normalizedSettingActionLabel = settingActionLabel?.trim() ?? ''; const normalizedSettingDescription = settingDescription?.trim() ?? ''; const hasSettingActionLabel = normalizedSettingActionLabel.length > 0; const hasSettingDescription = normalizedSettingDescription.length > 0; - const shouldHideBatchModule = - hideBatchModule || - progressTitle === '拼图草稿生成进度' || - progressTitle === '抓大鹅草稿生成进度'; const estimatedWaitText = progress?.estimatedRemainingMs != null - ? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}` - : '正在校准预计等待时间'; + ? formatDuration(progress.estimatedRemainingMs) + : '校准中'; const elapsedText = - progress != null - ? `已耗时 ${formatDuration(progress.elapsedMs)}` - : '正在启动世界生成'; + progress != null ? formatDuration(progress.elapsedMs) : '启动中'; return (
-
+ +
-
+
{isGenerating ? activeBadgeLabel : error @@ -199,143 +177,26 @@ export function CustomWorldGenerationView({
-
-
-
-
-
- {progressTitle} -
-
- {progress?.phaseLabel ?? '正在启动世界生成'} -
-
- {progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'} -
-
-
-
- 总进度 -
-
- {progressValue}% -
-
-
+
+
+ -
- +
-
- {shouldHideBatchModule ? null : ( -
-
- 当前批次 -
-
- {progress?.batchLabel ?? '准备中'} -
-
- )} -
-
- 预计等待 -
-
- {estimatedWaitText} -
-
-
-
- 计时 -
-
- {elapsedText} -
-
-
- -
- {steps.map((step, index) => { - const stepProgress = getStepProgressPercentage(step); - - return ( - -
-
- {step.label} -
-
- {getStepStatusLabel(step)} {stepProgress}% -
-
-
- -
-
- {step.detail} -
-
- ); - })} -
- {error ? ( -
+
{error}
) : null} @@ -372,14 +233,14 @@ export function CustomWorldGenerationView({
-
-
+
+
-
+
{settingTitle}
{hasSettingDescription ? ( -
+
{normalizedSettingDescription}
) : null} @@ -396,26 +257,26 @@ export function CustomWorldGenerationView({ ) : null}
{hasStructuredAnchors ? ( -
+
{anchorEntries.map((entry, index) => (
-
+
{entry.label}
-
+
{entry.value}
))}
) : ( -
+
{settingText || structuredEmptyText}
)} diff --git a/src/components/GenerationProgressHero.tsx b/src/components/GenerationProgressHero.tsx new file mode 100644 index 00000000..9883d96c --- /dev/null +++ b/src/components/GenerationProgressHero.tsx @@ -0,0 +1,289 @@ +import { Clock3, Hourglass } from 'lucide-react'; +import { motion } from 'motion/react'; +import { useEffect, useId, useRef } from 'react'; + +import generationHeroVideo from '../../media/create_bg_video.mp4'; + +const GENERATION_PROGRESS_RING_START_DEGREES = 225; +const GENERATION_PROGRESS_RING_SWEEP_DEGREES = 270; +const GENERATION_PROGRESS_RING_VIEWBOX = 400; +const GENERATION_PROGRESS_RING_CENTER = GENERATION_PROGRESS_RING_VIEWBOX / 2; +const GENERATION_PROGRESS_RING_RADIUS = 166; +const GENERATION_PROGRESS_RING_STROKE_WIDTH = 18; +const GENERATION_PROGRESS_RING_SWEEP_RATIO = + GENERATION_PROGRESS_RING_SWEEP_DEGREES / 360; + +type GenerationProgressHeroProps = { + title: string; + phaseLabel: string; + progressValue: number; + estimatedWaitText: string; + elapsedText: string; +}; + +type GenerationCurrentStepCardProps = { + label: string; + statusLabel: string; + progressValue: number; +}; + +function clampGenerationProgress(value: number) { + return Math.max(0, Math.min(100, Math.round(value))); +} + +function buildGenerationRingMetrics(progressValue: number) { + const circumference = 2 * Math.PI * GENERATION_PROGRESS_RING_RADIUS; + const sweepLength = circumference * GENERATION_PROGRESS_RING_SWEEP_RATIO; + const progressLength = sweepLength * (progressValue / 100); + + return { + circumference, + progressLength, + sweepLength, + }; +} + +export function GenerationPageBackdrop() { + const videoRef = useRef(null); + + useEffect(() => { + const video = videoRef.current; + if (!video) { + return undefined; + } + + video.defaultMuted = true; + video.muted = true; + video.volume = 0; + + const isJsdom = + window.navigator.userAgent.toLowerCase().includes('jsdom'); + const tryPlay = () => { + if (isJsdom) { + return; + } + try { + const playPromise = video.play(); + if (playPromise && typeof playPromise.then === 'function') { + void playPromise.catch(() => {}); + } + } catch { + // 中文注释:测试环境和某些内核可能同步拒绝 play;失败时保留静音背景层,不阻断页面渲染。 + } + }; + + tryPlay(); + video.addEventListener('loadeddata', tryPlay); + video.addEventListener('canplay', tryPlay); + video.addEventListener('playing', tryPlay); + window.addEventListener('focus', tryPlay); + document.addEventListener('visibilitychange', tryPlay); + + return () => { + video.removeEventListener('loadeddata', tryPlay); + video.removeEventListener('canplay', tryPlay); + video.removeEventListener('playing', tryPlay); + window.removeEventListener('focus', tryPlay); + document.removeEventListener('visibilitychange', tryPlay); + }; + }, []); + + return ( +
+ +
+
+ ); +} + +export function GenerationProgressHero({ + title, + phaseLabel, + progressValue, + estimatedWaitText, + elapsedText, +}: GenerationProgressHeroProps) { + const safeProgress = clampGenerationProgress(progressValue); + const ringGradientId = useId().replace(/:/g, ''); + const ringMetrics = buildGenerationRingMetrics(safeProgress); + const ringDegrees = Math.round((safeProgress / 100) * 270); + const ringTrackDasharray = `${ringMetrics.sweepLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`; + const ringFillDasharray = `${ringMetrics.progressLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`; + + return ( +
+
+ {title} + {phaseLabel ? ` ${phaseLabel}` : ''} +
+
+
+
+ +
+ 预计等待 +
+
+
+ {estimatedWaitText} +
+
+ +
+
+
+ 已耗时 +
+ +
+
+ {elapsedText} +
+
+ +
+ +
+
+ 总进度 +
+
+ {safeProgress}% +
+
+
+
+
+ ); +} + +export function GenerationCurrentStepCard({ + label, + statusLabel, + progressValue, +}: GenerationCurrentStepCardProps) { + const safeProgress = clampGenerationProgress(progressValue); + const isActive = statusLabel === '进行中'; + + return ( +
+
+
+
+ 当前步骤 +
+
+ {label} +
+
+
+
+ {statusLabel} {safeProgress}% +
+ {isActive ? ( +
+
+
+ +
+
+ ); +} diff --git a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx index 9a341f2b..02af134b 100644 --- a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx +++ b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx @@ -50,7 +50,7 @@ describe('BarkBattleGeneratingView', () => { updatedAt: '2026-05-14T10:01:00.000Z', }); - render( + const { container } = render( {}} @@ -59,9 +59,142 @@ describe('BarkBattleGeneratingView', () => { />, ); + expect(container.firstChild).toBeTruthy(); + expect((container.firstChild as HTMLElement).className).toContain('z-[1]'); + expect(screen.getByText('总进度')).toBeTruthy(); + expect(screen.getByText('总进度').className).toContain('text-[9px]'); + const pageVideo = screen.getByTestId( + 'generation-page-background-video', + ) as HTMLVideoElement; + expect(pageVideo.parentElement?.className).toContain('z-0'); + expect(pageVideo.parentElement?.className).toContain('bg-transparent'); + expect(pageVideo.parentElement?.className).not.toContain('bg-[#fff4ea]'); + expect((container.firstChild as HTMLElement).contains(pageVideo)).toBe( + true, + ); + expect(pageVideo.autoplay).toBe(true); + expect(pageVideo.loop).toBe(true); + expect(pageVideo.muted).toBe(true); + expect(pageVideo.playsInline).toBe(true); + expect(pageVideo.getAttribute('preload')).toBe('auto'); + expect( + document.querySelector( + 'video[data-testid="generation-page-background-video"] source[type="video/mp4"]', + ), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: '返回编辑' }).className).toContain( + 'text-xs', + ); + expect(screen.getByText('生成中').className).toContain('text-[11px]'); + expect(screen.getByText('当前步骤')).toBeTruthy(); + expect(screen.getByText('当前步骤').className).toContain('text-[10px]'); + expect(screen.getByTestId('generation-hero-wait-card').className).toContain( + 'text-center', + ); + expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain( + 'text-center', + ); + expect(screen.getByTestId('generation-hero-wait-card').className).toContain( + 'bg-white/58', + ); + expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain( + 'bg-white/58', + ); + expect(screen.getByText('预计等待').className).toContain('text-[9px]'); + expect(screen.getByText('已耗时').className).toContain('text-[9px]'); + expect(screen.getByText('预计等待').parentElement?.className).toContain( + 'justify-center', + ); + expect(screen.getByText('已耗时').parentElement?.className).toContain( + 'justify-center', + ); + expect(screen.getByText('3 分钟')).toBeTruthy(); + expect(screen.getByText('1 秒')).toBeTruthy(); + expect(screen.queryByText('预计还需 3 分钟')).toBeNull(); + expect(screen.queryByText('已耗时 1 秒')).toBeNull(); + expect(screen.getByTestId('generation-hero-progress-content').className).toContain( + 'justify-start', + ); + expect(screen.getByTestId('generation-hero-progress-content').className).toContain( + 'pt-[4%]', + ); expect(screen.getByText('玩家形象')).toBeTruthy(); - expect(screen.getByText('对手形象')).toBeTruthy(); - expect(screen.getByText('竞技背景')).toBeTruthy(); + expect(screen.getByText('进行中 36%')).toBeTruthy(); + expect(screen.getByText('进行中 36%').className).toContain('text-[11px]'); + expect(screen.getByText('总进度').className).toContain('text-[9px]'); + expect(screen.getByText('0%').className).toContain('text-[1.15rem]'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .className, + ).toContain('w-[min(35rem,94vw)]'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .className, + ).toContain('sm:w-[52rem]'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .getAttribute('aria-valuenow'), + ).toBe('0'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .getAttribute('data-ring-start-degrees'), + ).toBe('225'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .getAttribute('data-ring-sweep-degrees'), + ).toBe('270'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .getAttribute('data-ring-gap-degrees'), + ).toBe('90'); + expect( + screen + .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) + .getAttribute('data-ring-fill-degrees'), + ).toBe('0'); + expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe( + 'svg', + ); + expect( + screen + .getByTestId('generation-hero-progress-ring') + .getAttribute('viewBox'), + ).toBe('0 0 400 400'); + expect( + screen + .getByTestId('generation-hero-progress-ring-track') + .getAttribute('r'), + ).toBe('166'); + expect( + screen + .getByTestId('generation-hero-progress-ring-track') + .getAttribute('stroke-width'), + ).toBe('18'); + expect( + screen + .getByTestId('generation-hero-progress-ring-fill') + .getAttribute('stroke-dasharray'), + ).toMatch(/^0\.00 1043\.\d{2}$/u); + expect( + screen.getByRole('progressbar', { name: '玩家形象 进度' }), + ).toBeTruthy(); + expect( + screen + .getByRole('progressbar', { name: '玩家形象 进度' }) + .getAttribute('aria-valuenow'), + ).toBe('36'); + expect( + screen.getByTestId('generation-current-step-card').className, + ).toContain('bg-white/58'); + expect(screen.getByText('预览信息').className).toContain('text-[13px]'); + expect(screen.queryByText('对手形象')).toBeNull(); + expect(screen.queryByText('竞技背景')).toBeNull(); expect(onComplete).not.toHaveBeenCalled(); resolveGeneration({ diff --git a/src/components/bark-battle-creation/BarkBattleGeneratingView.tsx b/src/components/bark-battle-creation/BarkBattleGeneratingView.tsx index b1402bea..2a1a293f 100644 --- a/src/components/bark-battle-creation/BarkBattleGeneratingView.tsx +++ b/src/components/bark-battle-creation/BarkBattleGeneratingView.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, ArrowLeft, CheckCircle2, Loader2, Sparkles } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle'; @@ -12,6 +12,11 @@ import { generateAllBarkBattleImageAssets, updateBarkBattleDraftConfig, } from '../../services/bark-battle-creation'; +import { + GenerationCurrentStepCard, + GenerationPageBackdrop, + GenerationProgressHero, +} from '../GenerationProgressHero'; import { BarkBattlePreviewCard } from './BarkBattlePreviewCard'; type BarkBattleGeneratingViewProps = { @@ -110,6 +115,56 @@ function buildDraftGenerationKey(draft: BarkBattleDraftConfig) { ].join('|'); } +function getSlotStatusLabel(status: BarkBattleGeneratingSlotStatus) { + if (status === 'ready') { + return '完成'; + } + if (status === 'failed') { + return '失败'; + } + return '进行中'; +} + +function formatGenerationDuration(ms: number) { + const totalSeconds = Math.max(1, Math.ceil(Math.max(0, ms) / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes <= 0) { + return `${seconds} 秒`; + } + + if (seconds === 0) { + return `${minutes} 分钟`; + } + + return `${minutes} 分 ${seconds} 秒`; +} + +function resolveBarkBattleProgressValue( + slotStatuses: Partial< + Record + >, +) { + const readyCount = GENERATION_STEPS.filter( + (step) => slotStatuses[step.slot] === 'ready', + ).length; + return Math.round((readyCount / GENERATION_STEPS.length) * 100); +} + +function resolveCurrentBarkBattleStep( + slotStatuses: Partial< + Record + >, +) { + return ( + GENERATION_STEPS.find((step) => slotStatuses[step.slot] === 'generating') ?? + GENERATION_STEPS.find((step) => slotStatuses[step.slot] === 'failed') ?? + GENERATION_STEPS.find((step) => slotStatuses[step.slot] !== 'ready') ?? + GENERATION_STEPS[GENERATION_STEPS.length - 1] + ); +} + export function BarkBattleGeneratingView({ draft, isBusy = false, @@ -125,10 +180,33 @@ export function BarkBattleGeneratingView({ const [slotStatuses, setSlotStatuses] = useState< Partial> >({}); + const [elapsedMs, setElapsedMs] = useState(0); const primaryFailureMessage = useMemo( () => resolvePrimaryFailureMessage(slotFailures), [slotFailures], ); + const progressValue = resolveBarkBattleProgressValue(slotStatuses); + const currentStep = resolveCurrentBarkBattleStep(slotStatuses); + const currentStepStatus = currentStep + ? (slotStatuses[currentStep.slot] ?? + (hasSlotAsset(previewDraft, currentStep.slot) ? 'ready' : 'generating')) + : 'generating'; + const currentStepProgress = + currentStepStatus === 'ready' ? 100 : currentStepStatus === 'failed' ? 100 : 36; + const currentStepLabel = currentStep?.label ?? '竞技素材'; + const currentStepStatusLabel = getSlotStatusLabel(currentStepStatus); + + useEffect(() => { + const startedAtMs = Date.now(); + const timerId = window.setInterval(() => { + setElapsedMs(Date.now() - startedAtMs); + }, 1000); + setElapsedMs(0); + + return () => { + window.clearInterval(timerId); + }; + }, [draft.draftId]); useEffect(() => { setPreviewDraft(draft); @@ -277,76 +355,54 @@ export function BarkBattleGeneratingView({ }, [draft, onComplete, onError]); return ( -
-
-
+
+ +
+
- + 生成中
-
-
-
-
- - 自动生成素材 -
-

- {draft.title || '未命名声浪竞技场'} -

-
+
+
+ -
- {GENERATION_STEPS.map((step) => { - const status = - slotStatuses[step.slot] ?? - (hasSlotAsset(previewDraft, step.slot) ? 'ready' : 'generating'); - const ready = status === 'ready'; - const failed = - status === 'failed' || Boolean(slotFailures[step.slot]); - const statusLabel = ready - ? `${step.label}已生成` - : failed - ? `${step.label}生成失败` - : `${step.label}生成中`; - return ( -
- - {step.label} - - {ready ? ( - - ) : failed ? ( - - ) : ( - - )} -
- ); - })} +
+
{error || primaryFailureMessage ? ( -
+
{error ?? primaryFailureMessage}
) : null}
- +
+
+ 预览信息 +
+ +
@@ -354,4 +410,3 @@ export function BarkBattleGeneratingView({ } export default BarkBattleGeneratingView; - diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 44add0ee..ff231fa7 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -25,6 +25,14 @@ const testEntryConfig = { title: '选择创作类型', description: '先选玩法类型,再进入对应创作工作台。', }, + eventBanner: { + title: '泥点挑战', + description: '创作活动测试横幅。', + coverImageSrc: '/creation-type-references/puzzle.webp', + prizePoolMudPoints: 1000, + startsAtText: '2026-05-01', + endsAtText: '2026-05-31', + }, creationTypes: [ { id: 'rpg', @@ -35,6 +43,9 @@ const testEntryConfig = { visible: true, open: true, sortOrder: 10, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -46,6 +57,9 @@ const testEntryConfig = { visible: true, open: true, sortOrder: 30, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -57,6 +71,9 @@ const testEntryConfig = { visible: true, open: true, sortOrder: 40, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -68,6 +85,9 @@ const testEntryConfig = { visible: false, open: true, sortOrder: 50, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -79,6 +99,9 @@ const testEntryConfig = { visible: false, open: false, sortOrder: 60, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -90,6 +113,9 @@ const testEntryConfig = { visible: true, open: false, sortOrder: 70, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, ], @@ -665,17 +691,17 @@ test('creation hub works-only tab filters bark battle draft and published works' />, ); - expect(screen.getByRole('button', { name: '全部 2' })).toBeTruthy(); - expect(screen.getByRole('button', { name: '草稿 1' })).toBeTruthy(); - expect(screen.getByRole('button', { name: '已发布 1' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: '全部 2' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: '草稿 1' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: '已发布 1' })).toBeTruthy(); expect(screen.getByText('竖屏声浪草稿')).toBeTruthy(); expect(screen.getByText('竖屏声浪已发布')).toBeTruthy(); - await user.click(screen.getByRole('button', { name: '草稿 1' })); + await user.click(screen.getByRole('tab', { name: '草稿 1' })); expect(screen.getByText('竖屏声浪草稿')).toBeTruthy(); expect(screen.queryByText('竖屏声浪已发布')).toBeNull(); - await user.click(screen.getByRole('button', { name: '已发布 1' })); + await user.click(screen.getByRole('tab', { name: '已发布 1' })); expect(screen.queryByText('竖屏声浪草稿')).toBeNull(); expect(screen.getByText('竖屏声浪已发布')).toBeTruthy(); @@ -880,6 +906,38 @@ test('creation hub published share icon is shown directly on the card header', ( expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); }); +test('creation hub shows RPG published share icon without library entry', () => { + render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + />, + ); + + expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy(); + expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); + expect(screen.queryByText('作者:玩家')).toBeNull(); +}); + test('creation hub left swipe draft reveals delete without opening card', () => { const onDeletePublished = vi.fn(); const onOpenDraft = vi.fn(); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index ced0e82c..9f71189a 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -18,6 +18,14 @@ const testEntryConfig = { title: '选择创作类型', description: '先选玩法类型,再进入对应创作工作台。', }, + eventBanner: { + title: '泥点挑战', + description: '创作活动测试横幅。', + coverImageSrc: '/creation-type-references/puzzle.webp', + prizePoolMudPoints: 1000, + startsAtText: '2026-05-01', + endsAtText: '2026-05-31', + }, creationTypes: [ { id: 'rpg', @@ -28,6 +36,9 @@ const testEntryConfig = { visible: true, open: true, sortOrder: 10, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -39,17 +50,23 @@ const testEntryConfig = { visible: true, open: true, sortOrder: 30, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { id: 'match3d', title: '抓大鹅', subtitle: '3D 消除关卡', - badge: '可创建', + badge: '可创作', imageSrc: '/creation-type-references/match3d.webp', visible: true, open: true, sortOrder: 40, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -61,6 +78,9 @@ const testEntryConfig = { visible: false, open: true, sortOrder: 50, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -72,6 +92,9 @@ const testEntryConfig = { visible: false, open: false, sortOrder: 60, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -83,6 +106,9 @@ const testEntryConfig = { visible: true, open: false, sortOrder: 70, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, ], @@ -140,6 +166,96 @@ test('creation hub draft card renders compiled work summary fields', () => { expect(html).not.toContain('大鱼吃小鱼'); }); +test('creation start card renders reference-aligned banner and template metadata', () => { + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + mode="start-only" + />, + ); + + expect(html).toContain('creation-event-banner'); + expect(html).toContain('creation-event-banner__track'); + expect(html).toContain('creation-event-banner__slide'); + expect(html).toContain('creation-event-banner__timebar'); + expect(html).toContain('拼图主题创作赛'); + expect(html).toContain('抓大鹅主题创作赛'); + expect(html).toContain('1,000'); + expect(html).toContain('泥点数'); + expect(html).not.toContain('泥点挑战'); + expect(html).toMatch( + /creation-event-banner__timebar[\s\S]*creation-event-banner__pager[\s\S]*creation-template-card/u, + ); + expect(html).toContain('creation-template-card__body'); + expect(html).toContain('creation-template-card__cost-badge'); + expect(html).toContain('拼图关卡创作'); + expect(html).toContain('10-20泥点数'); + expect(html).toContain('即将开放'); + expect(html).not.toContain('可创建'); + expect(html).not.toContain('可创作'); + expect(html).not.toContain('creation-event-banner__counter'); + expect(html).not.toContain('预计消耗 10-20 泥点'); + expect(html).not.toContain('platform-creation-reference-card'); +}); + +test('creation start card keeps typography in compact UI scale', () => { + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + mode="start-only" + />, + ); + + expect(html).toMatch(/creation-template-card__title[^"]*\btext-sm\b/u); + expect(html).toMatch(/creation-template-card__subtitle[^"]*\btext-xs\b/u); + expect(html).toMatch( + /creation-template-card__cost-badge[^"]*\btext-\[11px\](?:\s|")/u, + ); + expect(html).not.toMatch( + /\b(text-lg|text-xl|sm:text-base|sm:text-lg|sm:text-xl|text-\[1\.08rem\])\b/u, + ); +}); + +test('creation start card removes the outer template list frame and tightens card grid', () => { + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + mode="start-only" + />, + ); + + expect(html).toContain('creation-template-list'); + expect(html).toMatch(/creation-template-list__grid[^"]*\bgap-2\b/u); + expect(html).toMatch(/creation-template-card[^"]*\bmin-h-\[12\.5rem\]/u); + expect(html).not.toMatch( + /creation-template-list[^"]*\bborder\b[^"]*\bborder-\[#f0dfd6\]/u, + ); +}); + test('creation hub renders puzzle works in the same unified list with puzzle tag', () => { const html = renderToStaticMarkup( 查看详情<'); }); + +test('creation hub root keeps the remap theme hook without the page card shell', () => { + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + mode="works-only" + />, + ); + + expect(html).toContain('platform-remap-surface'); + expect(html).not.toContain('platform-page-stage'); +}); + +test('creation hub draft tabs use discover-style channel labels', () => { + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + puzzleItems={[ + { + workId: 'puzzle:works-tab', + profileId: 'puzzle-profile-works-tab', + ownerUserId: 'user-1', + authorDisplayName: '测试作者', + levelName: '测试草稿', + summary: '测试草稿', + themeTags: [], + coverImageSrc: null, + publicationStatus: 'draft', + updatedAt: '2026-05-07T00:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + }, + ]} + onOpenPuzzleDetail={() => {}} + />, + ); + + expect(html).toContain('platform-mobile-home-channel'); + expect(html).toContain('platform-mobile-home-channel--active'); + expect(html).not.toContain('platform-tab--active'); +}); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 9e037b1d..1d7aa357 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -338,7 +338,7 @@ export function CustomWorldCreationHub({ const showWorkShelf = mode !== 'start-only'; return ( -
+
{showStartCard ? ( void; }; +type CreationEventBannerCard = CreationEntryConfig['eventBanner']; + +function shouldShowCreationBadge(badge: string) { + const normalizedBadge = badge.trim(); + return normalizedBadge !== '可创建' && normalizedBadge !== '可创作'; +} + export function CustomWorldCreationStartCard({ busy = false, error = null, @@ -22,30 +30,161 @@ export function CustomWorldCreationStartCard({ creationTypes, onCreateType, }: CustomWorldCreationStartCardProps) { - // 创作首页首屏卡带与创作类型弹层保持同一份展示口径, - // 避免某个玩法只在其中一个入口被隐藏而出现状态漂移。 - const visibleCreationTypes = getVisiblePlatformCreationTypes(creationTypes); + const creationTypeGroups = useMemo( + () => groupVisiblePlatformCreationTypes(creationTypes), + [creationTypes], + ); + const [activeCategoryId, setActiveCategoryId] = useState(null); + const activeGroup = + creationTypeGroups.find((group) => group.id === activeCategoryId) ?? + creationTypeGroups[0] ?? + null; + const visibleCreationTypes = activeGroup?.items ?? []; + const eventBanners = useMemo( + () => [ + { + ...entryConfig.eventBanner, + title: '拼图主题创作赛', + description: '用拼图关卡接住本周主题。', + coverImageSrc: '/creation-type-references/puzzle.webp', + prizePoolMudPoints: 1000, + }, + { + ...entryConfig.eventBanner, + title: '抓大鹅主题创作赛', + description: '把抓大鹅关卡做成主题挑战。', + coverImageSrc: '/creation-type-references/match3d.webp', + prizePoolMudPoints: 1000, + }, + ], + [entryConfig.eventBanner], + ); + const [activeBannerIndex, setActiveBannerIndex] = useState(0); + + function handleBannerScroll(event: UIEvent) { + const { clientWidth, scrollLeft } = event.currentTarget; + if (clientWidth <= 0) { + return; + } + + const nextIndex = Math.max( + 0, + Math.min(eventBanners.length - 1, Math.round(scrollLeft / clientWidth)), + ); + setActiveBannerIndex((currentIndex) => + currentIndex === nextIndex ? currentIndex : nextIndex, + ); + } return ( - // 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。 -
-
-
-
-
- {entryConfig.startCard.title} -
-
- {entryConfig.startCard.description} -
- - {busy - ? entryConfig.startCard.busyBadge - : entryConfig.startCard.idleBadge} - +
+
+
+ {eventBanners.map((banner, index) => { + const prizePoolText = + banner.prizePoolMudPoints.toLocaleString('zh-CN'); + + return ( +
+ +
+
+
+
+ + {banner.title} +
+
+ {banner.description} +
+
+ + + + 奖池 + + {prizePoolText} + + 泥点数 +
+
+ +
+
+ + 开始时间  {banner.startsAtText} + + | + + 结束时间  {banner.endsAtText} + +
+ +
+
+
+ ); + })} +
+
+ +
+
+ {creationTypeGroups.map((group) => { + const selected = group.id === activeGroup?.id; + return ( + + ); + })}
-
+
{visibleCreationTypes.map((item) => { const disabled = item.locked || busy; @@ -57,47 +196,35 @@ export function CustomWorldCreationStartCard({ onClick={() => { onCreateType(item.id); }} - className={`platform-creation-reference-card platform-interactive-card relative flex min-h-[4.6rem] w-[11.25rem] shrink-0 snap-start flex-col overflow-hidden rounded-[1.15rem] border p-0 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] xl:min-h-[6.4rem] ${ + className={`creation-template-card platform-interactive-card relative flex min-h-[12.5rem] flex-col overflow-hidden rounded-[1rem] border bg-white p-0 text-left transition sm:min-h-[15rem] sm:rounded-[1.2rem] ${ item.locked - ? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70' - : 'border-white/18 bg-white/16 text-white' + ? 'cursor-not-allowed border-[#eadbd3] text-[#725b4d] opacity-72' + : 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]' } ${busy && !item.locked ? 'opacity-70' : ''}`} > - -
-
- {item.locked ? ( - +
+ + {shouldShowCreationBadge(item.badge) ? ( + {item.badge} ) : null} - {item.locked ? ( - · - ) : ( - - )} + + + 10-20泥点数 +
-
-
+
+
{item.title}
-
+
{item.subtitle}
@@ -107,11 +234,11 @@ export function CustomWorldCreationStartCard({
{error ? ( -
+
{error}
) : null} -
+
); } diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 3bccf3b5..7358741e 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -728,8 +728,6 @@ export function CustomWorldWorkCard({ {item.summary}
-
作者:{item.authorDisplayName}
- {isPublished ? (
{item.pointIncentive ? ( diff --git a/src/components/custom-world-home/CustomWorldWorkTabs.tsx b/src/components/custom-world-home/CustomWorldWorkTabs.tsx index 1b928cb7..1eb6cc92 100644 --- a/src/components/custom-world-home/CustomWorldWorkTabs.tsx +++ b/src/components/custom-world-home/CustomWorldWorkTabs.tsx @@ -23,7 +23,11 @@ export function CustomWorldWorkTabs({ onChange, }: CustomWorldWorkTabsProps) { return ( -
+
{FILTER_OPTIONS.map((option) => { const count = option.id === 'draft' @@ -36,10 +40,10 @@ export function CustomWorldWorkTabs({ diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts index 767fa893..1db7d6d6 100644 --- a/src/components/custom-world-home/creationWorkShelf.test.ts +++ b/src/components/custom-world-home/creationWorkShelf.test.ts @@ -175,6 +175,39 @@ test('buildCreationWorkShelfItems keeps separate bark battle draft and published ); }); +test('buildCreationWorkShelfItems falls back to deterministic RPG public work code when library entry is missing', () => { + const items = buildCreationWorkShelfItems({ + rpgItems: [ + { + workId: 'rpg-work-published', + sourceType: 'published_profile', + status: 'published', + title: '潮雾列岛已发布版', + subtitle: '旧灯塔与失控航路', + summary: '已经发布的群岛世界作品。', + coverImageSrc: null, + updatedAt: '2026-04-20T10:00:00.000Z', + publishedAt: '2026-04-20T10:00:00.000Z', + stage: 'published', + stageLabel: '已发布', + playableNpcCount: 3, + landmarkCount: 4, + sessionId: null, + profileId: 'world-public-1', + canResume: false, + canEnterWorld: true, + }, + ], + bigFishItems: [], + puzzleItems: [], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.publicWorkCode).toBe('CW-00000001'); + expect(items[0]?.sharePath).toContain('/works/detail?work=CW-00000001'); + expect(items[0]?.canShare).toBe(true); +}); + test('buildCreationWorkShelfItems gives bark battle draft cover from character or reference fallback', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], @@ -1009,7 +1042,7 @@ test('bark battle draft generating state follows pending assets or missing three }); -test('CustomWorldWorkCard renders author for draft and published works', () => { +test('CustomWorldWorkCard hides author on shelf draft and published cards', () => { const buildItem = ( status: CreationWorkShelfItem['status'], authorDisplayName: string, @@ -1074,8 +1107,8 @@ test('CustomWorldWorkCard renders author for draft and published works', () => { }), ); - expect(draftHtml).toContain('作者:草稿作者'); - expect(publishedHtml).toContain('作者:发布作者'); + expect(draftHtml).not.toContain('作者:草稿作者'); + expect(publishedHtml).not.toContain('作者:发布作者'); }); test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => { diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index e39c5df9..f99727cd 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -10,6 +10,7 @@ import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contra import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { buildBabyObjectMatchPublicWorkCode, + buildCustomWorldPublicWorkCode, buildBarkBattlePublicWorkCode, buildBigFishPublicWorkCode, buildMatch3DPublicWorkCode, @@ -332,7 +333,10 @@ function mapRpgWorkToShelfItem( ? libraryEntries.find((entry) => entry.profileId === item.profileId) : null; const publicWorkCode = - item.status === 'published' ? (libraryEntry?.publicWorkCode ?? null) : null; + item.status === 'published' + ? (libraryEntry?.publicWorkCode?.trim() || + (item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null)) + : null; const badges: CreationWorkShelfBadge[] = [ buildStatusBadge(item.status), { id: 'type', label: 'RPG', tone: 'neutral' }, diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx index 16e1b08e..583c19ad 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx @@ -18,6 +18,14 @@ const entryConfig = { title: '选择创作类型', description: '', }, + eventBanner: { + title: '泥点挑战', + description: '创作活动测试横幅。', + coverImageSrc: '/creation-type-references/puzzle.webp', + prizePoolMudPoints: 1000, + startsAtText: '2026-05-01', + endsAtText: '2026-05-31', + }, creationTypes: [ { id: 'wooden-fish', @@ -28,6 +36,9 @@ const entryConfig = { visible: true, open: true, sortOrder: 10, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, ], diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index cb7e7088..432913d8 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, Loader2 } from 'lucide-react'; +import { Loader2 } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import { type Dispatch, @@ -205,6 +205,7 @@ import { buildWoodenFishGenerationAnchorEntries, createMiniGameDraftGenerationState, type MiniGameDraftGenerationKind, + type MiniGameDraftGenerationPhase, type MiniGameDraftGenerationState, } from '../../services/miniGameDraftGenerationProgress'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; @@ -384,7 +385,6 @@ import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; import { derivePlatformCreationTypes, - getVisiblePlatformCreationTypes, isPlatformCreationTypeOpen, isPlatformCreationTypeVisible, } from './platformEntryCreationTypes'; @@ -1734,30 +1734,120 @@ function createPendingDraftShelfState( }; } -function parseDraftGenerationStartedAtMs(value: string | null | undefined) { - const parsedMs = value ? Date.parse(value) : Number.NaN; - return Number.isFinite(parsedMs) ? parsedMs : Date.now(); -} - -function createMiniGameDraftGenerationStateFromStartedAt( +function createMiniGameDraftGenerationStateForRestoredDraft( kind: MiniGameDraftGenerationKind, - startedAtMs: number, metadata?: MiniGameDraftGenerationState['metadata'], ): MiniGameDraftGenerationState { return { ...createMiniGameDraftGenerationState(kind), - startedAtMs, ...(metadata ? { metadata } : {}), }; } +function rebaseMiniGameDraftGenerationStateForDisplay( + state: MiniGameDraftGenerationState, +): MiniGameDraftGenerationState { + const rebasedStartedAtMs = Date.now(); + + if (state.kind === 'puzzle') { + const puzzleAiRedraw = state.metadata?.puzzleAiRedraw; + return { + ...state, + startedAtMs: rebasedStartedAtMs, + finishedAtMs: undefined, + metadata: + typeof puzzleAiRedraw === 'boolean' ? { puzzleAiRedraw } : undefined, + }; + } + + return { + ...state, + startedAtMs: rebasedStartedAtMs, + finishedAtMs: undefined, + }; +} + +function rebaseMiniGameDraftBackgroundCompileTaskForDisplay< + T extends PuzzleBackgroundCompileTask | Match3DBackgroundCompileTask, +>( + task: T, +): T { + return { + ...task, + generationState: rebaseMiniGameDraftGenerationStateForDisplay( + task.generationState, + ), + }; +} + function createPuzzleDraftGenerationStateFromPayload( payload: CreatePuzzleAgentSessionRequest | null | undefined, + session: PuzzleAgentSessionSnapshot | null | undefined = null, ): MiniGameDraftGenerationState { + const puzzleProgressPercent = + session?.draft && !session.draft.formDraft + ? session.progressPercent + : undefined; + return { ...createMiniGameDraftGenerationState('puzzle'), metadata: { puzzleAiRedraw: payload?.aiRedraw ?? true, + puzzleActivePhaseId: + typeof puzzleProgressPercent === 'number' ? 'compile' : undefined, + puzzleActiveStepStartedAtMs: + typeof puzzleProgressPercent === 'number' ? Date.now() : undefined, + puzzleProgressPercent, + }, + }; +} + +function resolvePuzzlePhaseFromSessionProgress( + state: MiniGameDraftGenerationState, + session: PuzzleAgentSessionSnapshot, +): MiniGameDraftGenerationPhase { + if (session.progressPercent >= 96) { + return 'puzzle-select-image'; + } + if (session.progressPercent >= 94) { + return 'puzzle-ui-assets'; + } + if (session.progressPercent >= 88) { + return state.metadata?.puzzleAiRedraw === false + ? 'puzzle-level-scene' + : 'puzzle-cover-image'; + } + + return 'compile'; +} + +function mergePuzzleSessionProgressIntoGenerationState( + state: MiniGameDraftGenerationState, + session: PuzzleAgentSessionSnapshot, +): MiniGameDraftGenerationState { + const isCompiledGenerationSession = Boolean( + session.draft && !session.draft.formDraft, + ); + + const nextPhaseId = isCompiledGenerationSession + ? resolvePuzzlePhaseFromSessionProgress(state, session) + : state.metadata?.puzzleActivePhaseId; + const shouldResetActiveStepStart = + isCompiledGenerationSession && + nextPhaseId != null && + nextPhaseId !== state.metadata?.puzzleActivePhaseId; + + return { + ...state, + metadata: { + ...state.metadata, + puzzleActivePhaseId: nextPhaseId, + puzzleActiveStepStartedAtMs: shouldResetActiveStepStart + ? Date.now() + : state.metadata?.puzzleActiveStepStartedAtMs, + puzzleProgressPercent: isCompiledGenerationSession + ? session.progressPercent + : state.metadata?.puzzleProgressPercent, }, }; } @@ -2750,8 +2840,6 @@ export function PlatformEntryFlowShellImpl({ useState(() => Date.now()); const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] = useState(null); - const [activeCreationFormType, setActiveCreationFormType] = - useState('puzzle'); const [puzzleOnboardingPrompt, setPuzzleOnboardingPrompt] = useState(''); const [puzzleOnboardingPhase, setPuzzleOnboardingPhase] = useState('input'); @@ -4375,7 +4463,6 @@ export function PlatformEntryFlowShellImpl({ enterCreateTab, setSelectionStage, onSessionOpened: () => { - setActiveCreationFormType('match3d'); setShowCreationTypeModal(false); }, onActionComplete: async ({ payload, response, setSession }) => { @@ -4774,7 +4861,6 @@ export function PlatformEntryFlowShellImpl({ enterCreateTab, setSelectionStage, onSessionOpened: () => { - setActiveCreationFormType('puzzle'); sessionController.setCreationTypeError(null); setPuzzleCreationError(null); setShowCreationTypeModal(false); @@ -4912,9 +4998,11 @@ export function PlatformEntryFlowShellImpl({ ]); markPendingDraftGenerating('puzzle', session.sessionId); selectionStageRef.current = 'puzzle-generating'; + activePuzzleGenerationSessionIdRef.current = session.sessionId; setSelectionStage('puzzle-generating'); const nextGenerationState = createPuzzleDraftGenerationStateFromPayload( formPayload ?? buildPuzzleFormPayloadFromSession(session), + session, ); setPuzzleGenerationState(nextGenerationState); setPuzzleBackgroundCompileTasks((current) => ({ @@ -4938,7 +5026,7 @@ export function PlatformEntryFlowShellImpl({ const generationState = puzzleBackgroundCompileTasks[session.sessionId]?.generationState ?? puzzleGenerationState ?? - createPuzzleDraftGenerationStateFromPayload(formPayload); + createPuzzleDraftGenerationStateFromPayload(formPayload, session); const recovered = await recoverCompletedPuzzleDraftGeneration({ sessionId: session.sessionId, payload: formPayload, @@ -5050,6 +5138,7 @@ export function PlatformEntryFlowShellImpl({ const puzzleSession = puzzleFlow.session; const puzzleError = puzzleFlow.error; + const setPuzzleSession = puzzleFlow.setSession; const setPuzzleError = puzzleFlow.setError; puzzleErrorSetterRef.current = setPuzzleError; const isPuzzleBusy = puzzleFlow.isBusy; @@ -5263,6 +5352,75 @@ export function PlatformEntryFlowShellImpl({ isMiniGameDraftGenerating( activePuzzleBackgroundCompileTask?.generationState ?? null, ); + const puzzleGenerationViewPhase = puzzleGenerationViewState?.phase ?? null; + const shouldPollPuzzleGenerationSession = + selectionStage === 'puzzle-generating' && + activePuzzleGenerationSessionId != null && + isMiniGameDraftGenerating(puzzleGenerationViewState); + + useEffect(() => { + if (!shouldPollPuzzleGenerationSession || !activePuzzleGenerationSessionId) { + return undefined; + } + + let isDisposed = false; + + const pollPuzzleDraftGenerationSession = async () => { + try { + const { session: latestSession } = await getPuzzleAgentSession( + activePuzzleGenerationSessionId, + ); + if (isDisposed) { + return; + } + + setPuzzleSession(latestSession); + setPuzzleBackgroundCompileTasks((current) => { + const task = current[activePuzzleGenerationSessionId]; + if (!task) { + return current; + } + + return { + ...current, + [activePuzzleGenerationSessionId]: { + ...task, + session: latestSession, + generationState: mergePuzzleSessionProgressIntoGenerationState( + task.generationState, + latestSession, + ), + }, + }; + }); + setPuzzleGenerationState((current) => + current + ? mergePuzzleSessionProgressIntoGenerationState( + current, + latestSession, + ) + : current, + ); + } catch { + // 中文注释:拼图长 action 仍以主请求结果为准;轮询失败只保留当前进度展示。 + } + }; + + void pollPuzzleDraftGenerationSession(); + const timerId = window.setInterval(() => { + void pollPuzzleDraftGenerationSession(); + }, 3_000); + + return () => { + isDisposed = true; + window.clearInterval(timerId); + }; + }, [ + activePuzzleGenerationSessionId, + puzzleGenerationViewPhase, + shouldPollPuzzleGenerationSession, + setPuzzleSession, + ]); const match3DGeneratingSessionId = selectionStage === 'match3d-generating' ? match3dSession?.sessionId : null; @@ -5358,6 +5516,103 @@ export function PlatformEntryFlowShellImpl({ squareHoleFlow, ]); + const openMatch3DWorkspace = useCallback(() => { + setMatch3DRun(null); + setMatch3DProfile(null); + setMatch3DRuntimeProfile(null); + setMatch3DGenerationState(null); + setMatch3DError(null); + setStreamingMatch3DReplyText(''); + setIsStreamingMatch3DReply(false); + enterCreateTab(); + setShowCreationTypeModal(false); + setSelectionStage('match3d-agent-workspace'); + }, [ + enterCreateTab, + setIsStreamingMatch3DReply, + setMatch3DError, + setSelectionStage, + setStreamingMatch3DReplyText, + ]); + + const openJumpHopWorkspace = useCallback(() => { + setJumpHopError(null); + setJumpHopSession(null); + setJumpHopWork(null); + setJumpHopRun(null); + setJumpHopGenerationState(null); + enterCreateTab(); + setShowCreationTypeModal(false); + setSelectionStage('jump-hop-workspace'); + }, [enterCreateTab, setSelectionStage]); + + const openWoodenFishWorkspace = useCallback(() => { + setWoodenFishError(null); + setWoodenFishSession(null); + setWoodenFishWork(null); + setWoodenFishRun(null); + setWoodenFishGenerationState(null); + enterCreateTab(); + setShowCreationTypeModal(false); + setSelectionStage('wooden-fish-workspace'); + }, [enterCreateTab, setSelectionStage]); + + const openPuzzleWorkspace = useCallback(() => { + enterCreateTab(); + setShowCreationTypeModal(false); + setPuzzleCreationError(null); + setPuzzleError(null); + setSelectionStage('puzzle-agent-workspace'); + }, [ + enterCreateTab, + setPuzzleCreationError, + setPuzzleError, + setSelectionStage, + ]); + + const openBarkBattleWorkspace = useCallback(() => { + setBarkBattleDraftConfig(null); + setBarkBattlePublishedConfig(null); + setBarkBattleRuntimeMode('draft'); + setBarkBattleRuntimeReturnStage('platform'); + setBarkBattleError(null); + setBarkBattleGenerationPartialFailed(false); + setIsBarkBattleBusy(false); + enterCreateTab(); + setShowCreationTypeModal(false); + selectionStageRef.current = 'bark-battle-workspace'; + setSelectionStage('bark-battle-workspace'); + }, [enterCreateTab, setSelectionStage]); + + const openVisualNovelWorkspace = useCallback(() => { + enterCreateTab(); + setShowCreationTypeModal(false); + setVisualNovelError(null); + setSelectionStage('visual-novel-agent-workspace'); + }, [ + enterCreateTab, + setSelectionStage, + setVisualNovelError, + ]); + + const openBabyObjectMatchWorkspace = useCallback(() => { + if (!isBabyObjectMatchVisible) { + sessionController.setCreationTypeError(EDUTAINMENT_HIDDEN_MESSAGE); + return; + } + + enterCreateTab(); + setShowCreationTypeModal(false); + setBabyObjectMatchError(null); + setSelectionStage('baby-object-match-workspace'); + }, [ + enterCreateTab, + isBabyObjectMatchVisible, + sessionController, + setBabyObjectMatchError, + setSelectionStage, + ]); + const leaveCreativeAgentWorkspace = useCallback(() => { const sessionId = creativeAgentSession?.sessionId?.trim(); if (sessionId && creativeAgentSession?.stage !== 'target_ready') { @@ -5440,7 +5695,10 @@ export function PlatformEntryFlowShellImpl({ return; } - const generationState = createPuzzleDraftGenerationStateFromPayload(payload); + const generationState = createPuzzleDraftGenerationStateFromPayload( + payload, + nextSession, + ); setPuzzleBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { @@ -6024,7 +6282,6 @@ export function PlatformEntryFlowShellImpl({ setMatch3DFormDraftPayload(null); setMatch3DBackgroundCompileTasks({}); activeMatch3DGenerationSessionIdRef.current = null; - setActiveCreationFormType('puzzle'); setMatch3DWorks([]); setMatch3DGalleryEntries([]); setMatch3DRun(null); @@ -6099,6 +6356,16 @@ export function PlatformEntryFlowShellImpl({ selectionStage !== 'platform' && selectionStage !== 'work-detail' && selectionStage !== 'detail' && + selectionStage !== 'agent-workspace' && + selectionStage !== 'big-fish-agent-workspace' && + selectionStage !== 'match3d-agent-workspace' && + selectionStage !== 'square-hole-agent-workspace' && + selectionStage !== 'jump-hop-workspace' && + selectionStage !== 'wooden-fish-workspace' && + selectionStage !== 'puzzle-agent-workspace' && + selectionStage !== 'bark-battle-workspace' && + selectionStage !== 'visual-novel-agent-workspace' && + selectionStage !== 'baby-object-match-workspace' && selectionStage !== 'creative-agent-workspace' && selectionStage !== 'puzzle-gallery-detail' ) { @@ -6111,7 +6378,6 @@ export function PlatformEntryFlowShellImpl({ resetAutoSaveTrackingToIdle, resetRpgSessionViewState, selectionStage, - setActiveCreationFormType, setBigFishError, setIsStreamingMatch3DReply, setIsStreamingSquareHoleReply, @@ -6140,6 +6406,11 @@ export function PlatformEntryFlowShellImpl({ return; } + if (type === 'baby-object-match' && !isBabyObjectMatchVisible) { + sessionController.setCreationTypeError(EDUTAINMENT_HIDDEN_MESSAGE); + return; + } + if (type === 'rpg') { runProtectedAction(() => { void sessionController.openRpgAgentWorkspace(); @@ -6155,10 +6426,9 @@ export function PlatformEntryFlowShellImpl({ } if (type === 'match3d') { - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('match3d'); - setMatch3DError(null); + runProtectedAction(() => { + void openMatch3DWorkspace(); + }); return; } @@ -6170,91 +6440,60 @@ export function PlatformEntryFlowShellImpl({ } if (type === 'jump-hop') { - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('jump-hop'); - setJumpHopError(null); - setJumpHopSession(null); - setJumpHopWork(null); - setJumpHopRun(null); - setJumpHopGenerationState(null); + runProtectedAction(() => { + void openJumpHopWorkspace(); + }); return; } if (type === 'wooden-fish') { - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('wooden-fish'); - setWoodenFishError(null); - setWoodenFishSession(null); - setWoodenFishWork(null); - setWoodenFishRun(null); - setWoodenFishGenerationState(null); + runProtectedAction(() => { + void openWoodenFishWorkspace(); + }); return; } if (type === 'puzzle') { - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('puzzle'); - setPuzzleCreationError(null); - setPuzzleError(null); + runProtectedAction(() => { + void openPuzzleWorkspace(); + }); return; } if (type === 'bark-battle') { - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('bark-battle'); - setBarkBattleError(null); + runProtectedAction(() => { + void openBarkBattleWorkspace(); + }); return; } if (type === 'visual-novel') { - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('visual-novel'); - setVisualNovelError(null); + runProtectedAction(() => { + void openVisualNovelWorkspace(); + }); return; } if (type === 'baby-object-match') { - if (!isBabyObjectMatchVisible) { - sessionController.setCreationTypeError(EDUTAINMENT_HIDDEN_MESSAGE); - return; - } - - enterCreateTab(); - setShowCreationTypeModal(false); - setActiveCreationFormType('baby-object-match'); - setBabyObjectMatchError(null); - return; + runProtectedAction(() => { + void openBabyObjectMatchWorkspace(); + }); } }, [ - openBigFishAgentWorkspace, - enterCreateTab, isBabyObjectMatchVisible, - openSquareHoleAgentWorkspace, + openBarkBattleWorkspace, + openBigFishAgentWorkspace, + openBabyObjectMatchWorkspace, + openJumpHopWorkspace, + openMatch3DWorkspace, prepareCreationLaunch, - runProtectedAction, + openPuzzleWorkspace, + openSquareHoleAgentWorkspace, + openVisualNovelWorkspace, + openWoodenFishWorkspace, sessionController, - setActiveCreationFormType, - setBarkBattleError, - setMatch3DError, - setPuzzleCreationError, - setPuzzleError, - setJumpHopError, - setJumpHopGenerationState, - setJumpHopRun, - setJumpHopSession, - setJumpHopWork, - setWoodenFishError, - setWoodenFishGenerationState, - setWoodenFishRun, - setWoodenFishSession, - setWoodenFishWork, - setVisualNovelError, + runProtectedAction, ], ); @@ -10308,9 +10547,24 @@ export function PlatformEntryFlowShellImpl({ item.sourceSessionId === puzzleSession?.sessionId && isMiniGameDraftGenerating(activeGenerationState) ) { + if (!activeGenerationState) { + return; + } + const rebasedGenerationState = + rebaseMiniGameDraftGenerationStateForDisplay(activeGenerationState); enterCreateTab(); selectionStageRef.current = 'puzzle-generating'; activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; + setPuzzleGenerationState(rebasedGenerationState); + if (backgroundTask) { + setPuzzleBackgroundCompileTasks((current) => ({ + ...current, + [backgroundTask.session.sessionId]: { + ...backgroundTask, + generationState: rebasedGenerationState, + }, + })); + } setSelectionStage('puzzle-generating'); return; } @@ -10319,9 +10573,15 @@ export function PlatformEntryFlowShellImpl({ backgroundTask && isMiniGameDraftGenerating(backgroundTask.generationState) ) { - puzzleFlow.setSession(backgroundTask.session); - setPuzzleFormDraftPayload(backgroundTask.payload); - setPuzzleGenerationState(backgroundTask.generationState); + const rebasedTask = + rebaseMiniGameDraftBackgroundCompileTaskForDisplay(backgroundTask); + puzzleFlow.setSession(rebasedTask.session); + setPuzzleFormDraftPayload(rebasedTask.payload); + setPuzzleGenerationState(rebasedTask.generationState); + setPuzzleBackgroundCompileTasks((current) => ({ + ...current, + [rebasedTask.session.sessionId]: rebasedTask, + })); if (backgroundTask.error) { setPuzzleError(backgroundTask.error); } @@ -10337,21 +10597,32 @@ export function PlatformEntryFlowShellImpl({ const { session: latestSession } = await getPuzzleAgentSession( item.sourceSessionId, ); + const payload = buildPuzzleFormPayloadFromSession(latestSession); + const generationState = createMiniGameDraftGenerationStateForRestoredDraft( + 'puzzle', + { + puzzleAiRedraw: payload.aiRedraw ?? true, + puzzleProgressPercent: + latestSession.draft && !latestSession.draft.formDraft + ? latestSession.progressPercent + : undefined, + }, + ); puzzleFlow.setSession(latestSession); - setPuzzleFormDraftPayload( - buildPuzzleFormPayloadFromSession(latestSession), - ); + setPuzzleFormDraftPayload(payload); setPuzzleGenerationState( - createMiniGameDraftGenerationStateFromStartedAt( - 'puzzle', - parseDraftGenerationStartedAtMs(item.updatedAt), - { - puzzleAiRedraw: - buildPuzzleFormPayloadFromSession(latestSession).aiRedraw ?? - true, - }, - ), + rebaseMiniGameDraftGenerationStateForDisplay(generationState), ); + setPuzzleBackgroundCompileTasks((current) => ({ + ...current, + [latestSession.sessionId]: { + session: latestSession, + payload, + generationState: + rebaseMiniGameDraftGenerationStateForDisplay(generationState), + error: null, + }, + })); enterCreateTab(); selectionStageRef.current = 'puzzle-generating'; activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; @@ -10481,9 +10752,24 @@ export function PlatformEntryFlowShellImpl({ item.sourceSessionId === match3dSession?.sessionId && isMiniGameDraftGenerating(activeGenerationState) ) { + if (!activeGenerationState) { + return; + } + const rebasedGenerationState = + rebaseMiniGameDraftGenerationStateForDisplay(activeGenerationState); enterCreateTab(); selectionStageRef.current = 'match3d-generating'; activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; + setMatch3DGenerationState(rebasedGenerationState); + if (backgroundTask) { + setMatch3DBackgroundCompileTasks((current) => ({ + ...current, + [backgroundTask.session.sessionId]: { + ...backgroundTask, + generationState: rebasedGenerationState, + }, + })); + } setSelectionStage('match3d-generating'); return; } @@ -10492,9 +10778,15 @@ export function PlatformEntryFlowShellImpl({ backgroundTask && isMiniGameDraftGenerating(backgroundTask.generationState) ) { - setMatch3DSession(backgroundTask.session); - setMatch3DFormDraftPayload(backgroundTask.payload); - setMatch3DGenerationState(backgroundTask.generationState); + const rebasedTask = + rebaseMiniGameDraftBackgroundCompileTaskForDisplay(backgroundTask); + setMatch3DSession(rebasedTask.session); + setMatch3DFormDraftPayload(rebasedTask.payload); + setMatch3DGenerationState(rebasedTask.generationState); + setMatch3DBackgroundCompileTasks((current) => ({ + ...current, + [rebasedTask.session.sessionId]: rebasedTask, + })); if (backgroundTask.error) { setMatch3DError(backgroundTask.error); } @@ -10512,12 +10804,11 @@ export function PlatformEntryFlowShellImpl({ setMatch3DSession(latestSession); setMatch3DFormDraftPayload(null); setMatch3DProfile(null); - setMatch3DGenerationState( - createMiniGameDraftGenerationStateFromStartedAt( - 'match3d', - parseDraftGenerationStartedAtMs(item.updatedAt), - ), - ); + const generationState = + rebaseMiniGameDraftGenerationStateForDisplay( + createMiniGameDraftGenerationStateForRestoredDraft('match3d'), + ); + setMatch3DGenerationState(generationState); enterCreateTab(); selectionStageRef.current = 'match3d-generating'; activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; @@ -12744,7 +13035,8 @@ export function PlatformEntryFlowShellImpl({ puzzleShelfError ?? puzzleError ?? (isVisualNovelCreationOpen ? visualNovelError : null) ?? - babyObjectMatchError) + babyObjectMatchError ?? + barkBattleError) } onRetry={() => { platformBootstrap.setPlatformError(null); @@ -12796,7 +13088,8 @@ export function PlatformEntryFlowShellImpl({ puzzleCreationError ?? puzzleError ?? (isVisualNovelCreationOpen ? visualNovelError : null) ?? - babyObjectMatchError + babyObjectMatchError ?? + barkBattleError } createBusy={ !creationEntryConfig || @@ -12929,208 +13222,9 @@ export function PlatformEntryFlowShellImpl({ ) : null} ); - const creationStartContent = ( -
-
-
-
- {getVisiblePlatformCreationTypes(creationEntryTypes).map((item) => { - const selected = item.id === activeCreationFormType; - const disabled = item.locked; - - return ( - - ); - })} -
-
- -
- {activeCreationFormType === 'match3d' ? ( - } - > - { - void executeMatch3DAction(payload); - }} - initialFormPayload={match3dFormDraftPayload} - onCreateFromForm={(payload) => { - runProtectedAction(() => { - void createMatch3DDraftFromForm(payload); - }); - }} - showBackButton={false} - title={null} - /> - - ) : activeCreationFormType === 'visual-novel' ? ( - } - > - { - runProtectedAction(() => { - void createVisualNovelDraftFromForm(payload); - }); - }} - showBackButton={false} - title={null} - /> - - ) : activeCreationFormType === 'baby-object-match' ? ( - } - > - { - void createBabyObjectMatchDraftFromForm(payload); - }} - showBackButton={false} - title={null} - /> - - ) : activeCreationFormType === 'bark-battle' ? ( - } - > - { - void createBarkBattleGeneratingDraft(payload); - }} - showBackButton={false} - title={null} - /> - - ) : activeCreationFormType === 'jump-hop' ? ( - } - > - { - void compileJumpHopSession(result, payload); - }} - /> - - ) : activeCreationFormType === 'wooden-fish' ? ( - } - > - { - void compileWoodenFishSession(result, payload); - }} - /> - - ) : ( - } - > - { - void submitPuzzleMessage(payload); - }} - onExecuteAction={(payload) => { - executePuzzleWorkspaceAction(payload); - }} - initialFormPayload={puzzleFormDraftPayload} - onCreateFromForm={(payload) => { - runProtectedAction(() => { - void createPuzzleDraftFromForm(payload); - }); - }} - onAutoSaveForm={(payload) => { - void savePuzzleFormDraft(payload); - }} - showBackButton={false} - title={null} - /> - - )} -
-
-
+ const creationStartContent = renderCreationHubContent( + 'start-only', + '正在加载创作大厅...', ); const draftHubContent = renderCreationHubContent( 'works-only', @@ -13615,7 +13709,7 @@ export function PlatformEntryFlowShellImpl({ )} - {selectionStage === 'match3d-agent-workspace' && match3dSession && ( + {selectionStage === 'match3d-agent-workspace' && ( { void executeMatch3DAction(payload); }} + initialFormPayload={match3dFormDraftPayload} + onCreateFromForm={(payload) => { + runProtectedAction(() => { + void createMatch3DDraftFromForm(payload); + }); + }} /> @@ -14013,6 +14113,29 @@ export function PlatformEntryFlowShellImpl({ )} + {selectionStage === 'bark-battle-workspace' && ( + + } + > + { + void createBarkBattleGeneratingDraft(payload); + }} + /> + + + )} + {selectionStage === 'square-hole-agent-workspace' && ( { enterCreateTab(); - setActiveCreationFormType('bark-battle'); selectionStageRef.current = 'platform'; setSelectionStage('platform'); }} @@ -15034,7 +15156,6 @@ export function PlatformEntryFlowShellImpl({ } onBack={() => { enterCreateTab(); - setActiveCreationFormType('bark-battle'); selectionStageRef.current = 'platform'; setSelectionStage('platform'); }} @@ -15074,7 +15195,6 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('bark-battle-result'); } else { enterCreateTab(); - setActiveCreationFormType('bark-battle'); setSelectionStage('platform'); } }} diff --git a/src/components/platform-entry/platformEntryCreationTypes.test.ts b/src/components/platform-entry/platformEntryCreationTypes.test.ts index 00edf621..e1de45da 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.test.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import { derivePlatformCreationTypes, + groupVisiblePlatformCreationTypes, getVisiblePlatformCreationTypes, isPlatformCreationTypeOpen, isPlatformCreationTypeVisible, @@ -22,6 +23,9 @@ test('database entry config controls visibility open state and display order', ( visible: true, open: false, sortOrder: 30, + categoryId: 'recommended', + categoryLabel: '热门推荐', + categorySortOrder: 20, updatedAtMicros: 1, }, { @@ -33,6 +37,9 @@ test('database entry config controls visibility open state and display order', ( visible: true, open: true, sortOrder: 20, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -44,6 +51,9 @@ test('database entry config controls visibility open state and display order', ( visible: false, open: true, sortOrder: 10, + categoryId: 'festival', + categoryLabel: '节日主题', + categorySortOrder: 30, updatedAtMicros: 1, }, ]); @@ -79,6 +89,9 @@ test('visible platform creation types hide invisible cards and put locked cards visible: false, open: true, sortOrder: 1, + categoryId: 'hidden', + categoryLabel: '隐藏', + categorySortOrder: 99, updatedAtMicros: 1, }, { @@ -90,6 +103,9 @@ test('visible platform creation types hide invisible cards and put locked cards visible: true, open: false, sortOrder: 2, + categoryId: 'recommended', + categoryLabel: '热门推荐', + categorySortOrder: 20, updatedAtMicros: 1, }, { @@ -101,6 +117,9 @@ test('visible platform creation types hide invisible cards and put locked cards visible: true, open: true, sortOrder: 3, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, ]); @@ -131,6 +150,9 @@ test('edutainment switch hides baby object match creation entry from database co visible: true, open: true, sortOrder: 1, + categoryId: 'character', + categoryLabel: '角色创作', + categorySortOrder: 40, updatedAtMicros: 1, }, { @@ -142,6 +164,9 @@ test('edutainment switch hides baby object match creation entry from database co visible: true, open: true, sortOrder: 2, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, ]); @@ -160,6 +185,9 @@ test('edutainment switch hides baby object match creation entry from database co visible: true, open: true, sortOrder: 1, + categoryId: 'character', + categoryLabel: '角色创作', + categorySortOrder: 40, updatedAtMicros: 1, }, { @@ -171,6 +199,9 @@ test('edutainment switch hides baby object match creation entry from database co visible: true, open: true, sortOrder: 2, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, ]); @@ -194,6 +225,9 @@ test('baby object match entry is visible and open when database marks it creatab visible: true, open: true, sortOrder: 90, + categoryId: 'character', + categoryLabel: '角色创作', + categorySortOrder: 40, updatedAtMicros: 1, }, ]); @@ -208,3 +242,76 @@ test('baby object match entry is visible and open when database marks it creatab expect(isPlatformCreationTypeVisible(cards, 'baby-object-match')).toBe(true); expect(isPlatformCreationTypeOpen(cards, 'baby-object-match')).toBe(true); }); + +test('groups visible platform creation types by backend category metadata', () => { + const cards = derivePlatformCreationTypes([ + { + id: 'puzzle', + title: '秋日暖阳', + subtitle: '记录秋日的温暖时光', + badge: '热门', + imageSrc: '/creation-type-references/puzzle.webp', + visible: true, + open: true, + sortOrder: 30, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, + updatedAtMicros: 1, + }, + { + id: 'match3d', + title: '秋日小屋', + subtitle: '打造专属的秋日小屋', + badge: '精选', + imageSrc: '/creation-type-references/match3d.webp', + visible: true, + open: true, + sortOrder: 40, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, + updatedAtMicros: 1, + }, + { + id: 'visual-novel', + title: '视觉小说', + subtitle: '分支叙事体验', + badge: '敬请期待', + imageSrc: '/creation-type-references/visual-novel.webp', + visible: true, + open: false, + sortOrder: 60, + categoryId: 'festival', + categoryLabel: '节日主题', + categorySortOrder: 30, + updatedAtMicros: 1, + }, + { + id: 'hidden', + title: '隐藏入口', + subtitle: '隐藏', + badge: '隐藏', + imageSrc: '/creation-type-references/hidden.webp', + visible: false, + open: true, + sortOrder: 10, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, + updatedAtMicros: 1, + }, + ]); + + const groups = groupVisiblePlatformCreationTypes(cards); + + expect(groups.map((group) => group.label)).toEqual([ + '最近创作', + '节日主题', + ]); + expect(groups[0]?.items.map((item) => item.id)).toEqual([ + 'puzzle', + 'match3d', + ]); + expect(groups[1]?.items.map((item) => item.id)).toEqual(['visual-novel']); +}); diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts index e52faf99..3aad4e59 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.ts @@ -10,9 +10,23 @@ export type PlatformCreationTypeCard = { badge: string; imageSrc: string; locked: boolean; + categoryId: string; + categoryLabel: string; + categorySortOrder: number; + sortOrder: number; hidden?: boolean; }; +export type PlatformCreationTypeGroup = { + id: string; + label: string; + sortOrder: number; + items: PlatformCreationTypeCard[]; +}; + +const FALLBACK_CREATION_CATEGORY_ID = 'recent'; +const FALLBACK_CREATION_CATEGORY_LABEL = '最近创作'; + export function getVisiblePlatformCreationTypes( creationTypes: readonly PlatformCreationTypeCard[], ) { @@ -41,6 +55,50 @@ export function isPlatformCreationTypeOpen( ); } +function normalizeCategoryId(value: string) { + const normalized = value.trim(); + return normalized || FALLBACK_CREATION_CATEGORY_ID; +} + +function normalizeCategoryLabel(value: string) { + const normalized = value.trim(); + return normalized || FALLBACK_CREATION_CATEGORY_LABEL; +} + +export function groupVisiblePlatformCreationTypes( + creationTypes: readonly PlatformCreationTypeCard[], +): PlatformCreationTypeGroup[] { + const groups = new Map(); + + for (const item of getVisiblePlatformCreationTypes(creationTypes)) { + const categoryId = normalizeCategoryId(item.categoryId); + const categoryLabel = normalizeCategoryLabel(item.categoryLabel); + const existing = groups.get(categoryId); + + if (existing) { + existing.items.push(item); + if (item.categorySortOrder < existing.sortOrder) { + existing.sortOrder = item.categorySortOrder; + } + continue; + } + + groups.set(categoryId, { + id: categoryId, + label: categoryLabel, + sortOrder: item.categorySortOrder, + items: [item], + }); + } + + return [...groups.values()].sort((left, right) => { + if (left.sortOrder !== right.sortOrder) { + return left.sortOrder - right.sortOrder; + } + return left.label.localeCompare(right.label, 'zh-Hans-CN'); + }); +} + /** * 创作入口卡片只做展示派生;配置事实源来自后端 API / SpacetimeDB,前端不再保留入口默认配置。 */ @@ -56,6 +114,10 @@ export function derivePlatformCreationTypes( badge: item.badge, imageSrc: item.imageSrc, locked: !item.open, + categoryId: normalizeCategoryId(item.categoryId), + categoryLabel: normalizeCategoryLabel(item.categoryLabel), + categorySortOrder: item.categorySortOrder, + sortOrder: item.sortOrder, hidden: !item.visible || (item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()), diff --git a/src/components/platform-entry/platformEntryTypes.ts b/src/components/platform-entry/platformEntryTypes.ts index 7820728f..e34c9403 100644 --- a/src/components/platform-entry/platformEntryTypes.ts +++ b/src/components/platform-entry/platformEntryTypes.ts @@ -36,6 +36,7 @@ export type SelectionStage = | 'jump-hop-result' | 'jump-hop-runtime' | 'jump-hop-gallery-detail' + | 'bark-battle-workspace' | 'bark-battle-generating' | 'bark-battle-result' | 'bark-battle-runtime' diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 54240439..036b09fe 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -35,8 +35,8 @@ import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, } from '../../../packages/shared/src/contracts/runtime'; -import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary'; +import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { readPublicWorkCodeFromLocationSearch, resolveSelectionStageFromPath, @@ -196,9 +196,30 @@ async function clickFirstAsyncButtonByName( async function openCreateTemplateHub(user: ReturnType) { await clickFirstButtonByName(user, '创作'); - expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy(); - expect(screen.getByRole('tab', { name: '拼图' })).toBeTruthy(); - expect(screen.getByText('拼图工作区:missing-session')).toBeTruthy(); + const panel = getPlatformTabPanel('create'); + await waitFor(() => { + expect(panel.getAttribute('aria-hidden')).toBe('false'); + }); + expect( + await within(panel).findByRole('tablist', { name: '玩法模板分类' }), + ).toBeTruthy(); + expect( + await within(panel).findByRole('button', { name: /拼图/u }), + ).toBeTruthy(); + expect(within(panel).queryByText('拼图工作区:missing-session')).toBeNull(); + return panel; +} + +async function findCreationTypeButton(name: string | RegExp) { + const matcher = + typeof name === 'string' ? new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'u') : name; + return within(getPlatformTabPanel('create')).findByRole('button', { name: matcher }); +} + +function queryCreationTypeButton(name: string | RegExp) { + const matcher = + typeof name === 'string' ? new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'u') : name; + return within(getPlatformTabPanel('create')).queryByRole('button', { name: matcher }); } async function openDraftHub(user: ReturnType) { @@ -208,7 +229,7 @@ async function openDraftHub(user: ReturnType) { expect(panel.getAttribute('aria-hidden')).toBe('false'); }); expect( - await within(panel).findByRole('button', { name: /全部/u }), + await within(panel).findByRole('tab', { name: /全部/u }), ).toBeTruthy(); } @@ -276,6 +297,14 @@ const testCreationEntryConfig = { title: '选择创作类型', description: '先选玩法类型,再进入对应创作工作台。', }, + eventBanner: { + title: '泥点挑战', + description: '创作活动测试横幅。', + coverImageSrc: '/creation-type-references/puzzle.webp', + prizePoolMudPoints: 1000, + startsAtText: '2026-05-01', + endsAtText: '2026-05-31', + }, creationTypes: [ { id: 'rpg', @@ -286,6 +315,9 @@ const testCreationEntryConfig = { visible: true, open: true, sortOrder: 10, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -297,6 +329,9 @@ const testCreationEntryConfig = { visible: true, open: true, sortOrder: 30, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -308,6 +343,9 @@ const testCreationEntryConfig = { visible: true, open: true, sortOrder: 40, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -319,6 +357,9 @@ const testCreationEntryConfig = { visible: true, open: true, sortOrder: 45, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -330,6 +371,9 @@ const testCreationEntryConfig = { visible: false, open: true, sortOrder: 50, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -341,6 +385,9 @@ const testCreationEntryConfig = { visible: false, open: false, sortOrder: 60, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -352,6 +399,9 @@ const testCreationEntryConfig = { visible: true, open: false, sortOrder: 70, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -363,6 +413,9 @@ const testCreationEntryConfig = { visible: false, open: true, sortOrder: 80, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, { @@ -374,6 +427,9 @@ const testCreationEntryConfig = { visible: true, open: true, sortOrder: 90, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, updatedAtMicros: 1, }, ], @@ -3345,81 +3401,80 @@ test('create tab shows template tabs and embeds puzzle form by default', async ( await openCreateTemplateHub(user); - expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy(); - expect(screen.getByRole('tablist', { name: '选择模板' }).className).toContain( + expect(screen.getByRole('tablist', { name: '玩法模板分类' })).toBeTruthy(); + expect( + screen.getByRole('tablist', { name: '玩法模板分类' }).className, + ).toContain( 'scroll-px-3', ); expect( - screen.getByRole('tab', { name: '拼图' }).getAttribute('aria-selected'), + screen.getByRole('tab', { name: '最近创作' }).getAttribute('aria-selected'), ).toBe('true'); expect( - screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src, - ).toContain('/creation-type-references/puzzle.webp'); - expect( - screen.getByRole('tab', { name: '文字冒险' }).querySelector('img')?.src, - ).toContain('/creation-type-references/rpg.webp'); - expect( - screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src, - ).toContain('/creation-type-references/match3d.webp'); - expect( - screen.getByRole('tab', { name: '汪汪声浪' }).querySelector('img')?.src, - ).toContain('/creation-type-references/bark-battle.webp'); - expect( - screen.getByRole('tab', { name: '宝贝识物' }).querySelector('img')?.src, - ).toContain('/child-motion-demo/picture-book-grass-stage.png'); - expect( - screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'), + await findCreationTypeButton('拼图'), ).toBeTruthy(); expect( - screen.getByRole('tab', { name: '拼图' }).querySelector('.text-inherit'), + await findCreationTypeButton('文字冒险'), + ).toBeTruthy(); + expect( + await findCreationTypeButton('抓大鹅'), + ).toBeTruthy(); + expect( + await findCreationTypeButton('汪汪声浪'), + ).toBeTruthy(); + expect( + await findCreationTypeButton('宝贝识物'), + ).toBeTruthy(); + expect( + queryCreationTypeButton('智能创作'), ).toBeNull(); + expect( + screen + .getByRole('tab', { name: '最近创作' }) + .querySelector('[class*="bg-[#d9793f]"]'), + ).toBeTruthy(); expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull(); expect(screen.queryByPlaceholderText('问一问陶泥儿')).toBeNull(); expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull(); - expect(screen.queryByRole('tab', { name: /方洞挑战/u })).toBeNull(); - expect(screen.queryByRole('tab', { name: '视觉小说' })).toBeNull(); - expect(screen.getByRole('tab', { name: /抓大鹅/u })).toBeTruthy(); - expect(screen.getByRole('tab', { name: /汪汪声浪/u })).toBeTruthy(); - expect(screen.getByRole('tab', { name: /宝贝识物/u })).toBeTruthy(); expect(createRpgCreationSession).not.toHaveBeenCalled(); expect(match3dCreationClient.createSession).not.toHaveBeenCalled(); expect(createPuzzleAgentSession).not.toHaveBeenCalled(); }); -test('create tab switches match3d into the embedded entry form', async () => { +test('create tab opens match3d entry form from the template card', async () => { const user = userEvent.setup(); render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); - expect( - screen.getByRole('tab', { name: '抓大鹅' }).getAttribute('aria-selected'), - ).toBe('true'); expect(await screen.findByText('抓大鹅工作区:missing-session')).toBeTruthy(); expect(createPuzzleAgentSession).not.toHaveBeenCalled(); expect(match3dCreationClient.createSession).not.toHaveBeenCalled(); }); -test('create tab switches bark battle into the embedded config form', async () => { +test('create tab opens puzzle entry form from the template card', async () => { const user = userEvent.setup(); render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '汪汪声浪' })); + await user.click(await findCreationTypeButton('拼图')); + + expect(await screen.findByText('拼图工作区:missing-session')).toBeTruthy(); + expect(createPuzzleAgentSession).not.toHaveBeenCalled(); +}); + +test('create tab opens bark battle entry form from the template card', async () => { + const user = userEvent.setup(); + + render(); + + await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('汪汪声浪')); - expect( - screen.getByRole('tab', { name: '汪汪声浪' }).getAttribute('aria-selected'), - ).toBe('true'); expect(await screen.findByText('汪汪声浪配置表单')).toBeTruthy(); - expect(screen.getByTestId('bark-battle-editor-back-state').textContent).toBe( - 'back-hidden', - ); - expect(screen.getByTestId('bark-battle-editor-title-state').textContent).toBe( - 'title-hidden', - ); expect(screen.queryByText('汪汪声浪运行态')).toBeNull(); expect(createBarkBattleDraft).not.toHaveBeenCalled(); expect(publishBarkBattleWork).not.toHaveBeenCalled(); @@ -3431,7 +3486,7 @@ test('bark battle draft result can test before publish and publish to work detai render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '汪汪声浪' })); + await user.click(await findCreationTypeButton('汪汪声浪')); await user.click(await screen.findByRole('button', { name: '生成草稿' })); expect(createBarkBattleDraft).toHaveBeenCalledWith({ @@ -3525,7 +3580,7 @@ test('bark battle form checks mud points before creating image assets', async () render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '汪汪声浪' })); + await user.click(await findCreationTypeButton('汪汪声浪')); await user.click(await screen.findByRole('button', { name: '生成草稿' })); expect( @@ -3547,7 +3602,7 @@ test('bark battle draft is visible in draft shelf while image assets are generat render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '汪汪声浪' })); + await user.click(await findCreationTypeButton('汪汪声浪')); await user.click(await screen.findByRole('button', { name: '生成草稿' })); expect(await screen.findByText('自动生成素材')).toBeTruthy(); @@ -3596,7 +3651,7 @@ test('published bark battle stays visible when refresh temporarily returns only render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '汪汪声浪' })); + await user.click(await findCreationTypeButton('汪汪声浪')); await user.click(await screen.findByRole('button', { name: '生成草稿' })); await waitFor(() => { expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith( @@ -3811,7 +3866,17 @@ test('persisted generating match3d draft opens generation progress after refresh 'match3d-session-generating', ); }); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); + expect( + screen + .getByRole('progressbar', { name: '抓大鹅草稿生成进度' }) + .getAttribute('aria-valuenow'), + ).toBe('0'); + expect(screen.getByText('0%')).toBeTruthy(); expect(screen.queryByText('抓大鹅结果页')).toBeNull(); expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith( 'match3d-profile-generating', @@ -4514,7 +4579,9 @@ test('match3d result back returns to platform creation page', async () => { expect(await screen.findByText('抓大鹅结果页')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回' })); - expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy(); + expect( + await screen.findByRole('tablist', { name: '玩法模板分类' }), + ).toBeTruthy(); expect(screen.queryByText('抓大鹅结果页')).toBeNull(); }); @@ -6788,7 +6855,9 @@ test('puzzle draft result back button returns to creation hub', async () => { await user.click(screen.getByRole('button', { name: '返回' })); - expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy(); + expect( + await screen.findByRole('tablist', { name: '玩法模板分类' }), + ).toBeTruthy(); expect(await screen.findByText('拼图工作区:missing-session')).toBeTruthy(); expect( screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'), @@ -6825,14 +6894,15 @@ test('persisted generating puzzle draft opens generation progress after refresh' }, ], }); - vi.mocked(getPuzzleAgentSession).mockResolvedValueOnce({ - session: buildMockPuzzleAgentSession({ - sessionId: 'puzzle-session-generating', - stage: 'collecting_anchors', - progressPercent: 42, - lastAssistantReply: '正在生成拼图草稿。', - updatedAt: '2026-05-18T12:00:00.000Z', - }), + const persistedGeneratingPuzzleSession = buildMockPuzzleAgentSession({ + sessionId: 'puzzle-session-generating', + stage: 'collecting_anchors', + progressPercent: 88, + lastAssistantReply: '正在生成拼图草稿。', + updatedAt: '2026-05-18T12:00:00.000Z', + }); + vi.mocked(getPuzzleAgentSession).mockResolvedValue({ + session: persistedGeneratingPuzzleSession, }); render(); @@ -6845,7 +6915,19 @@ test('persisted generating puzzle draft opens generation progress after refresh' 'puzzle-session-generating', ); }); - expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '拼图草稿生成进度', + }), + ).toBeTruthy(); + expect( + Number( + screen + .getByRole('progressbar', { name: '拼图草稿生成进度' }) + .getAttribute('aria-valuenow'), + ), + ).toBe(0); + expect(screen.getByText('0%')).toBeTruthy(); expect(screen.queryByText('拼图结果页')).toBeNull(); }); @@ -7844,7 +7926,9 @@ test('running custom world draft generation can return to creation center with s expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); - expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy(); + expect( + await screen.findByRole('tablist', { name: '玩法模板分类' }), + ).toBeTruthy(); await openDraftHub(user); expect(await screen.findByText('潮雾列岛')).toBeTruthy(); @@ -8892,7 +8976,9 @@ test('agent draft result back button returns to creation hub without syncing res await user.click(screen.getByRole('button', { name: /返回创作/u })); await waitFor(() => { - expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy(); + expect( + screen.getByRole('tablist', { name: '玩法模板分类' }), + ).toBeTruthy(); }); expect( @@ -9215,14 +9301,16 @@ test('manual tab switch is preserved after platform bootstrap requests finish', render(); await clickFirstButtonByName(user, '创作'); - expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy(); + expect( + await screen.findByRole('tablist', { name: '玩法模板分类' }), + ).toBeTruthy(); resolveGalleryRequest([]); await waitFor(() => { expect( within(getPlatformTabPanel('create')).getByRole('tablist', { - name: '选择模板', + name: '玩法模板分类', }), ).toBeTruthy(); }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index ab7a6f1d..4f494a95 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -413,6 +413,11 @@ const originalUserAgent = navigator.userAgent; const originalMaxTouchPoints = navigator.maxTouchPoints; const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; +const DEFAULT_PROFILE_CREATED_AT = '2026-04-01T00:00:00.000Z'; + +function buildFreshProfileCreatedAt() { + return new Date().toISOString(); +} function dispatchPointerEvent( target: HTMLElement, @@ -670,6 +675,33 @@ function mockWechatMobileLayout() { }); } +function mockNarrowMobileLayout() { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit Mobile', + }); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: vi.fn().mockImplementation((query: string) => { + const normalizedQuery = query.replace(/\s/g, ''); + return { + matches: + normalizedQuery.includes('max-width:767px') || + normalizedQuery.includes('max-width:768px'), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }; + }), + }); +} + function renderProfileView( onRechargeSuccess = vi.fn(), profileDashboardOverrides: Partial< @@ -690,7 +722,7 @@ function renderProfileView( loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: new Date().toISOString(), + createdAt: DEFAULT_PROFILE_CREATED_AT, ...userOverrides, }, canAccessProtectedData: true, @@ -1056,7 +1088,7 @@ afterEach(() => { loginMethod: 'password', bindingStatus: 'active', wechatBound: false, - createdAt: new Date().toISOString(), + createdAt: DEFAULT_PROFILE_CREATED_AT, }); mockQrCodeToDataUrl.mockResolvedValue('data:image/png;base64,QR'); mockRedirectToPaymentUrl.mockReset(); @@ -1094,7 +1126,9 @@ test('opens wallet ledger modal from narrative coin card', async () => { const user = userEvent.setup(); renderProfileView(); - await user.click(screen.getByRole('button', { name: /泥点\s*0/u })); + await user.click( + screen.getByRole('button', { name: /泥点余额\s*0/u }), + ); expect(await screen.findByText('泥点账单')).toBeTruthy(); expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1); @@ -1760,19 +1794,21 @@ test('profile native qr confirmation refreshes only after server reports paid', expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); -test('non-wechat profile shows reward code instead of recharge entry', async () => { +test('non-wechat profile opens reward code from recharge-shaped entry', async () => { const user = userEvent.setup(); renderProfileView(); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); expect( - within(shortcutRegion).queryByRole('button', { name: /充值/u }), - ).toBeNull(); + within(shortcutRegion).getByRole('button', { name: /泥点充值/u }), + ).toBeTruthy(); expect( within(shortcutRegion).getByRole('button', { name: /兑换码/u }), ).toBeTruthy(); - await user.click(within(shortcutRegion).getByRole('button', { name: /兑换码/u })); + await user.click( + within(shortcutRegion).getByRole('button', { name: /泥点充值/u }), + ); expect(await screen.findByPlaceholderText('输入兑换码')).toBeTruthy(); expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled(); }); @@ -1821,7 +1857,7 @@ test('profile played works card shows count unit', () => { }); const playedCard = screen.getByRole('button', { - name: /玩过\s*1个/u, + name: /已玩游戏数量\s*1个/u, }); expect(within(playedCard).getByText('1个')).toBeTruthy(); @@ -1832,18 +1868,120 @@ test('profile stats cards are centered without update timestamp', () => { updatedAt: '2026-05-03T08:01:00Z', }); - const walletCard = screen.getByRole('button', { name: /泥点\s*0/u }); - const playTimeCard = screen.getByRole('button', { name: /游戏时长/u }); - const playedCard = screen.getByRole('button', { name: /玩过\s*0个/u }); + const walletCard = screen.getByRole('button', { + name: /泥点余额\s*0/u, + }); + const playTimeCard = screen.getByRole('button', { name: /游戏时长|累计游戏时长/u }); + const playedCard = screen.getByRole('button', { name: /已玩游戏数量\s*0个/u }); for (const card of [walletCard, playTimeCard, playedCard]) { - expect(card.className).toContain('items-center'); - expect(card.className).toContain('justify-center'); + expect(card.className).toContain('platform-profile-stat-card'); expect(card.className).toContain('text-center'); } expect(screen.queryByText(/更新于/u)).toBeNull(); }); +test('mobile profile page matches the reference layout sections', async () => { + mockWechatMobileLayout(); + + const { container } = renderProfileView(vi.fn(), { + walletBalance: 70, + totalPlayTimeMs: 0, + playedWorldCount: 0, + }, { createdAt: buildFreshProfileCreatedAt() }); + + const profilePage = container.querySelector('.platform-profile-page'); + expect(profilePage).toBeTruthy(); + expect(profilePage?.classList.contains('platform-page-stage')).toBe(true); + expect(profilePage?.querySelector('.platform-profile-scene-decor')).toBeTruthy(); + expect(profilePage?.classList.contains('platform-profile-page')).toBe(true); + expect(profilePage?.getAttribute('style') ?? '').not.toContain('overflow: hidden'); + + const membershipCard = screen.getByRole('button', { name: '查看权益' }); + expect(membershipCard.className).toContain('platform-profile-membership-card'); + expect( + within(membershipCard).getByText('普通用户').className, + ).toContain('platform-profile-membership-card__title'); + expect(within(membershipCard).getByText('普通用户')).toBeTruthy(); + expect(within(membershipCard).getByText('升级会员,享专属特权与福利')).toBeTruthy(); + + const statPanel = screen.getByRole('region', { name: '我的数据' }); + expect(statPanel.className).toContain('platform-profile-stats-panel'); + expect(statPanel.querySelector('.platform-profile-stats-grid')).toBeTruthy(); + expect(within(statPanel).getByRole('button', { name: /泥点余额\s*70/u })).toBeTruthy(); + expect(within(statPanel).getByRole('button', { name: /累计游戏时长\s*0小时/u })).toBeTruthy(); + expect(within(statPanel).getByRole('button', { name: /已玩游戏数量\s*0个/u })).toBeTruthy(); + expect( + within(statPanel).getByRole('button', { name: /泥点余额\s*70/u }).className, + ).toContain('platform-profile-stat-card'); + + const dailyTask = screen.getByRole('button', { name: /每日任务/u }); + expect(dailyTask.className).toContain('platform-profile-daily-task-card'); + expect(dailyTask.querySelector('.platform-profile-daily-task-card__title')).toBeTruthy(); + expect(dailyTask.querySelector('.platform-profile-daily-task-card__desc')).toBeTruthy(); + expect(dailyTask.querySelector('.platform-profile-daily-task-card__progress')).toBeTruthy(); + expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点'); + expect(within(dailyTask).getByText('0 / 1')).toBeTruthy(); + + const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); + expect( + shortcutRegion.querySelector('.platform-profile-shortcut-grid'), + ).toBeTruthy(); + expect( + shortcutRegion.querySelectorAll('.platform-profile-shortcut-button'), + ).toHaveLength(5); + expect( + shortcutRegion + .querySelector('.platform-profile-shortcut-grid') + ?.classList.contains('platform-profile-shortcut-grid'), + ).toBe(true); + for (const label of [ + '泥点充值', + '邀请好友', + '兑换码', + '玩家社区', + '反馈与建议', + ]) { + expect( + within(shortcutRegion).getByRole('button', { name: new RegExp(label, 'u') }), + ).toBeTruthy(); + } + + const settingsRegion = screen.getByRole('region', { name: '设置入口' }); + for (const label of ['主题设置', '账号与安全', '通用设置']) { + expect( + within(settingsRegion).getByRole('button', { name: new RegExp(label, 'u') }), + ).toBeTruthy(); + } + + const secondaryShortcuts = screen.getByRole('region', { + name: '次级入口', + }); + expect( + within(secondaryShortcuts).getByRole('button', { name: /存档/u }), + ).toBeTruthy(); + expect( + await within(secondaryShortcuts).findByRole('button', { + name: /填邀请码/u, + }), + ).toBeTruthy(); + + const profileHeader = profilePage?.querySelector('.platform-profile-header'); + expect(profileHeader).toBeTruthy(); + expect(profileHeader?.querySelector('.platform-profile-header__identity-row')).toBeTruthy(); + expect(profileHeader?.querySelector('.platform-profile-header__name')).toBeTruthy(); + expect(profileHeader?.querySelector('.platform-profile-header__code')).toBeTruthy(); + + const legalRegion = screen.getByRole('region', { name: '法律信息' }); + expect(legalRegion.className).toContain('platform-profile-legal-strip'); + expect(legalRegion.textContent).toContain('用户协议'); + expect(legalRegion.textContent).toContain('隐私政策'); + expect(legalRegion.textContent).toContain('免责声明'); + expect(legalRegion.textContent).toContain(ICP_RECORD_NUMBER); + expect(legalRegion.textContent).toContain('2026025677'); + expect(legalRegion.querySelector('.platform-profile-legal-strip__link')).toBeTruthy(); +}); + test('desktop account entry uses saved avatar image when available', () => { mockDesktopLayout(); const avatarUrl = 'data:image/png;base64,AAAA'; @@ -1886,27 +2024,33 @@ test('wallet ledger modal shows empty and error states', async () => { mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] }); renderProfileView(); - await user.click(screen.getByRole('button', { name: /泥点\s*0/u })); - expect(await screen.findByText('暂无账单记录')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /泥点余额\s*0/u })); + expect(await screen.findByText('泥点账单')).toBeTruthy(); + await waitFor(() => { + expect(screen.getByText('暂无账单记录')).toBeTruthy(); + }); await user.click(screen.getByLabelText('关闭泥点账单')); mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败')); - await user.click(screen.getByRole('button', { name: /泥点\s*0/u })); + await user.click(screen.getByRole('button', { name: /泥点余额\s*0/u })); - expect(await screen.findByText('加载失败')).toBeTruthy(); + expect(await screen.findByText('泥点账单')).toBeTruthy(); + await waitFor(() => { + expect(screen.getByText('加载失败')).toBeTruthy(); + }); expect(screen.getByText('重新加载')).toBeTruthy(); }); test('profile invite shortcut shows reward subtitle and invited users', async () => { const user = userEvent.setup(); - renderProfileView(); + renderProfileView(vi.fn(), {}, { createdAt: buildFreshProfileCreatedAt() }); const inviteButton = screen.getByRole('button', { name: /邀请好友/u }); - expect(within(inviteButton).getByText('双方得30')).toBeTruthy(); + expect(within(inviteButton).getByText('双方得 30 泥点')).toBeTruthy(); const communityButton = screen.getByRole('button', { name: /玩家社区/u }); - expect(within(communityButton).getByText('每日领福利')).toBeTruthy(); + expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy(); await user.click(inviteButton); @@ -1922,21 +2066,25 @@ test('profile invite shortcut shows reward subtitle and invited users', async () }); test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => { - renderProfileView(); + renderProfileView( + vi.fn(), + {}, + { createdAt: buildFreshProfileCreatedAt() }, + ); const inviteButton = screen.getByRole('button', { name: /邀请好友/u }); const redeemButton = await screen.findByRole('button', { name: /填邀请码/u, }); const communityButton = screen.getByRole('button', { name: /玩家社区/u }); + const secondaryShortcuts = screen.getByRole('region', { + name: '次级入口', + }); + expect(inviteButton).toBeTruthy(); + expect(communityButton).toBeTruthy(); expect( - inviteButton.compareDocumentPosition(redeemButton) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - expect( - redeemButton.compareDocumentPosition(communityButton) & - Node.DOCUMENT_POSITION_FOLLOWING, + within(secondaryShortcuts).getByRole('button', { name: /填邀请码/u }), ).toBeTruthy(); expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy(); }); @@ -2006,7 +2154,11 @@ test('profile redeem invite modal submits code and hides shortcut after success' const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); - renderProfileView(onRechargeSuccess); + renderProfileView( + onRechargeSuccess, + {}, + { createdAt: buildFreshProfileCreatedAt() }, + ); await user.click(await screen.findByRole('button', { name: /填邀请码/u })); const input = await screen.findByLabelText('邀请码'); @@ -2050,11 +2202,10 @@ test('profile page shows legal entries and ICP record link', async () => { const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); expect( - shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'), + shortcutRegion + .querySelector('.platform-profile-shortcut-grid') + ?.classList.contains('platform-profile-shortcut-grid'), ).toBe(true); - expect( - within(shortcutRegion).getByRole('button', { name: /每日任务/u }), - ).toBeTruthy(); expect( within(shortcutRegion).getByRole('button', { name: /邀请好友/u }), ).toBeTruthy(); @@ -2064,6 +2215,24 @@ test('profile page shows legal entries and ICP record link', async () => { expect( within(shortcutRegion).getByRole('button', { name: /反馈/u }), ).toBeTruthy(); + const dailyTask = screen.getByRole('button', { name: /每日任务/u }); + expect(dailyTask).toBeTruthy(); + expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点'); + + const settingsRegion = screen.getByRole('region', { name: '设置入口' }); + expect( + within(settingsRegion).getByRole('button', { name: /存档/u }), + ).toBeTruthy(); + + const secondaryShortcuts = screen.getByRole('region', { + name: '次级入口', + }); + expect( + within(secondaryShortcuts).getByRole('button', { name: /存档/u }), + ).toBeTruthy(); + expect( + within(secondaryShortcuts).queryByRole('button', { name: /填邀请码/u }), + ).toBeNull(); const legalRegion = screen.getByRole('region', { name: '法律信息' }); expect( @@ -2138,6 +2307,83 @@ test('logged in draft bottom tab shows unread marker', () => { expect(draftButton.querySelector('.platform-nav-unread-dot')).toBeTruthy(); }); +test('logged in create tab shows real wallet balance beside the brand', () => { + mockNarrowMobileLayout(); + + const { container } = render( + action(), + openSettingsModal: vi.fn(), + openAccountModal: vi.fn(), + setCurrentUser: vi.fn(), + logout: vi.fn(async () => undefined), + musicVolume: 0.42, + setMusicVolume: vi.fn(), + platformTheme: 'light', + setPlatformTheme: vi.fn(), + isHydratingSettings: false, + isPersistingSettings: false, + settingsError: null, + }} + > + 创作内容
} + /> + , + ); + + const topbar = container.querySelector('.platform-mobile-topbar'); + expect(topbar).toBeTruthy(); + expect( + topbar?.querySelector('.platform-mobile-create-wallet-chip'), + ).toBeTruthy(); + expect(topbar?.textContent).toContain('陶泥儿'); + expect(topbar?.textContent).toContain('1,234泥点'); +}); + test('mobile discover search submits public work code', async () => { const user = userEvent.setup(); const onSearchPublicCode = vi.fn(); @@ -2248,6 +2494,15 @@ test('mobile discover keeps edutainment works in the last dedicated channel only throw new Error('缺少发现面板'); } + const discoverStage = discoverPanel.querySelector( + '.platform-mobile-home-stage', + ); + expect(discoverStage).toBeTruthy(); + expect(discoverStage?.classList.contains('platform-remap-surface')).toBe( + true, + ); + expect(discoverStage?.classList.contains('platform-page-stage')).toBe(false); + const channels = Array.from( discoverPanel.querySelectorAll('.platform-mobile-home-channel'), ).map((button) => button.textContent); @@ -3117,7 +3372,6 @@ test('desktop logged in home syncs mobile home modules without square or latest expect(screen.queryByText('作品广场')).toBeNull(); expect(screen.queryByText('公开作品')).toBeNull(); expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull(); - expect(screen.getAllByText('拼图').length).toBeGreaterThan(0); expect(screen.queryByText('1777110165.990127Z')).toBeNull(); }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 881fb5d7..aa3607b4 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -11,7 +11,7 @@ import { Coins, Compass, Copy, - FileText, + Crown, Gamepad2, GitFork, Heart, @@ -20,9 +20,11 @@ import { Palette, Pencil, Plus, + ScanLine, Search, Settings, Share2, + ShieldCheck, SlidersHorizontal, Sparkles, Star, @@ -45,6 +47,16 @@ import { useState, } from 'react'; +import profileClockImage from '../../../media/profile/_Image (1).png'; +import profileGamepadImage from '../../../media/profile/_Image (2).png'; +import profileStillLifeImage from '../../../media/profile/_Image (3).png'; +import profileCoinsImage from '../../../media/profile/_Image (4).png'; +import profileInviteImage from '../../../media/profile/_Image (5).png'; +import profileGiftImage from '../../../media/profile/_Image (6).png'; +import profileCommunityImage from '../../../media/profile/_Image (7).png'; +import profileFeedbackImage from '../../../media/profile/_Image (8).png'; +import profileMascotImage from '../../../media/profile/_Image (9).png'; +import profilePointImage from '../../../media/profile/_Image.png'; import communityQqQrImage from '../../../media/social-media-group/qq.png'; import communityWechatQrImage from '../../../media/social-media-group/wechat.png'; import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth'; @@ -215,8 +227,12 @@ const MOBILE_PAGE_STAGE_CLASS = 'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2'; const MOBILE_RECOMMEND_PAGE_STAGE_CLASS = 'platform-page-stage min-w-0 space-y-4 overflow-hidden pb-2'; +const MOBILE_DISCOVER_PAGE_STAGE_CLASS = + 'platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2'; const DESKTOP_PAGE_STAGE_CLASS = 'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4'; +const DESKTOP_DISCOVER_PAGE_STAGE_CLASS = + 'platform-remap-surface min-w-0 space-y-5 pb-4'; const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)'; const PLATFORM_HOME_TABS: PlatformHomeTab[] = [ 'home', @@ -2384,12 +2400,14 @@ function ProfileStatCard({ value, onClick, icon, + imageSrc, }: { cardKey: ProfileDashboardCardKey; label: string; value: string; onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null; icon: ComponentType<{ className?: string }>; + imageSrc?: string; }) { const Icon = icon; @@ -2397,16 +2415,23 @@ function ProfileStatCard({ ); @@ -2426,11 +2451,13 @@ function ProfileShortcutButton({ subLabel, icon, onClick, + imageSrc, }: { label: string; subLabel?: ReactNode; icon: ComponentType<{ className?: string }>; onClick?: (() => void) | null; + imageSrc?: string; }) { const Icon = icon; @@ -2438,16 +2465,20 @@ function ProfileShortcutButton({ + ); +} + +function ProfileSecondaryShortcutButton({ + label, + subLabel, + icon, + onClick, +}: { + label: string; + subLabel?: string; + icon: ComponentType<{ className?: string }>; + onClick: () => void; +}) { + const Icon = icon; + + return ( + + ); +} + function ProfileLegalSection({ onOpenDocument, }: { @@ -2462,33 +2559,21 @@ function ProfileLegalSection({ }) { return (
-
- 法律信息 -
-
+
{LEGAL_DOCUMENTS.map((document, index) => ( ))}
@@ -2496,7 +2581,7 @@ function ProfileLegalSection({ href={ICP_RECORD_URL} target="_blank" rel="noreferrer" - className="mt-3 block text-center text-xs font-semibold text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)]" + className="platform-profile-legal-strip__record" > {ICP_RECORD_NUMBER} @@ -5379,7 +5464,7 @@ export function RpgEntryHomeView({ ); const mobileDiscoverContent: ReactNode = ( -
+
+
{visibleDiscoverChannels.map((channel) => { const active = discoverChannel === channel.id; @@ -5849,30 +5934,55 @@ export function RpgEntryHomeView({ const savesContent: ReactNode = draftTabContent ?? fallbackDraftContent; const profileContent: ReactNode = ( -
+
{authUi?.user ? ( <> -
-
-
+
+
+ + +
+ +
+
@@ -5887,28 +5997,27 @@ export function RpgEntryHomeView({ } /> -
+
-
+
{authUi.user.displayName}
-
- 陶泥号 {publicUserCode} +
+ 陶泥号: {publicUserCode}
- -
-
-
+ + +
+
{isLoadingDashboard ? ( <> @@ -5954,23 +6064,26 @@ export function RpgEntryHomeView({ <> @@ -5978,23 +6091,26 @@ export function RpgEntryHomeView({ <> @@ -6002,101 +6118,125 @@ export function RpgEntryHomeView({
+ +
-
+
- 领10 - - - } - icon={Star} - onClick={openTaskCenterPanel} - /> - - 0 - ? `${saveEntries.length}个可继续` - : '继续游玩' - } - icon={Archive} - onClick={() => setProfilePopupPanel('saveArchives')} - /> - {showRechargeEntry ? ( - - ) : null} - 双方得30 - - - } + subLabel="双方得 30 泥点" icon={UserPlus} + imageSrc={profileInviteImage} onClick={() => openProfilePopupPanel('invite')} /> - {canShowReferralRedeemShortcut ? ( - openProfilePopupPanel('redeem')} - /> - ) : null} + openProfilePopupPanel('community')} />
-
- + /> + setProfilePopupPanel('saveArchives')} + /> +
+ +
+ 0 + ? `${saveEntries.length}个可继续` + : '继续游玩' + } + icon={Archive} + onClick={() => setProfilePopupPanel('saveArchives')} + /> + {canShowReferralRedeemShortcut ? ( + openProfilePopupPanel('redeem')} + /> + ) : null}
@@ -6576,7 +6716,19 @@ export function RpgEntryHomeView({ {!isMobileRecommendTab ? (
- {!isAuthenticated ? ( + {isAuthenticated && activeTab === 'create' ? ( + + ) : !isAuthenticated ? ( + ) : null}