diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index 223d9ba0d..dd76ff3b3 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -309,7 +309,7 @@ pub(crate) project_bundle_sha256: Option, | 方法 / 路径 | 说明 | | --- | --- | | `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 代作品也能看到并跳到主题页**;单条只够渲染跳转入口(简介与成员数去主题页取) | -| `GET /games/{gameId}/lineage`(新) | 以该 game 的根为顶返回树:`{ rootGameId, root: LineageNode \| null, nodes: [LineageNode], truncated }`,`LineageNode = { gameId, title, authorName, generation, parentGameId, playCount, status }`,按代际升序 / 同代创建时间升序稳定排序;节点上限 200,超出返回 `truncated: true`。**锚点必须公开可读**:未公开、已软删除或不存在的作品返回 404(与公开详情同口径),不用空标题占位或空树代替 404。树内只出现未软删除且已公开的作品;父/祖辈被排除时孩子照常出现并保留 `generation` 与 `parentGameId`,由展示层标注「原作品已不可用」,不补 null 占位节点 | +| `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 占位节点 | | `GET /games/{gameId}/derived`(新,可选分页) | 直接子代列表:`{ gameId, nodes: [LineageNode], truncated }`,只含未软删除且已公开的直接子代(与公开详情 `forkCount` 同口径,因此条数与「被改编 N」一致);锚点同样必须公开可读,否则 404 | | `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) | diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index c8cc2f0c0..636cc7530 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -191,7 +191,8 @@ export type GameDistributionLineage = { * 只有公开且未软删除的作品会成为节点(锚点不可读时接口直接 404),因此 `title` 必然存在; * `authorName` 为 null 表示作者信息不可得。`parentGameId` 指向的作品可能不在树里 * (父作品已删/未公开),展示层据此把该节点标注为「原作品不可用」,不猜测父作品内容。 - * 节点只带作品级公开信息,不含对象键、素材键或 owner user id 之外的私有字段。 + * 节点只带作品级公开信息,不含**素材键 / 内部 id**(例如 `coverAssetId`);`coverObjectKey` + * 是公开投影的一部分,与公开目录 / 详情同源同口径,无封面时为 null。 */ export type GameDistributionLineageNode = { gameId: string; @@ -203,6 +204,11 @@ export type GameDistributionLineageNode = { parentGameId?: string | null; playCount: number; status: GameDistributionGameVisibility; + /** + * 该节点作品当前生效的封面对象键(与公开目录 / 详情同源,都来自游戏行 + * `cover_object_key`);无封面时为 null。只发对象键,不带 `coverAssetId` 等私有 id。 + */ + coverObjectKey?: string | null; }; /** diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 581b3f72b..ab7d9fac6 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -2255,6 +2255,7 @@ fn lineage_node_payload(node: GameDistributionLineageNodeRecord) -> GameDistribu parent_game_id: node.parent_game_id, play_count: node.play_count, status: game_distribution_visibility(node.status.as_str()), + cover_object_key: node.cover_object_key, } } @@ -6964,6 +6965,7 @@ mod tests { parent_game_id: None, play_count: 12, status: "published".to_string(), + cover_object_key: Some("covers/root.png".to_string()), }; let child = GameDistributionLineageNodeRecord { game_id: "game-child".to_string(), @@ -6974,6 +6976,8 @@ mod tests { parent_game_id: Some("game-removed".to_string()), play_count: 3, status: "mystery".to_string(), + // 无封面:键仍发出,值为 null(不是缺键,也不是空串占位)。 + cover_object_key: None, }; let tree = serde_json::to_value(lineage_tree_payload(GameDistributionLineageTreeRecord { root_game_id: "game-root".to_string(), @@ -6995,15 +6999,14 @@ mod tests { tree["nodes"][1]["status"], Value::String("unpublished".to_string()) ); - // 节点键集合必须恰好是契约里的那七个:多一个键就意味着多泄露一类信息。 + // 带封面:对象键原样发出;无封面:键仍发出但为 null(两种负载都必须如实)。 + assert_eq!( + tree["nodes"][0]["coverObjectKey"], + Value::String("covers/root.png".to_string()) + ); + assert_eq!(tree["nodes"][1]["coverObjectKey"], Value::Null); + // 节点键集合必须恰好是契约里的那八个:多一个键就意味着多泄露一类信息。 // serde_json 的 Map 按字母序输出,所以这里比较排序后的键集合而不是固定顺序。 - let mut node_keys = tree["nodes"][0] - .as_object() - .expect("节点必须是对象") - .keys() - .cloned() - .collect::>(); - node_keys.sort(); let mut expected_keys = vec![ "gameId", "title", @@ -7012,9 +7015,25 @@ mod tests { "parentGameId", "playCount", "status", + "coverObjectKey", ]; expected_keys.sort(); - assert_eq!(node_keys, expected_keys); + for index in [0usize, 1usize] { + let node = tree["nodes"][index].as_object().expect("节点必须是对象"); + let mut node_keys = node.keys().cloned().collect::>(); + node_keys.sort(); + assert_eq!( + node_keys, expected_keys, + "节点 {index} 的键集合必须恰好是白名单" + ); + // 只有公开投影字段:私有素材 id / 对象键的别名一个都不许出现。 + for forbidden in ["coverAssetId", "cover_asset_id", "coverAssetID"] { + assert!( + !node.contains_key(forbidden), + "节点 {index} 负载不得含私有 id 字段 {forbidden}" + ); + } + } let derived = serde_json::to_value(derived_games_payload(GameDistributionDerivedGamesRecord { diff --git a/server-rs/crates/module-game-distribution/src/lineage.rs b/server-rs/crates/module-game-distribution/src/lineage.rs index b07fee4d9..a46027926 100644 --- a/server-rs/crates/module-game-distribution/src/lineage.rs +++ b/server-rs/crates/module-game-distribution/src/lineage.rs @@ -47,6 +47,9 @@ pub struct LineageTreeEntry { pub is_published: bool, pub status: String, pub created_at_micros: i64, + /// 该节点作品当前生效的封面对象键,与公开详情 / 公开目录同源(游戏行的 `cover_object_key`); + /// 没有封面时为 `None`(对外发 `null`),不伪造空串或占位键。 + pub cover_object_key: Option, } /// 对外节点;`author_name` 为 `None` 表示作者信息不可得(账号与冻结资料都没有名字)。 @@ -64,6 +67,9 @@ pub struct LineageTreeNode { pub parent_game_id: Option, pub play_count: u64, pub status: String, + /// 封面对象键(游戏行的 `cover_object_key`);与公开目录 / 详情同源同口径,无封面为 `None`。 + /// 只发对象键本身,**不带** `coverAssetId` 这类私有素材 id。 + pub cover_object_key: Option, } /// 族谱树:`root` 为根节点在可见集合中的投影,根不可读(已删/未公开)时为 `None`。 @@ -145,6 +151,8 @@ pub struct LineageGameFacts { pub is_published: bool, pub status: String, pub created_at_micros: i64, + /// 游戏行的封面对象键;与公开目录 / 详情用同一个字段,无封面为 `None`。 + pub cover_object_key: Option, } /// 血缘行的事实:谁改编自谁、属于哪个根、第几代。 @@ -241,6 +249,7 @@ pub fn lineage_entry_from_game_facts( is_published: facts.is_published, status: facts.status, created_at_micros: facts.created_at_micros, + cover_object_key: facts.cover_object_key, } } @@ -269,6 +278,7 @@ fn lineage_tree_node(entry: &LineageTreeEntry) -> LineageTreeNode { parent_game_id: entry.parent_game_id.clone(), play_count: entry.play_count, status: entry.status.clone(), + cover_object_key: entry.cover_object_key.clone(), } } @@ -299,6 +309,7 @@ mod lineage_tree_tests { } .to_string(), created_at_micros, + cover_object_key: None, } } @@ -550,6 +561,7 @@ mod lineage_tree_tests { } .to_string(), created_at_micros: 10, + cover_object_key: None, } } @@ -673,4 +685,61 @@ mod lineage_tree_tests { assert_eq!(resolve_lineage_author_name(None, None), None); assert_eq!(resolve_lineage_author_name(Some(""), Some(" ")), None); } + + /// 封面对象键与公开目录 / 详情同源(游戏行 `cover_object_key`),逐层原样透传到对外节点; + /// 无封面时为 `None`,不伪造空串或占位键。 + #[test] + fn cover_object_key_is_passed_through_and_may_be_absent() { + // 带封面:行事实 → 树输入行 → 对外节点,三层都必须带上同一个键。 + let mut covered = facts("with-cover", false, true); + covered.cover_object_key = Some("covers/with-cover.png".to_string()); + let entries = assemble_lineage_tree_entries("with-cover", Some(covered), &[]); + assert_eq!( + entries[0].cover_object_key.as_deref(), + Some("covers/with-cover.png"), + "fact 上的封面对象键必须折进树输入行" + ); + let tree = build_lineage_tree(&entries, "with-cover", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); + assert_eq!( + tree.nodes[0].cover_object_key.as_deref(), + Some("covers/with-cover.png"), + "树输入行的封面对象键必须折进对外节点" + ); + + // 有 / 无封面在同一个响应里都如实透传:`Some(key)` 原样,`None` 保持 `None`。 + let mut plain = published("plain", 0, 1); + plain.cover_object_key = None; + let mut child = published("child-cover", 1, 2); + child.cover_object_key = Some("covers/child.png".to_string()); + let tree = build_lineage_tree( + &[plain, child.clone()], + "plain", + GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, + ); + let plain_node = tree + .nodes + .iter() + .find(|node| node.game_id == "plain") + .expect("无封面节点应保留"); + assert_eq!( + plain_node.cover_object_key, None, + "无封面节点必须发 null 语义" + ); + let child_node = tree + .nodes + .iter() + .find(|node| node.game_id == "child-cover") + .expect("带封面节点应保留"); + assert_eq!( + child_node.cover_object_key.as_deref(), + Some("covers/child.png") + ); + + // 衍生列表走同一份折叠函数,封面同样不得丢。 + let list = build_derived_node_list(&[child], GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT); + assert_eq!( + list.nodes[0].cover_object_key.as_deref(), + Some("covers/child.png") + ); + } } diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index dccd9ffae..72879faad 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -298,7 +298,9 @@ pub struct GameDistributionSetForkAuthorizationRequest { /// 族谱树上对外可见的单个作品节点。 /// -/// 只带作品级公开信息:不含对象键、素材键或 owner user id 之外的私有字段。 +/// 只带作品级公开信息:不含**素材键 / 内部 id**(例如 `coverAssetId`);封面对象键 +/// `cover_object_key` 是公开投影的一部分,与公开目录 / 详情同源同口径(都来自游戏行的 +/// `cover_object_key`),无封面时为空。 /// 只有公开且未软删除的作品会成为节点(锚点可读性由接口在读出前判定,不可读即 404), /// 因此标题必然存在;`author_name` 为 `None` 表示作者信息不可得。 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -318,6 +320,10 @@ pub struct GameDistributionLineageNode { /// 所以线上取值实际恒为 `published`;保留该字段是为了将来允许非公开节点进入树时 /// 不必再改契约,届时展示层按此渲染状态。 pub status: GameDistributionVisibility, + /// 该节点作品当前生效的封面对象键(与公开目录 / 详情同源,都来自游戏行 + /// `cover_object_key`);无封面时为 `null`。只发对象键,**不带** `coverAssetId` 等私有 id。 + #[serde(default)] + pub cover_object_key: Option, } /// 族谱读接口的响应:以根作品为顶的整棵树。 diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index 85f93a571..894981478 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -299,6 +299,8 @@ pub struct GameDistributionLineageNodeRecord { pub parent_game_id: Option, pub play_count: u64, pub status: String, + /// 封面对象键(游戏行 `cover_object_key`);与公开目录 / 详情同源,无封面为 `None`。 + pub cover_object_key: Option, } /// 以某作品根为顶的整棵族谱树。 @@ -578,6 +580,7 @@ fn map_lineage_node( parent_game_id: value.parent_game_id, play_count: value.play_count, status: value.status, + cover_object_key: value.cover_object_key, } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_node_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_node_snapshot_type.rs index 7bdc3ab33..33d97dd2d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_node_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_lineage_node_snapshot_type.rs @@ -14,6 +14,7 @@ pub struct GameDistributionLineageNodeSnapshot { pub parent_game_id: Option, pub play_count: u64, pub status: String, + pub cover_object_key: Option, } impl __sdk::InModule for GameDistributionLineageNodeSnapshot { diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index ae20ec8ab..7d522e9d6 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1663,6 +1663,9 @@ pub struct GameDistributionLineageNodeSnapshot { pub play_count: u64, /// 可见性;未公开的锚点节点原样回传 `unpublished` / `suspended`。 pub status: String, + /// 该节点作品当前生效的封面对象键(游戏行 `cover_object_key`),与公开目录 / 详情同源; + /// 无封面时为 `None`。只发对象键,**不带** `coverAssetId` 这类私有素材 id。 + pub cover_object_key: Option, } /// 族谱树读取结果;`found == false` 表示目标作品不存在或已软删除(api-server 映射 404)。 @@ -6357,6 +6360,7 @@ fn game_distribution_lineage_game_facts( is_published: game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, status: game.visibility.clone(), created_at_micros: game.created_at.to_micros_since_unix_epoch(), + cover_object_key: game.cover_object_key.clone(), } } @@ -6392,6 +6396,7 @@ fn game_distribution_lineage_node_snapshot( parent_game_id: node.parent_game_id, play_count: node.play_count, status: node.status, + cover_object_key: node.cover_object_key, } } diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index a155c98e8..29d311a5f 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -1579,7 +1579,7 @@ describe('原版与二创对比入口', () => { expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull(); }); - it('族谱节点只在其父作品可见时给出对比入口', async () => { + it('树节点只保留代际/标题/作者/Fork:不渲染游玩数与对比入口', async () => { getGameLineageMock.mockResolvedValueOnce({ rootGameId: 'game-root', root: createLineageNode({ @@ -1621,13 +1621,11 @@ describe('原版与二创对比入口', () => { ); expect(await screen.findByText('叶节点')).toBeTruthy(); - // 两个节点各有一个可见父引用?只有叶节点的父(game-root)在树里。 - const compareButtons = screen.getAllByRole('button', { - name: '与原版对比', - }); - expect(compareButtons).toHaveLength(1); - fireEvent.click(compareButtons[0]!); - expect(onOpenCompare).toHaveBeenCalledWith('game-root', 'game-leaf'); + // 树节点只保留「代际 + 标题 + 作者 + Fork」:游玩数与「与原版对比」留在详情页, + // 树里不再重复渲染(6 个节点还行,多了会吵)。 + expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull(); + expect(screen.queryByText(/次游玩/u)).toBeNull(); + expect(screen.getByRole('button', { name: 'Fork 叶节点' })).toBeTruthy(); }); }); @@ -2085,6 +2083,99 @@ describe('GameLineagePage 可视化与交互', () => { ).toBe(0); }); + it('初次进入满足宽高两个维度并带可读下限;适应窗口按钮给全览', async () => { + getGameLineageMock.mockResolvedValueOnce(treeWithSibling); + render( + , + ); + + await screen.findByRole('button', { name: /《母版》/u }); + // jsdom 没有布局尺寸:min(scaleX, scaleY) 会远小于 1,因此触发可读下限 60%。 + expect( + document.querySelector('.game-lineage-zoom')?.textContent, + ).toBe('60%'); + expect(screen.getByText(/已按可读缩放显示/u)).toBeTruthy(); + // 主动点「适应窗口」才做真正的全览(不受可读下限约束),提示随之收起。 + fireEvent.click(screen.getByRole('button', { name: '适应窗口' })); + expect( + document.querySelector('.game-lineage-zoom')?.textContent, + ).toBe('35%'); + expect(screen.queryByText(/已按可读缩放显示/u)).toBeNull(); + }); + + it('代际轴在固定层上(不随内容层缩放平移),放大后仍在画布左侧', async () => { + getGameLineageMock.mockResolvedValueOnce(treeWithSibling); + render( + , + ); + + await screen.findByRole('button', { name: /《母版》/u }); + const axisLayer = document.querySelector('.game-lineage-canvas__axis'); + expect(axisLayer).not.toBeNull(); + expect(axisLayer?.querySelectorAll('.game-lineage-axis').length).toBe(3); + // 轴不在内容层里:不会跟着 transform 缩放/平移。 + expect( + document.querySelector('.game-lineage-canvas__content .game-lineage-axis'), + ).toBeNull(); + expect(axisLayer?.textContent).toContain('母版'); + + fireEvent.click(screen.getByRole('button', { name: '放大' })); + expect(document.querySelector('.game-lineage-canvas__axis')).not.toBeNull(); + expect(axisLayer?.querySelectorAll('.game-lineage-axis').length).toBe(3); + }); + + it('节点封面:有 coverObjectKey 时用 GameCover 渲染缩略图,没有时不渲染封面块', async () => { + getGameLineageMock.mockResolvedValueOnce({ + rootGameId: 'game-root', + root: null, + nodes: [ + { + ...createLineageNode({ + gameId: 'game-with-cover', + title: '有封面', + generation: 0, + parentGameId: null, + }), + coverObjectKey: 'game-distribution/cover/one.png', + }, + createLineageNode({ + gameId: 'game-without-cover', + title: '没封面', + generation: 1, + parentGameId: 'game-with-cover', + }), + ], + truncated: false, + }); + render( + , + ); + + await screen.findByText('有封面'); + // 只有带 coverObjectKey 的那个节点渲染 GameCover(复用既有封面组件与换签逻辑)。 + expect(document.querySelectorAll('.game-lineage-node .game-cover')).toHaveLength( + 1, + ); + expect( + document.querySelectorAll('img.game-cover__image'), + ).toHaveLength(1); + // 没有封面的节点不渲染空封面块。 + const withoutCover = screen.getByRole('button', { name: /《没封面》/u }); + expect(withoutCover.querySelector('.game-cover')).toBeNull(); + }); + it('缩放按钮改变百分比,「适应窗口」重新适配', async () => { getGameLineageMock.mockResolvedValueOnce(treeWithSibling); render( @@ -2096,14 +2187,17 @@ describe('GameLineagePage 可视化与交互', () => { ); await screen.findByRole('button', { name: /《母版》/u }); - const zoomLabel = () => screen.getByRole('status').textContent ?? ''; - const before = Number.parseInt(zoomLabel(), 10); + const zoomLabel = () => + document.querySelector('.game-lineage-zoom')?.textContent ?? ''; + // 初次适配是 effect 里跑的:先等它落定(jsdom 无布局尺寸 → 命中可读下限 60%)。 + await waitFor(() => expect(zoomLabel()).toBe('60%')); fireEvent.click(screen.getByRole('button', { name: '放大' })); - expect(Number.parseInt(zoomLabel(), 10)).toBeGreaterThan(before); + expect(zoomLabel()).toBe('72%'); fireEvent.click(screen.getByRole('button', { name: '缩小' })); - expect(Number.parseInt(zoomLabel(), 10)).toBe(before); + expect(zoomLabel()).toBe('60%'); + // 适应窗口(强制全览)在 jsdom 下退到最小缩放下限。 fireEvent.click(screen.getByRole('button', { name: '适应窗口' })); - expect(zoomLabel()).toMatch(/%$/u); + expect(zoomLabel()).toBe('35%'); }); it('节点的 Fork 次要动作不触发导航;未失焦时给出降级提示与复制入口', async () => { @@ -2118,7 +2212,9 @@ describe('GameLineagePage 可视化与交互', () => { ); const leafCard = await screen.findByRole('button', { name: /《叶节点》/u }); - const forkButton = within(leafCard).getByRole('button', { name: 'Fork' }); + const forkButton = within(leafCard).getByRole('button', { + name: 'Fork 叶节点', + }); vi.useFakeTimers(); fireEvent.click(forkButton); // 次要动作直通唤起,且不抢主点击。 diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index 9333af468..313b894c9 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -12,6 +12,7 @@ import { } from 'react'; import { + type GameDistributionGame, type GameDistributionLineageResponse, getGameLineage, } from '../../services/gameDistributionClient'; @@ -20,6 +21,7 @@ import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { useCopyFeedback } from '../common/useCopyFeedback'; +import { GameCover } from './GameCover'; import { FORK_LAUNCH_FALLBACK_DELAY_MS, openForkProtocol, @@ -41,8 +43,6 @@ type GameLineagePageProps = { highlightGameId?: string | null; onBack: () => void; onOpenDetail: (gameId: string) => void; - /** 打开「原版与二创对比」;参数是原版(父作品)与二创(该节点)。 */ - onOpenCompare?: (leftGameId: string, rightGameId: string) => void; /** * 内嵌模式:隐藏页面外壳(返回按钮 / 页头 / 脚注),只渲染可视化树本身。 * 供主题页逐根渲染多棵树复用同一份树查询与渲染(不新建第二套树逻辑)。 @@ -53,6 +53,11 @@ type GameLineagePageProps = { const MIN_SCALE = 0.35; const MAX_SCALE = 1.8; const FIT_PADDING = 24; +/** + * 可读下限:把整棵树硬塞进窗口会把字缩到看不清(尤其窄屏), + * 所以初次进入若全览缩放低于它,就按可读缩放 + 可平移呈现,由用户点「适应窗口」主动全览。 + */ +const READABLE_MIN_SCALE = 0.6; type ViewportState = { scale: number; offsetX: number; offsetY: number }; @@ -66,9 +71,7 @@ function LineageTreeNodeCard({ layoutNode, isAnchor, isDimmed, - parentAvailable, onOpenDetail, - onOpenCompare, onActivate, onDeactivate, onOpenFork, @@ -76,24 +79,33 @@ function LineageTreeNodeCard({ layoutNode: LineageLayoutNode; isAnchor: boolean; isDimmed: boolean; - /** 父作品是否是树里的**真实可见节点**:父不可用时不提供「与原版对比」(对比必然一头空白)。 */ - parentAvailable: boolean; onOpenDetail: (gameId: string) => void; - onOpenCompare?: (leftGameId: string, rightGameId: string) => void; onActivate: (gameId: string) => void; onDeactivate: () => void; onOpenFork: (gameId: string) => void; }) { const { gameId, title, authorName, generation, isStub } = layoutNode; const displayTitle = isStub ? '原作品已不可用' : (title ?? '未命名作品'); - const authorLabel = isStub ? '' : (authorName ?? '').trim() || '未署名作者'; + const authorLabel = isStub + ? '标题与作者名不对外展示' + : (authorName ?? '').trim() || '未署名作者'; const accessibleName = isStub ? `原作品已不可用 · ${generationAxisLabel(generation)}` : `${generationAxisLabel(generation)}作品《${displayTitle}》· ${authorLabel}`; - const compareParentGameId = layoutNode.node?.parentGameId ?? null; // 父作品不可用的占位节点:可聚焦(读屏能听到它),但**不可点进详情**(那会 404), // 也没有 Fork 动作(取件接口同样会拒绝)。 const isInteractive = !isStub; + /** + * 封面:族谱节点只带 `coverObjectKey`(服务端补齐中),没有完整游戏行。 + * `GameCover` 只读 title / coverColor / icon / coverObjectKey 四个字段,这里用与 + * `game_payload` 相同的兜底色与图标补上,其余字段不参与渲染——不做逐节点详情请求。 + */ + const coverGame = { + title: displayTitle, + coverColor: '#F3E4D0', + icon: '🎮', + coverObjectKey: layoutNode.coverObjectKey, + } as unknown as GameDistributionGame; return (
onActivate(gameId)} onBlur={onDeactivate} > - - - {generationAxisLabel(generation)} - - {isAnchor ? ( - 当前作品 + + {layoutNode.coverObjectKey ? ( + ) : null} + + + + {generationAxisLabel(generation)} + + {isAnchor ? ( + 当前作品 + ) : null} + + {displayTitle} + {/* 树节点只留「代际 + 标题 + 作者 + Fork」:游玩数与「与原版对比」在详情页已有,不在这里重复。 */} + + {authorLabel} + + - {displayTitle} - {isStub ? ( - // 占位节点只有 ID 与代际:标题就是降级文案本身,不再重复一遍,也不展示作者名。 - - 标题与作者名不对外展示 - - ) : ( - - {authorLabel} - - {layoutNode.node?.playCount.toLocaleString() ?? 0} 次游玩 - - )} {isInteractive ? ( - {onOpenCompare && compareParentGameId && parentAvailable ? ( - - ) : null} ) : null}
@@ -195,7 +199,6 @@ export function GameLineagePage({ highlightGameId = null, onBack, onOpenDetail, - onOpenCompare, embedded = false, }: GameLineagePageProps) { const normalizedRootId = rootGameId?.trim() ?? ''; @@ -211,8 +214,13 @@ export function GameLineagePage({ offsetY: 0, }); const [forkNotice, setForkNotice] = useState(''); + const [fitHint, setFitHint] = useState(''); const [forkFallbackGameId, setForkFallbackGameId] = useState(''); - const { copyState: forkCopyState, copyText: copyForkId } = useCopyFeedback(); + // 复制反馈保留更久(默认 1.4s 在真实浏览器里太短:用户/验收脚本都可能错过那一瞬), + // 并且旁边常驻一条 role=status 的说明,标签复位后仍然能看到「刚才复制成功了」。 + const { copyState: forkCopyState, copyText: copyForkId } = useCopyFeedback({ + resetDelayMs: 5000, + }); const viewportRef = useRef(null); const forkTimerRef = useRef(null); const forkHandlersRef = useRef<(() => void) | null>(null); @@ -262,42 +270,49 @@ export function GameLineagePage({ : null, [activeGameId, includeSubtree, layout.nodes], ); - const stubIds = useMemo( - () => - new Set( - layout.nodes.filter((item) => item.isStub).map((item) => item.gameId), - ), - [layout.nodes], - ); const anchorId = highlightGameId?.trim() || normalizedRootId; const derivedCount = nodes.filter((node) => node.generation > 0).length; - /** 适应窗口:把整棵树缩放进画布可视区域,并居中。 */ - const fitToView = useCallback(() => { - const viewportElement = viewportRef.current; - if (!viewportElement || layout.nodes.length === 0) return; - const availableWidth = viewportElement.clientWidth - FIT_PADDING * 2; - const availableHeight = viewportElement.clientHeight - FIT_PADDING * 2; - const scale = Math.min( - MAX_SCALE, - Math.max( - MIN_SCALE, - Math.min( - availableWidth / Math.max(layout.width, 1), - availableHeight / Math.max(layout.height, 1), - 1, + /** 适应窗口:宽高**两个维度同时**满足(取更小的那个比例),并居中。 */ + const fitToView = useCallback( + (forceFullView = false) => { + const viewportElement = viewportRef.current; + if (!viewportElement || layout.nodes.length === 0) return; + const availableWidth = Math.max( + 1, + viewportElement.clientWidth - FIT_PADDING * 2, + ); + const availableHeight = Math.max( + 1, + viewportElement.clientHeight - FIT_PADDING * 2, + ); + const fullFit = Math.min( + availableWidth / Math.max(layout.width, 1), + availableHeight / Math.max(layout.height, 1), + ); + // 全览会缩到低于可读下限时(初次进入),改用可读缩放:信息先看得清,全览交给按钮。 + const floored = !forceFullView && fullFit < READABLE_MIN_SCALE; + const scale = Math.min( + MAX_SCALE, + Math.max(MIN_SCALE, floored ? READABLE_MIN_SCALE : fullFit), + ); + setFitHint( + floored + ? '树较大,已按可读缩放显示:拖拽平移查看,或点「适应窗口」看全树。' + : '', + ); + setViewport({ + scale, + // 水平居中:内容宽度已含左侧代际轴,避免右侧留一大片空白。 + offsetX: Math.max( + FIT_PADDING, + (viewportElement.clientWidth - layout.width * scale) / 2, ), - ), - ); - setViewport({ - scale, - offsetX: Math.max( - FIT_PADDING, - (viewportElement.clientWidth - layout.width * scale) / 2, - ), - offsetY: FIT_PADDING, - }); - }, [layout.height, layout.nodes.length, layout.width]); + offsetY: FIT_PADDING, + }); + }, + [layout.height, layout.nodes.length, layout.width], + ); // 初次进入(或换了根作品)自动适配一次;之后交给用户自己缩放/平移,不抢视野。 const fittedForRef = useRef(''); @@ -457,7 +472,7 @@ export function GameLineagePage({ tone="secondary" size="sm" shape="pill" - onClick={fitToView} + onClick={() => fitToView(true)} aria-label="适应窗口" >