共创升为侧边栏顶层入口(桌面+窄屏),删游戏页子页签;共创页卡片加「共创 N」次数角标(coCreationCount 优先、回退 forkCount)
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 17:44:15 +08:00
parent 21582cedc2
commit cc9c3f7be1
11 changed files with 311 additions and 152 deletions
@@ -1,5 +1,17 @@
# 决策记录
## 2026-10-07 「共创」从游戏页子页签升为侧边栏顶层入口;共创页卡片显示「共创 N」次数
- 用户口径(原话):「共创页不应该是游戏页里的子标签,而应该是**侧边栏里的大标签**」;「共创页里的游戏卡片要突出一个**共创次数**(即它有多少个子项目)」。
- 决策(入口):桌面侧边栏一级入口改为 `创作 / 项目 / 游戏 / **共创** / 创作者主页 / 我的`(「共创」紧挨「游戏」、同一套 `ActiveRailButton`),窄屏底部 dock 改为 `游戏 / **共创** / 创作者主页 / 我的`(4 列)。两侧都指向**同一个** `GameGalleryPage`(`scope="forkable"`,`/games/co-creation`,请求带 `forkable=1`),**不新建页面、不新建路由**。
- 决策(子页签删除):`GameGalleryPage` 与 `GameThemesPage` 里那段「游戏 / 共创」子页签**整段删除**(它只为这个切换而存在);随之删掉 `GameGalleryPage` 的 `onOpenGallery` / `onOpenThemes` 两个 prop(调用点一并清理,不留兼容壳)。**主题列表页与主题详情路由保留**,入口改为「作品详情 → 所属主题」或直接 URL。
- 决策(高亮互斥):`isGamesStage`(游戏区,含共创,决定搜索口语/面板滚动)与**导航高亮**拆开——新增 `isCoCreationStage`,导航用 `isGamesNavActive = isGamesStage && !isCoCreationStage`,保证进共创时高亮「共创」而不是「游戏」。
- 决策(老链接):子页签从来只有 `setSelectionStage('game-co-creation', { path: '/games/co-creation' })`,没有自己的路径,因此 **`/games/co-creation` 深链语义不变**、无需重定向;路由表/stage 枚举/标题表本来就有这一项。
- 决策(共创次数角标):在**共创页**卡片封面左上(复用共享卡 `GameCard` 的 `badges` 槽 + `pointer-events: none`)显示实心强调色角标「共创 N」,**只在共创页出现**(广场/游戏页不传 `badges`,默认表现零改动)。取值 `resolveCoCreationBadgeCount(game) = game.coCreationCount ?? game.forkCount`,`<=0` 或缺省**不渲染**(不显示「共创 0」)。
- 口径(2026-10-07 服务端已定稿并落地):`coCreationCount` = **该作品子树里公开可见的全部后代数(不含自己)**,`forkCount` = **直接子代数量**;两者并存、互不推导(同一张卡上前者 ≥ 后者)。可见性规则与 `forkCount` 同一份(未软删除 + 已公开),已删 / 未公开的后代不进任何一层计数;血缘环 / 自引用按 `visited` 去重,自己永不计数。**公开投影(公开目录 / 公开详情 / 我的收藏 / 主题成员)恒发 `coCreationCount`(没有后代时是 `0`,不是缺键)**,`scripts/check-game-distribution-dto-parity.mjs` 已把它与 `forkCount` 一起钉成 `public_game_payload` 的 `mustEmit`;**作者侧同形响应不发**该键,因此读取方仍按 `coCreationCount ?? forkCount` 取值。计数由 spacetime 在**只读事务里现算**:一次建 `module_game_distribution::PublicDerivativeIndex`(父 → 直接子代血缘边 + 可见性集合)+ 每行一次 BFS 子树计数,不新增表 / 列 / 索引、不落物化计数,api-server 只发键不遍历。前端(`resolveCoCreationBadgeCount`)无需改动。
- 影响面(本轮,只动 `src/**` + `packages/shared/**` 可加扩展 + `docs/**`):`src/components/platform-entry/PlatformEntryActiveFlowShell.tsx`(rail + dock + stage 标志)、`PlatformEntryActiveFlowShell.test.tsx`、`src/components/game-distribution/GameGalleryPage.tsx`(删子页签、加角标)、`GameThemesPage.tsx`(删同一行子页签)、`GameDistributionPages.test.tsx`、`src/components/game-distribution/gameDistribution.css`(角标样式,新增块)、`packages/shared/src/contracts/gameDistribution.ts`(可加字段 `coCreationCount?`)、`docs/【技术方案】游戏共创与作品Fork-2026-10-03.md`(§3.4 / §3.6.1 / §3.10)、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(导航基线)。
- 验证方式:`npx vitest run src/components/game-distribution src/components/platform-entry src/components/creator src/routing src/components/creation-home`、`npm run typecheck`、全仓 `npx eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0`、`npm run check:encoding`、`git diff --check`;真机(只读用户栈 `127.0.0.1:8082` + 独立端口 Vite):桌面「侧边栏点共创 → 列表(含共创次数角标)→ 点卡进详情 → 返回」、窄屏底部导航一遍、深链 `/games/co-creation` 直达、游戏页(子页签移除后)无死路。
## 2026-10-07 Fork 建项收口:成品包铺进预览根、登记可运行原型与初始工程版本
- 背景(真 bug,用户实测):用户在 AGC 里 Fork 了一个只发布过成品包的作品(`source: package`),项目建出来之后**运行页签不可用**——只显示「首个可运行原型尚未完成,运行视图暂不可用」,点击无反应,发布也被同一判据拦下。实物证据:`%APPDATA%/world.genarrative.ai-game-creator/projects/gameagent-a74aa1d9`(2026-10-06 15:27:58 建项)的 manifest **16 个 seed 任务全 `pending`(含 `code-prototype`)、没有 `versions[]`、没有 `preview`**,小到一个 296 B 的默认占位 `game/index.html`;父作品那套真实可玩产物(`index.html` + `assets/index-*.js` + 素材)被解到 `reference/<gameId>/` —— 该目录**不在预览根**,`preview::project_game_root()` 与服务预览的 `resolve_preview_path()` 都走不到,AGC 界面也没有任何入口能播它。
@@ -321,6 +321,7 @@ pub(crate) project_bundle_sha256: Option<String>,
| `GET /games/{gameId}`(**既有,响应增量**) | 追加 `forkAuthorization`、`forkCount`、`lineage`(可选)。**不追加 `forkSourceAvailable`**:网页端「改造这个作品」入口的显隐只依据 `forkAuthorization`(已公开 + 非禁止即可引导去取件),真实可复刻形态由 `/games/{gameId}/fork-source` 的 `source` 字段回答,不需要在公开详情里提前判断;若 M2b 需要在详情页区分「可源码级改造 / 只能参考」,再在 M2b 里加该字段。**另追加 `collected`(仅登录用户,2026-10-06)**:登录已认证时返回 `true` / `false`(真实投影,值来自 `is_game_distribution_collected_and_return`);**匿名请求不返回该字段**(也不发 `false`——`false` 会把「未登录」说成「没收藏」)。该字段随请求者变化,所以这条路径必须 `no-store`、不得有任何共享缓存。**再追加 `themes: [{ themeId, name, badge }]`(2026-10-06 增量,共创主题)**:该作品所属的**公开**主题(只含 `status == published`);**匿名与登录都发、无主题时恒发空数组**(与「仅登录才发」的 `collected` 不同——没有主题与没登录因此不会被混成同一种缺键)。口径是「先取该作品的**根**、再按根反查公开主题」,所以**第 N 代作品也能看到并跳到主题页**;单条只够渲染跳转入口(简介与成员数去主题页取)**。**版本摘要 `currentVersion` 再追加 `changeSummary`(2026-10-06,衍生作品发布必填项)**:该版本相对**改编来源**作品的核心改动说明——衍生作品必有(`20–500` 个字符,详见下方作者路由的 `POST /versions` 行),0 代母版为 **`null`**(发键不发值:客户端不必靠缺键猜),因此详情页与族谱溯源能逐代展示「这一版改了什么」。它不含任何私有字段(没有对象键 / 素材 id),匿名可读,也不需要按查看者变化 |
| `GET /games/{gameId}/lineage`(新) | 以该 game 的根为顶返回树:`{ rootGameId, root: LineageNode \| null, nodes: [LineageNode], truncated }`,`LineageNode = { gameId, title, authorName, generation, parentGameId, playCount, status, coverObjectKey }`(`coverObjectKey` 为该节点作品**当前生效的封面对象键**,与公开目录 / 详情**同源同口径**、都来自游戏行 `cover_object_key`,**无封面时为 `null`**;只发对象键,不带 `coverAssetId` 等私有 id),按代际升序 / 同代创建时间升序稳定排序;节点上限 200,超出返回 `truncated: true`。**锚点必须公开可读**:未公开、已软删除或不存在的作品返回 404(与公开详情同口径),不用空标题占位或空树代替 404。树内只出现未软删除且已公开的作品;父/祖辈被排除时孩子照常出现并保留 `generation` 与 `parentGameId`,由展示层标注「原作品已不可用」,不补 null 占位节点。**「累计世代数」不是服务端字段**:本作品子树的深度 = 子树最大代际 − 本作品代际(根作品若有 3 层后代则为 3),前端从同一次响应的 `generation` 自算,服务端不新增字段也不为此多查一次。**右侧面板的「本次核心改动说明」不贴到节点上**:`LineageNode` 保持现在的字段集(不加 `changeSummary`),面板只对**选中节点**请求一次公开详情取 `currentVersion.changeSummary`(衍生作品为字符串、母版为 `null`,见上方公开详情行)——即**不做 N+1**:不是每渲染一个节点就查一次详情(两条前端口径已落地:`bd73acfcc`) |
| `GET /games/{gameId}/derived`(新,可选分页) | 直接子代列表:`{ gameId, nodes: [LineageNode], truncated }`,只含未软删除且已公开的直接子代(与公开详情 `forkCount` 同口径,因此条数与「被改编 N」一致);锚点同样必须公开可读,否则 404 |
| 公开作品投影(`public_game_payload`,**2026-10-07 增量**) | **已有** `forkCount` = **被改编次数(直接子代数量)**,公开目录与公开详情都发。**新增(2026-10-07 字段定义已定稿;⚠️ **当前运行栈实测尚未返回该键**,见本行末尾)** `coCreationCount` = **「共创次数」= 该作品子树里公开可见的全部后代数(不含自己)**——网页端「共创」页卡片上的「共创 N」角标优先读它、缺省时回退 `forkCount`(见 §3.6.1 的卡片条目)。**两个计数的口径(不可互相推导)**:`forkCount` 只数**直接子代**(「被改编 N」);`coCreationCount` 数**整棵子树**的可见后代(「共创 N」),同一张卡上后者的数字只会 ≥ 前者。两者共用同一批可见性规则(`module_game_distribution::counts_as_public_derivative`:**未软删除 + 已公开**),只是深度不同;已删 / 未公开的后代**不进任何一层计数**(中间作品不可见也不断链:它可见的后代仍算进祖先的后代数,与族谱「父被排除时孩子照常出现」同一口径);血缘环 / 自引用按 `visited` 去重,**自己永远不计数**。`scripts/check-game-distribution-dto-parity.mjs` 已把 `forkCount`、`coCreationCount` **都**钉成「公开 payload 必须发」的字段(`mustEmit`),因此**可依赖**;**没有后代时发 `0` 而不是缺键**(缺键会被读成旧响应而触发回退)。**作者侧同形响应不发 `coCreationCount`**(与 `currentVersion` / `ratingSummary` 同类:它只是公开投影的字段),所以读取方仍需按 `coCreationCount ?? forkCount` 取值。**算法与成本(事务侧,2026-10-07 定稿)**:公开目录事务本来就要全表扫 `game_distribution_game` 做过滤 / 排序 / 切页,因此在该**只读事务里先建一次**「父 → 直接子代」血缘索引 + 一次可见性集合(`PublicDerivativeIndex`),再对**本页每一行**做一次 BFS 子树计数(`visited` 去重,边际成本 O(后代数)~O(作品数));索引**每个事务只建一次**(公开目录 / 公开详情 / 我的收藏 / 主题成员各自建一条),**不新增表 / 列 / 索引、不落物化计数**(避免与下架 / 封禁 / 删除状态产生第二份真相),**api-server 只发键、不遍历**。证据:`module-game-distribution` 的 `PublicDerivativeIndex` 纯函数单测(根 + 3 直接子代 + 1 孙代 ⇒ `forkCount == 3`、`coCreationCount == 4`;无子代 ⇒ 两键皆 `0` 且恒发;软删 / 未公开后代不计数;自引用与血缘环安全)+ api-server 的 payload 测试 + 本 parity 脚本。 |
| `GET /api/game-distribution/themes?limit=&cursor=`(新,2026-10-06) | 公开共创主题列表。**匿名可读 + `no-store`**,只含 `status == published` 的主题。响应 `{ themes: [{ themeId, name, summary, badge, memberCount }], nextCursor }`;`memberCount` = 该主题**真实可见成员数**(不截断、不套响应体积上限,也不是成员行总数)。它与详情 `roots` 的长度在可见成员超过 50 时**故意不相等**——「展示上限」不该污染「这个主题有多少棵树」,判定口径仍是同一份。分页沿用 `/my-collections` 那套游标惯例:`limit` 缺省 **20**、上限 **50**、超界**截断**(客户端拿到一个完整页,而不是需要重试的错误);`cursor` 形如 `"{createdAtMicros}:{themeId}"`(解析只切第一个冒号);**非法游标 → 400 `THEME_INVALID_CURSOR`**(模块侧报错透传,不吞成 200 空页);`nextCursor` 为真实值,**末页为 `null`**。排序 `created_at` 倒序 + `themeId` 升序兜底(全序,翻页不重不漏),顺序定义为「**先按可见性过滤、再排序切页**」 |
| `GET /api/game-distribution/themes/{themeId}`(新,2026-10-06) | 公开主题详情。**匿名可读 + `no-store`**;顶层**扁平** `{ themeId, name, summary, badge, memberCount, roots, rootsTruncated }`(与 `GET /games/{gameId}` 同形,不引入第二套包装)。`roots` 逐条是公开目录**同一份** `public_game_payload` 投影,按 `sort_order` 升序 + `member_id` 升序稳定排序(不泄露对象键、不泄露未公开作品)。主题**不存在 / `draft` / `archived` 一律 404**(模块侧回同一句「主题不存在」,由既有映射落 404;**不返回空壳**、公开侧不发 `THEME_*` 码);**已发布但可见成员为空 = 200 + 空 `roots`**(空态而不是错误)。**成员数口径已定(2026-10-06)**:`roots` 受响应体积上限 **50** 约束(排序后取前 50 条),`memberCount` 报**真实可见成员数**(不截断),`rootsTruncated` 仅当 `roots` 少于 `memberCount` 时为 `true`——即「下面这份 `roots` 被上限截断」的显式信号,与族谱响应的 `truncated` 同约定(详见 §3.10.6 与 §3.10.9) |
@@ -499,7 +500,12 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr
- **封面盒几何口径(2026-10-07 收口)**:共享样式 `.game-cover` 自带 `min-height: 12rem`(紧凑态 `7rem`),会把族谱封面里的内层盒子撑到 192px,使 `height: 100%` 永远不生效(图片盒虚高、只靠 `overflow: hidden` 裁)。族谱封面里已显式 `.game-lineage-node__cover .game-cover { min-height: 0 }` —— **内层盒与 img 盒真正等于封面盒**(真机:画布卡片 127.4×71.7、清单行 72×40.5,比例都是 1.778 = 16:9,`object-fit: cover`)。
- **全屏 overlay 的画布高度口径**:全屏模式画布高度 = **视口高度 − 头部栏 − 筛选/控件两行**(`--lineage-overlay-chrome: 6.5rem`,真机 900−54−50 = **796px**),即「吃满一屏、底部没有空白带」,图下方的父代/子代清单接在画布下面随 overlay 滚动;**内嵌在详情页时仍用可读高度上限** `clamp(22rem, 62vh, 46rem)` —— 两种模式分开取值。
- **截断 / 空树 / 不可用父节点**在两种形态下都有降级:截断提示、空态、以及「原作品已不可用」的占位节点(可聚焦、不可点进详情、不展示被删作品标题与作者名)。
- **「共创」页签口径(2026-10-07,用户口径)**:页签与「游戏」页签**复用同一个 `GameGalleryPage`**(`scope="forkable"`),数据来自公开目录并带 `forkable=1`(分页/游标/去重/末页/失败态全部沿用已实现的「加载更多」);**服务端尚未生效期间客户端再兜一层同义过滤**(`forkAuthorization !== 'forbidden'`,未知按禁止失败关闭),两条口径一致、服务端上线后等价幂等。页签不再指向共创主题列表(主题列表页与路由保留,无页签入口);页面标题 `game-co-creation` → 「共创 - 陶泥儿」,路由 `/games/co-creation`,移动端门限同样按「只读浏览 = 作品展示」豁免。空态为「暂时没有开放共创的作品」。
- **「共创」= 侧边栏顶层入口(2026-10-07 用户口径修正,取代同日早前的「游戏页子页签」)**:用户明确「共创页不应该是游戏页里的子标签,而应该是**侧边栏里的大标签**」。因此:
- **入口**:桌面侧边栏一级入口为 `创作 / 项目 / 游戏 / 共创 / 创作者主页 / 我的`(「共创」紧挨「游戏」,与它平级、**同一套** `ActiveRailButton` 样式),窄屏底部 dock 同步为 `游戏 / 共创 / 创作者主页 / 我的`(4 列)。两侧都指向**同一个** `GameGalleryPage`(`scope="forkable"`):数据来自公开目录并带 `forkable=1`(分页/游标/去重/末页/失败态全部沿用已实现的「加载更多」);**服务端尚未生效期间客户端再兜一层同义过滤**(`forkAuthorization !== 'forbidden'`,未知按禁止关闭),两条口径一致、服务端上线后等价幂等。页面标题 `game-co-creation` → 「共创 - 陶泥儿」,路由 `/games/co-creation`,移动端门限同样按「只读浏览 = 作品展示」豁免。空态为「暂时没有开放共创的作品」。
- **高亮互斥**:进共创时高亮「共创」而**不再**高亮「游戏」——`isGamesStage`(游戏区,含共创,决定搜索口语/面板滚动)与导航高亮拆开:新增 `isCoCreationStage`,导航用 `isGamesNavActive = isGamesStage && !isCoCreationStage`。
- **子页签删除**:游戏页与共创主题列表页里的「游戏 / 共创」子页签**全部删除**(它只为这个切换而存在)——`GameGalleryPage` 的 `onOpenGallery` / `onOpenThemes` 两个 prop 与那段 `role="tablist" aria-label="游戏区"` 一并移除,`GameThemesPage` 的同一行也删除(它保留「返回」,入口来自作品详情的「所属主题」或直接 URL)。
- **老链接 / 深链**:子页签从来没有自己的路径(点击即 `setSelectionStage('game-co-creation', { path: '/games/co-creation' })`),因此 **`/games/co-creation` 这个深链语义完全不变**、收藏与分享继续可用,无需重定向;路由表(`activeAppPageRoutes`)、stage 枚举、标题表本来就有这一项,未改。
- **卡片「共创 N」次数**:共创页卡片在**封面左上**(复用共享卡 `GameCard` 的 `badges` 槽,`pointer-events: none`)显示实心强调色角标「共创 N」,**只在共创页出现**(广场/游戏页不传 `badges`,默认表现零改动)。取值口径 `resolveCoCreationBadgeCount`:优先服务端公开目录新增的 `coCreationCount`,缺省时**回退**契约里已有的 `forkCount`(**直接子代数量**,`scripts/check-game-distribution-dto-parity.mjs` 已强制公开 payload 必须发它),两者都为 0/缺省时**不渲染角标**(不显示「共创 0」)。**口径已定(2026-10-07)**:`coCreationCount` = **公开可见的全部后代数(不含自己)**、`forkCount` = **直接子代**,两者并存、互不推导;公开投影(目录 / 详情 / 我的收藏 / 主题成员)**恒发** `coCreationCount`(没有后代时是 `0`,不是缺键),因此卡片上的数字在公开路径上直接取它,回退分支只服务作者侧同形响应 / 旧响应。
- **初始适配口径(2026-10-07 复核)**:初次进入按**宽高同时 fit**(`min(scaleX, scaleY)` + `FIT_PADDING`)并**水平垂直都居中**;若这个比例低于可读下限 `READABLE_MIN_SCALE = 0.6`,就**不强行全览**,改用可读缩放 + 平移,并给出明确提示「树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「总览」看全树。」——两种情况下都**不会**在没有提示的前提下把最深的代际丢在视口外。窄屏「图谱视图」另按**宽度**适配(见上文)。
- **主干线唯一性复核(2026-10-07,真实数据)**:演示树 `共创演示·A0|星海工坊`(8 节点、`playCount` 全 0,仅 A111 = 1)实测 **每代恰好一个主干**:A0(0 代)/ A1(1 代)/ A11(2 代)/ A111(3 代);A2、A3、A12、A21 **都不是**主干。同代并列 0 热度时取「同代中**创建时间最早**」的那个(`/lineage` 契约即「代际升序、同代按创建时间升序」,布局只按输入顺序稳定取第一个),因此结果确定、不随渲染变化。**结论:主干逻辑未改**;新增「每代 isTrunk 数量恰好 1」的断言把它钉住(`lineageTreeLayout.test.ts`)。
@@ -621,7 +627,7 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr
### 3.10 共创主题(平台命名的作品树归组实体)
> **形态**:网页端「游戏 / 共创」两个页签**展示的都是游戏卡片**(同一套组件、同一套游标分页)——「游戏」= 全部公开作品,「**共创**」= **只列支持共创的作品**(请求带 `forkable=1`,见 §3.4 目录接口)。**2026-10-07 口径变更**:早前「共创」页签进的是共创主题列表,用户明确「先别整主题了」,所以页签改直出作品卡片;**共创主题的实现与路由全部保留**(`/games/themes` 主题列表、`/games/theme?id=` 主题详情、作品详情里的主题链接),只是**页签不再指向主题列表**,主题相关页面改由「作品详情 → 所属主题」或直接 URL 进入。
> **形态**:网页端「游戏 / 共创」**不再是页内子页签**——2026-10-07 用户口径修正后,「共创」是**侧边栏顶层入口**(与「游戏」平级、桌面与移动端都有),指向的仍是 `scope="forkable"` 的 `GameGalleryPage`(**只列支持共创的作品**,请求带 `forkable=1`,见 §3.4 目录接口与 §3.6.1 的入口条目)。早前两次口径变更记录在案:先「共创页签进共创主题列表」→ 用户「先别整主题了」改成直出作品卡片 → 再「不该是子标签、应该是侧边栏大标签」升为顶层入口;**共创主题的实现与路由全部保留**(`/games/themes` 主题列表、`/games/theme?id=` 主题详情、作品详情里的主题链接),只是没有任何页签入口,主题相关页面由「作品详情 → 所属主题」或直接 URL 进入。
>
> 本节把 §7 第 3 条(「是否引入共创主题实体」)从**待拍板**收敛为**已拍板**:采纳「平台命名主题 → 作品树」。归属关系的**曝光口径不变**——作品之间不互相挂靠,主题是运营侧的**额外归组维度**,不是作品的可读锚点。**设计定稿于 2026-10-06(commit `063c04a1b`);服务端三块随后落地**:数据模型与领域纯函数 `2fa201e0d`、公开读路径 `742723a58`、后台写路径 `033e3aa79`。**前台(共创 Tab / 主题页 / 详情页入口)与后台管理 UI 仍未做**。
>
@@ -678,14 +678,14 @@
让用户从游戏详情进入任意作者主页、查看作者公开游戏、关注或取消关注,并在自己的列表管理关注和粉丝。验收以真实账号关系、计数、公开游戏过滤及跨页回读一致为准。
2026-10-05 实现状态:桌面导航为“创作 / 项目 / 游戏 / 创作者主页 / 我的”,移动端为“游戏 / 创作者主页 / 我的”;`activeAppPageRoutes` 已接入创作者主页和关系列表。`GameDetailPage` 通过共享 `GameDetailDisplay` 的作者插槽提供主页链接和独立关注按钮。私有关注关系表、查询/写入 API 以及页面交互已实现;后端已由用户验收,前端工程验证通过,用户已要求提交并推送。本文前部旧入口说明不作为本专题的导航基线。
2026-10-05 实现状态:桌面导航为“创作 / 项目 / 游戏 / 创作者主页 / 我的”,移动端为“游戏 / 创作者主页 / 我的”;`activeAppPageRoutes` 已接入创作者主页和关系列表。**2026-10-07 变更**:新增**顶层「共创」入口**(桌面导航扩为“创作 / 项目 / 游戏 / **共创** / 创作者主页 / 我的”,移动端底部 dock 扩为“游戏 / **共创** / 创作者主页 / 我的”),指向 `scope="forkable"` 的作品列表 `/games/co-creation`;原先“游戏”页与主题列表页里的“游戏 / 共创”子页签已删除(共创不再挂在游戏页下)。`GameDetailPage` 通过共享 `GameDetailDisplay` 的作者插槽提供主页链接和独立关注按钮。私有关注关系表、查询/写入 API 以及页面交互均已实现;后端已由用户验收,前端工程验证通过,用户已要求提交并推送。本文前部旧入口说明不作为本专题的导航基线。
必须项为入口、主页、双向列表查看、关注/取消关注/重新关注、本人移除粉丝、公开游戏列表及详情跳转。风险项为关系方向、越权、公开/私有游戏隔离、并发请求与关系列表分页回读。私信、关注动态、通知、拉黑、推荐榜单、主页装修、关系隐私设置和既有游戏目录分页改造不纳入首版。
### 页面与导航
- 桌面导航保留前三项顺序,新增第四项“创作者主页”,“我的”变为第五项。第四项默认进入当前登录账号自己的主页(用户已确认)。
- 移动端为“游戏 / 创作者主页 / 我的”,保留创作、项目的现有桌面限制,不把五项桌面入口强行塞入底部导航。
- 桌面导航保留前三项顺序,新增第四项“创作者主页”,“我的”变为第五项。第四项默认进入当前登录账号自己的主页(用户已确认)。**2026-10-07 变更**:在“游戏”与“创作者主页”之间插入顶层“共创”(顺序为 `创作 / 项目 / 游戏 / 共创 / 创作者主页 / 我的`),指向 `/games/co-creation`。
- 移动端为“游戏 / **共创** / 创作者主页 / 我的”(2026-10-07 起为四项),保留创作、项目的现有桌面限制,不把六项桌面入口全塞入底部导航;共创是公开只读的作品列表,窄屏同样可达。
- 主页路由 `/creators?id=<userId>`;裸 `/creators` 表示自己的主页,登录后解析当前账号。显式 ID 深链可刷新、分享及直接访问,自己的主页与他人的主页共用页面。
- 列表路由 `/creators/connections?id=<userId>&tab=following|followers`,关注数和粉丝数分别进入对应列表;返回恢复来源页及分页/滚动位置,直接访问的返回兜底为所属主页。
- 主页和关系列表的返回优先使用应用内浏览器历史;没有可回退的应用内历史时,列表替换当前条目到所属主页,主页替换当前条目到游戏广场 `/games`。兜底返回不得新增历史条目,避免连续返回在主页地址之间循环。
@@ -364,8 +364,27 @@ export type GameDistributionGame = {
* 只有母版能单向提升,因此 `forbidden` 只可能出现在母版上。
*/
forkAuthorization?: GameDistributionForkAuthorization;
/** 被改编次数(直接子代数量);旧数据或字段缺省时读作 0。 */
/**
* 被**直接**改编的次数(直接子代数量);旧数据或字段缺省时读作 0。
*
* 与 `coCreationCount` 的区别:这里只数**直接**子代(「被改编 N」),`coCreationCount`
* 数**整棵子树**的可见后代(「共创 N」)。两者共用同一批可见性规则,只是深度不同。
*/
forkCount?: number;
/**
* **共创次数** = 该作品子树里**公开可见的全部后代**数(**不含自己**)。
*
* - 口径(服务端 2026-10-07 定稿):沿血缘边 BFS 整棵子树,只数「未软删除 + 已公开」的后代
* (与 `forkCount` 同一批规则 `counts_as_public_derivative`,只是深度不同);已删 / 未公开的
* 后代不进任何一层计数。血缘环 / 自引用按已访问集合去重,自己永远不计数。
* - **公开投影恒发该键**:公开目录、公开详情,以及共用同一份公开投影的「我的收藏」与
* 「主题成员」都会发出;没有后代时发 `0`(不是缺键——缺键会被读成旧响应)。
* - 作者侧同形响应(我的作品 / 创建 / 提交 / 审核 / 撤回)**不发**这个键,因此读取方仍需
* 回退:`coCreationCount ?? forkCount`,两者都缺省或 `<= 0` 时不渲染角标。
* - 计数在服务端**只读事务里现算**(一次建「父 → 直接子代」索引 + 一次子树计数),不落表、
* 不新增索引,api-server 只发键不遍历。
*/
coCreationCount?: number;
/** 血缘摘要;根作品或旧数据为 null/缺省。 */
lineage?: GameDistributionLineage | null;
/**
@@ -381,7 +381,7 @@ describe('GameGalleryPage', () => {
);
});
it('scope=forkable(共创页签):请求带 forkable、渲染游戏卡片而非主题卡、无主题入口', async () => {
it('scope=forkable(共创页):请求带 forkable、渲染游戏卡片而非主题卡、无主题入口', async () => {
listGamesMock.mockResolvedValueOnce({
games: [
createGame({
@@ -392,14 +392,7 @@ describe('GameGalleryPage', () => {
],
nextCursor: null,
});
const onOpenGallery = vi.fn();
render(
<GameGalleryPage
scope="forkable"
onOpenDetail={vi.fn()}
onOpenGallery={onOpenGallery}
/>,
);
render(<GameGalleryPage scope="forkable" onOpenDetail={vi.fn()} />);
// ① 请求带 forkable(服务端未上线时参数会被忽略,客户端还有同义兜底过滤)。
await screen.findByText('可以共创的作品');
@@ -412,16 +405,86 @@ describe('GameGalleryPage', () => {
expect(document.querySelectorAll('.game-grid > *').length).toBe(1);
expect(document.querySelector('.game-themes-page')).toBeNull();
expect(screen.queryByText(/棵作品树/u)).toBeNull();
// ⑤ 「共创」页签是激活态,且**没有**任何指向主题列表的入口。
// ③ 「游戏 / 共创」子页签已删除(共创升为侧边栏顶层入口):
// 本页不再有任何「游戏区」页签,也不再有指向主题列表的入口。
expect(screen.queryByRole('tablist', { name: '游戏区' })).toBeNull();
expect(screen.queryByRole('tab', { name: '共创' })).toBeNull();
// 分类 / 设备筛选页签仍在。
expect(screen.getByRole('tablist', { name: '游戏分类' })).toBeTruthy();
});
it('scope=forkable:卡片带「共创 N」角标,优先 coCreationCount、回退 forkCount、0 不渲染', async () => {
listGamesMock.mockResolvedValueOnce({
games: [
{
...createGame({
id: 'game-new-field',
title: '服务端新字段',
forkAuthorization: 'full',
}),
coCreationCount: 7,
forkCount: 3,
},
{
...createGame({
id: 'game-legacy-field',
title: '老字段回退',
forkAuthorization: 'full',
}),
forkCount: 3,
},
{
...createGame({
id: 'game-zero',
title: '还没人共创',
forkAuthorization: 'full',
}),
coCreationCount: 0,
forkCount: 0,
},
],
nextCursor: null,
});
render(<GameGalleryPage scope="forkable" onOpenDetail={vi.fn()} />);
const newFieldCard = (await screen.findByText('服务端新字段')).closest(
'.game-card',
) as HTMLElement;
expect(
screen.getByRole('tab', { name: '共创' }).getAttribute('aria-selected'),
).toBe('true');
newFieldCard.querySelector('.game-card__co-creation-badge')?.textContent,
).toBe('共创 7');
const legacyCard = screen
.getByText('老字段回退')
.closest('.game-card') as HTMLElement;
expect(
screen.getByRole('tab', { name: '游戏' }).getAttribute('aria-selected'),
).toBe('false');
// 「游戏」页签调回调回广场(不自己改 URL)。
fireEvent.click(screen.getByRole('tab', { name: '游戏' }));
expect(onOpenGallery).toHaveBeenCalledTimes(1);
legacyCard.querySelector('.game-card__co-creation-badge')?.textContent,
).toBe('共创 3');
const zeroCard = screen
.getByText('还没人共创')
.closest('.game-card') as HTMLElement;
expect(zeroCard.querySelector('.game-card__co-creation-badge')).toBeNull();
// 角标在共享卡的 badges 槽里(绝对定位在封面左上),不是新加的卡内行。
expect(newFieldCard.querySelector('.game-card__badges')).toBeTruthy();
});
it('游戏广场(scope=all)卡片不带「共创 N」角标', async () => {
listGamesMock.mockResolvedValueOnce({
games: [
{
...createGame({ id: 'game-plain', title: '广场作品' }),
forkCount: 5,
coCreationCount: 5,
},
],
nextCursor: null,
});
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
const card = (await screen.findByText('广场作品')).closest(
'.game-card',
) as HTMLElement;
expect(card.querySelector('.game-card__badges')).toBeNull();
expect(card.querySelector('.game-card__co-creation-badge')).toBeNull();
});
it('scope=forkable:客户端兜底过滤掉禁止共创的作品(与服务端 forkable 同义)', async () => {
@@ -2474,13 +2537,7 @@ describe('GameThemesPage', () => {
nextCursor: null,
});
const onOpenTheme = vi.fn();
render(
<GameThemesPage
onBack={vi.fn()}
onOpenGallery={vi.fn()}
onOpenTheme={onOpenTheme}
/>,
);
render(<GameThemesPage onBack={vi.fn()} onOpenTheme={onOpenTheme} />);
expect(await screen.findByText('精选共创')).toBeTruthy();
expect(screen.getByText('精选')).toBeTruthy();
@@ -2518,13 +2575,7 @@ describe('GameThemesPage', () => {
],
nextCursor: null,
});
render(
<GameThemesPage
onBack={vi.fn()}
onOpenGallery={vi.fn()}
onOpenTheme={vi.fn()}
/>,
);
render(<GameThemesPage onBack={vi.fn()} onOpenTheme={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
await waitFor(() =>
@@ -2538,26 +2589,14 @@ describe('GameThemesPage', () => {
it('没有主题时给出空态,失败时给出错误与重试', async () => {
listThemesMock.mockResolvedValueOnce({ themes: [], nextCursor: null });
render(
<GameThemesPage
onBack={vi.fn()}
onOpenGallery={vi.fn()}
onOpenTheme={vi.fn()}
/>,
);
render(<GameThemesPage onBack={vi.fn()} onOpenTheme={vi.fn()} />);
expect(
await screen.findByText('还没有已发布的共创主题,稍后再来看看。'),
).toBeTruthy();
cleanup();
listThemesMock.mockRejectedValueOnce(new Error('网络错误'));
render(
<GameThemesPage
onBack={vi.fn()}
onOpenGallery={vi.fn()}
onOpenTheme={vi.fn()}
/>,
);
render(<GameThemesPage onBack={vi.fn()} onOpenTheme={vi.fn()} />);
expect(await screen.findByText('网络错误')).toBeTruthy();
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy();
});
@@ -37,17 +37,13 @@ type GameGalleryPageProps = {
onOpenMyGames?: () => void;
onOpenPublish?: () => void;
/**
* 页签作用域:`all` = 游戏广场(全部公开作品,默认);`forkable` = **共创页签**
* 页签作用域:`all` = 游戏广场(全部公开作品,默认);`forkable` = **共创页**
* (只列**支持共创**的作品,请求带 `forkable=1`)。
*
* 2026-10-07:`forkable` 从「游戏页里的子页签」升级为**侧边栏顶层入口**
* (`/games/co-creation`,侧边栏「共创」),页面内的「游戏 / 共创」子页签已删除。
*/
scope?: 'all' | 'forkable';
/** 切到「游戏」页签(`/games`);缺失时不渲染该页签。 */
onOpenGallery?: () => void;
/**
* 切到「共创」页签(只列支持共创的作品);缺失时不渲染该页签。
* 注意:它**不再**指向共创主题列表——主题列表页与路由仍保留,只是没有页签入口了。
*/
onOpenThemes?: () => void;
};
/** 页签作用域 → 只保留了「支持共创」这一条口径的过滤(与 `forkable=1` 同义)。 */
@@ -55,14 +51,30 @@ function isForkable(game: GameDistributionGame) {
return resolveGameForkAuthorization(game.forkAuthorization) !== 'forbidden';
}
/**
* 卡片上的「共创 N」次数(模块私有:只在共创页的卡片上消费)。
*
* 字段口径(2026-10-07,服务端字段待上线):
* - 优先读服务端公开目录**新增**的 `coCreationCount`(以服务端为准);
* - 老响应 / 服务端未上线时回退到**已有**的 `forkCount`(契约里写明是「被改编次数 = 直接子代数量」);
* - 两者都缺省、或为 0 → 返回 `null`(**不渲染角标**,也不渲染「共创 0」)。
*
* 只在 `scope === 'forkable'` 时使用,广场 / 游戏页卡片不受影响。
*/
function resolveCoCreationBadgeCount(
game: Pick<GameDistributionGame, 'coCreationCount' | 'forkCount'>,
): number | null {
const raw = game.coCreationCount ?? game.forkCount;
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) return null;
return Math.floor(raw);
}
export function GameGalleryPage({
searchKeyword = '',
onOpenDetail,
onOpenMyGames,
onOpenPublish,
scope = 'all',
onOpenGallery,
onOpenThemes,
}: GameGalleryPageProps) {
const forkableOnly = scope === 'forkable';
const initialFiltersRef = useRef<GameGalleryFilters | null>(null);
@@ -368,46 +380,6 @@ export function GameGalleryPage({
</label>
</div>
</div>
{onOpenGallery || onOpenThemes ? (
<div className="game-category-tabs" role="tablist" aria-label="游戏区">
<button
type="button"
role="tab"
aria-selected={!forkableOnly}
className={
forkableOnly
? 'game-category-tab'
: 'game-category-tab game-category-tab--active'
}
onClick={() => {
if (!forkableOnly) return;
onOpenGallery?.();
}}
>
游戏
</button>
{/*
「共创」页签展示的是**支持共创的作品卡片**(与游戏页签同一套卡片、同一套分页),
不再指向共创主题列表——主题列表页与路由仍然保留,只是这里没有入口了。
*/}
<button
type="button"
role="tab"
aria-selected={forkableOnly}
className={
forkableOnly
? 'game-category-tab game-category-tab--active'
: 'game-category-tab'
}
onClick={() => {
if (forkableOnly) return;
onOpenThemes?.();
}}
>
共创
</button>
</div>
) : null}
<div className="game-category-tabs" role="tablist" aria-label="游戏分类">
{CATEGORIES.map((item) => (
<button
@@ -501,14 +473,33 @@ export function GameGalleryPage({
) : null}
{!isLoading && !error && visibleGames.length > 0 ? (
<div className="game-grid">
{visibleGames.map((game) => (
<GameCard
cover={<GameCover game={game} />}
key={game.id}
game={game}
onOpen={() => openDetail(game.id)}
/>
))}
{visibleGames.map((game) => {
/**
* 「共创 N」次数角标:**只在共创页出现**(`scope === 'forkable'`),
* 广场 / 游戏页卡片完全不受影响(不传 `badges` 时共享卡行为不变)。
*/
const coCreationCount = forkableOnly
? resolveCoCreationBadgeCount(game)
: null;
return (
<GameCard
cover={<GameCover game={game} />}
key={game.id}
game={game}
badges={
coCreationCount === null ? undefined : (
<span
className="game-card__co-creation-badge"
aria-label={`被共创 ${coCreationCount} 次`}
>
共创 {coCreationCount}
</span>
)
}
onOpen={() => openDetail(game.id)}
/>
);
})}
</div>
) : null}
{/*
@@ -13,22 +13,19 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
type GameThemesPageProps = {
onBack: () => void;
/** 「游戏广场」页签:回到 `/games`(同一个 stage 的既有页面)。 */
onOpenGallery: () => void;
onOpenTheme: (themeId: string) => void;
};
/**
* 共创主题列表(游戏区的「共创」页签)。
* 共创主题列表(`/games/themes`)。
*
* 匿名可读;分页沿用服务端游标惯例(默认 20 / 上限 50 / 末页 nextCursor 为 null):
* 只有 `nextCursor` 非空才渲染「加载更多」,点击时把已加载的页追加在后面。
*
* 2026-10-07:「游戏 / 共创」子页签已删除(共创升为侧边栏顶层入口、指向作品列表,
* 不再指向主题列表)。本页保留路由与「返回」,入口来自作品详情的「所属主题」或直接 URL。
*/
export function GameThemesPage({
onBack,
onOpenGallery,
onOpenTheme,
}: GameThemesPageProps) {
export function GameThemesPage({ onBack, onOpenTheme }: GameThemesPageProps) {
const [themes, setThemes] = useState<GameDistributionThemeSummary[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -76,25 +73,6 @@ export function GameThemesPage({
<RefreshCcw aria-hidden="true" /> 刷新
</PlatformActionButton>
</div>
<div className="game-category-tabs" role="tablist" aria-label="游戏区">
<button
type="button"
role="tab"
aria-selected={false}
className="game-category-tab"
onClick={onOpenGallery}
>
游戏
</button>
<button
type="button"
role="tab"
aria-selected
className="game-category-tab game-category-tab--active"
>
共创
</button>
</div>
{error ? (
<PlatformStatusMessage tone="error" surface="platform">
@@ -2973,3 +2973,21 @@
.lineage-relation__item > .game-card--lineage {
width: 100%;
}
/*
共创页卡片的「共创 N」次数角标(2026-10-07)。
位置由共享卡自己的 `.game-card__badges` 槽决定(绝对定位在封面上沿,见
packages/shared/src/components/GameCard/style.css);这里只给这一枚角标的观感:
实心强调色 + 白字,保证在任意封面上都醒目、可读。
只有「共创」页(`scope="forkable"`)会传这个角标,广场 / 游戏页卡片不传、零影响。
*/
.game-card__co-creation-badge {
border-radius: 999px;
padding: 0.1rem 0.5rem;
background: var(--platform-accent-strong, var(--platform-accent));
color: #fff;
font-size: 0.7rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.5;
box-shadow: 0 0.25rem 0.6rem rgba(104, 54, 36, 0.22);
}
@@ -33,7 +33,7 @@ export const MOBILE_WELCOME_EXEMPT_STAGES: Readonly<Record<string, true>> = {
'game-lineage': true,
// 共创主题列表:`/games/themes`(公开只读)
'game-themes': true,
// 「共创」页签:只列支持共创的作品(公开只读,本身就是「作品展示」)
// 「共创」页(顶层入口):只列支持共创的作品(公开只读,本身就是「作品展示」)
'game-co-creation': true,
// 共创主题详情:`/games/theme?id=<themeId>`(公开只读)
'game-theme': true,
@@ -481,7 +481,7 @@ describe('PlatformEntryActiveFlowShell', () => {
within(navigation)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['创作', '项目', '游戏', '创作者主页', '我的']);
).toEqual(['创作', '项目', '游戏', '共创', '创作者主页', '我的']);
expect(
within(navigation)
.getByRole('button', { name: '创作' })
@@ -547,7 +547,7 @@ describe('PlatformEntryActiveFlowShell', () => {
within(navigation)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['游戏', '创作者主页', '我的']);
).toEqual(['游戏', '共创', '创作者主页', '我的']);
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy();
expect(
@@ -671,7 +671,7 @@ describe('PlatformEntryActiveFlowShell', () => {
within(navigation)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['游戏', '创作者主页', '我的']);
).toEqual(['游戏', '共创', '创作者主页', '我的']);
expect(
within(navigation)
.getByRole('button', { name: '我的' })
@@ -1550,8 +1550,8 @@ describe('创作族谱', () => {
});
});
describe('共创页签(只列支持共创的作品)', () => {
it('广场「共创」页签切到 game-co-creation(不再是主题列表)', async () => {
describe('共创顶层入口(只列支持共创的作品)', () => {
it('侧边栏「共创」直达 game-co-creation,游戏页不再有「游戏/共创」子页签', async () => {
window.history.replaceState(null, '', '/games');
gameDistributionMock.listGames.mockResolvedValue({
games: [],
@@ -1563,6 +1563,59 @@ describe('共创页签(只列支持共创的作品)', () => {
});
const setSelectionStage = vi.fn();
const { rerender } = render(
<PlatformEntryFlowShellImpl
selectionStage="games"
setSelectionStage={setSelectionStage}
/>,
);
// ① 游戏页里**不再有**「游戏区」子页签(共创已升为顶层入口)。
expect(screen.queryByRole('tablist', { name: '游戏区' })).toBeNull();
expect(screen.queryByRole('tab', { name: '共创' })).toBeNull();
// ② 侧边栏「共创」与「游戏」平级,点它进共创页。
const rail = screen.getByRole('navigation', { name: '平台导航' });
expect(
within(rail)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['创作', '项目', '游戏', '共创', '创作者主页', '我的']);
fireEvent.click(within(rail).getByRole('button', { name: '共创' }));
expect(setSelectionStage).toHaveBeenCalledWith('game-co-creation', {
path: '/games/co-creation',
});
// 主题列表接口在这条路径上**不该**被请求。
expect(gameDistributionMock.listThemes).not.toHaveBeenCalled();
// ③ 进共创页时高亮的是「共创」,不再高亮「游戏」。
rerender(
<PlatformEntryFlowShellImpl
selectionStage="game-co-creation"
setSelectionStage={setSelectionStage}
/>,
);
expect(
within(rail)
.getByRole('button', { name: '共创' })
.getAttribute('aria-current'),
).toBe('page');
expect(
within(rail)
.getByRole('button', { name: '游戏' })
.getAttribute('aria-current'),
).toBeNull();
});
it('窄屏底部导航也有「共创」,点它同样直达共创页', async () => {
responsiveMock.isDesktopLayout = false;
window.history.replaceState(null, '', '/games');
gameDistributionMock.listGames.mockResolvedValue({
games: [],
nextCursor: null,
});
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="games"
@@ -1570,12 +1623,18 @@ describe('共创页签(只列支持共创的作品)', () => {
/>,
);
fireEvent.click(await screen.findByRole('tab', { name: '共创' }));
const dock = await screen.findByRole('navigation', {
name: '移动平台导航',
});
expect(
within(dock)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['游戏', '共创', '创作者主页', '我的']);
fireEvent.click(within(dock).getByRole('button', { name: '共创' }));
expect(setSelectionStage).toHaveBeenCalledWith('game-co-creation', {
path: '/games/co-creation',
});
// 主题列表接口在这条路径上**不该**被请求(页签已不指向主题)。
expect(gameDistributionMock.listThemes).not.toHaveBeenCalled();
});
it('game-co-creation 渲染的是游戏卡片,且请求带 forkable=1', async () => {
@@ -5,6 +5,7 @@ import {
Monitor,
Palette,
Search,
Sparkles,
UserRound,
UsersRound,
} from 'lucide-react';
@@ -217,6 +218,8 @@ function MobileProfileDock({
onOpenProfile,
gameActive,
onOpenGames,
coCreationActive,
onOpenCoCreation,
creatorActive,
onOpenCreator,
}: {
@@ -224,13 +227,15 @@ function MobileProfileDock({
onOpenProfile: () => void;
gameActive: boolean;
onOpenGames: () => void;
coCreationActive: boolean;
onOpenCoCreation: () => void;
creatorActive: boolean;
onOpenCreator: () => void;
}) {
return (
<div className="platform-mobile-bottom-dock min-w-0 shrink-0 lg:hidden">
<nav
className="platform-bottom-nav grid grid-cols-3"
className="platform-bottom-nav grid grid-cols-4"
aria-label="移动平台导航"
>
<ActiveBottomNavButton
@@ -239,6 +244,16 @@ function MobileProfileDock({
label="游戏"
onClick={onOpenGames}
/>
{/*
「共创」是**顶层入口**(与游戏/创作者主页/我的并列),不再是游戏页里的子页签;
窄屏同样从底部导航直达 `/games/co-creation`。
*/}
<ActiveBottomNavButton
active={coCreationActive}
icon={Sparkles}
label="共创"
onClick={onOpenCoCreation}
/>
<ActiveBottomNavButton
active={creatorActive}
icon={UsersRound}
@@ -729,10 +744,12 @@ export function PlatformEntryFlowShellImpl({
<MobileProfileDock
active={false}
gameActive={false}
coCreationActive={false}
creatorActive={false}
onOpenCreator={() => openCreator()}
onOpenProfile={openProfile}
onOpenGames={openGames}
onOpenCoCreation={openGameCoCreation}
/>
<PlatformActiveMobileWelcomeDialog
open={shouldOpenMobileHomeWelcome}
@@ -772,6 +789,10 @@ export function PlatformEntryFlowShellImpl({
const isCreatorStage =
selectionStage === 'creator-home' ||
selectionStage === 'creator-connections';
/**
* 游戏区(含共创):决定「游戏味」的宿主行为——搜索框口语、面板是否内滚。
* **不再等于导航高亮**:共创是独立顶层入口,两者的高亮互斥(见 `isGamesNavActive`)。
*/
const isGamesStage =
selectionStage === 'games' ||
selectionStage === 'game-detail' ||
@@ -783,6 +804,10 @@ export function PlatformEntryFlowShellImpl({
selectionStage === 'game-mine' ||
selectionStage === 'game-lineage' ||
selectionStage === 'game-publish';
/** 侧边栏 / 底部导航的「共创」高亮(顶层入口,2026-10-07 从游戏页子页签升上来)。 */
const isCoCreationStage = selectionStage === 'game-co-creation';
/** 游戏区但不在共创页时,「游戏」这个导航项才是激活态——进共创时高亮共创、不再高亮游戏。 */
const isGamesNavActive = isGamesStage && !isCoCreationStage;
/**
* 游玩页自己撑满剩余高度,面板不能再滚动,否则移动端会出现整页可滚的
* 半屏播放器;其余舞台继续沿用面板滚动。
@@ -852,13 +877,26 @@ export function PlatformEntryFlowShellImpl({
onClick={openProjects}
/>
<ActiveRailButton
active={isGamesStage}
emphasized={isGamesStage}
active={isGamesNavActive}
emphasized={isGamesNavActive}
icon={Gamepad2}
iconSrc="/creation-home/nav-games.png"
label="游戏"
onClick={openGames}
/>
{/*
「共创」:**顶层入口**(2026-10-07 用户口径:不该是游戏页里的子页签)。
指向 `scope="forkable"` 的那个列表页(`/games/co-creation`),
与「游戏」平级;在共创页时高亮的是这一项,不再是「游戏」。
*/}
<ActiveRailButton
active={isCoCreationStage}
emphasized={isCoCreationStage}
icon={Sparkles}
iconSrc="/creation-home/nav-discover.png"
label="共创"
onClick={openGameCoCreation}
/>
<ActiveRailButton
active={isCreatorStage}
icon={UsersRound}
@@ -1053,7 +1091,6 @@ export function PlatformEntryFlowShellImpl({
searchKeyword={activeSearchKeyword}
onOpenDetail={openGameDetail}
onOpenMyGames={openMyGames}
onOpenThemes={openGameCoCreation}
onOpenPublish={
gamePublishGateAllowed ? openGamePublish : undefined
}
@@ -1112,12 +1149,10 @@ export function PlatformEntryFlowShellImpl({
searchKeyword={activeSearchKeyword}
scope="forkable"
onOpenDetail={openGameDetail}
onOpenGallery={openGames}
/>
) : selectionStage === 'game-themes' ? (
<GameThemesPage
onBack={backFromGameThemes}
onOpenGallery={openGames}
onOpenTheme={openGameTheme}
/>
) : selectionStage === 'game-theme' ? (
@@ -1144,11 +1179,13 @@ export function PlatformEntryFlowShellImpl({
</div>
<MobileProfileDock
active={isProfileStage}
gameActive={isGamesStage}
gameActive={isGamesNavActive}
coCreationActive={isCoCreationStage}
creatorActive={isCreatorStage}
onOpenCreator={() => openCreator()}
onOpenProfile={openProfile}
onOpenGames={openGames}
onOpenCoCreation={openGameCoCreation}
/>
</div>
</div>