lineage viz polish 2: dual-axis fit, readable floor, fixed axis layer, edge contrast
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
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:
@@ -309,7 +309,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 代作品也能看到并跳到主题页**;单条只够渲染跳转入口(简介与成员数去主题页取) |
|
||||
| `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) |
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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 {
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 对外节点;`author_name` 为 `None` 表示作者信息不可得(账号与冻结资料都没有名字)。
|
||||
@@ -64,6 +67,9 @@ pub struct LineageTreeNode {
|
||||
pub parent_game_id: Option<String>,
|
||||
pub play_count: u64,
|
||||
pub status: String,
|
||||
/// 封面对象键(游戏行的 `cover_object_key`);与公开目录 / 详情同源同口径,无封面为 `None`。
|
||||
/// 只发对象键本身,**不带** `coverAssetId` 这类私有素材 id。
|
||||
pub cover_object_key: Option<String>,
|
||||
}
|
||||
|
||||
/// 族谱树:`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<String>,
|
||||
}
|
||||
|
||||
/// 血缘行的事实:谁改编自谁、属于哪个根、第几代。
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 族谱读接口的响应:以根作品为顶的整棵树。
|
||||
|
||||
@@ -299,6 +299,8 @@ pub struct GameDistributionLineageNodeRecord {
|
||||
pub parent_game_id: Option<String>,
|
||||
pub play_count: u64,
|
||||
pub status: String,
|
||||
/// 封面对象键(游戏行 `cover_object_key`);与公开目录 / 详情同源,无封面为 `None`。
|
||||
pub cover_object_key: Option<String>,
|
||||
}
|
||||
|
||||
/// 以某作品根为顶的整棵族谱树。
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ pub struct GameDistributionLineageNodeSnapshot {
|
||||
pub parent_game_id: Option<String>,
|
||||
pub play_count: u64,
|
||||
pub status: String,
|
||||
pub cover_object_key: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionLineageNodeSnapshot {
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 族谱树读取结果;`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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
<GameLineagePage
|
||||
rootGameId="game-root"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<GameLineagePage
|
||||
rootGameId="game-root"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<GameLineagePage
|
||||
rootGameId="game-root"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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);
|
||||
// 次要动作直通唤起,且不抢主点击。
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -131,32 +143,36 @@ function LineageTreeNodeCard({
|
||||
onFocus={() => onActivate(gameId)}
|
||||
onBlur={onDeactivate}
|
||||
>
|
||||
<span className="game-lineage-node__head">
|
||||
<span className="game-lineage-node__generation">
|
||||
{generationAxisLabel(generation)}
|
||||
</span>
|
||||
{isAnchor ? (
|
||||
<span className="game-lineage-node__anchor">当前作品</span>
|
||||
<span className="game-lineage-node__main">
|
||||
{layoutNode.coverObjectKey ? (
|
||||
<GameCover
|
||||
game={coverGame}
|
||||
compact
|
||||
objectKey={layoutNode.coverObjectKey}
|
||||
/>
|
||||
) : null}
|
||||
<span className="game-lineage-node__text">
|
||||
<span className="game-lineage-node__head">
|
||||
<span className="game-lineage-node__generation">
|
||||
{generationAxisLabel(generation)}
|
||||
</span>
|
||||
{isAnchor ? (
|
||||
<span className="game-lineage-node__anchor">当前作品</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="game-lineage-node__title">{displayTitle}</span>
|
||||
{/* 树节点只留「代际 + 标题 + 作者 + Fork」:游玩数与「与原版对比」在详情页已有,不在这里重复。 */}
|
||||
<span className="game-lineage-node__meta">
|
||||
<span>{authorLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="game-lineage-node__title">{displayTitle}</span>
|
||||
{isStub ? (
|
||||
// 占位节点只有 ID 与代际:标题就是降级文案本身,不再重复一遍,也不展示作者名。
|
||||
<span className="game-lineage-node__meta">
|
||||
<span>标题与作者名不对外展示</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="game-lineage-node__meta">
|
||||
<span>{authorLabel}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{layoutNode.node?.playCount.toLocaleString() ?? 0} 次游玩</span>
|
||||
</span>
|
||||
)}
|
||||
{isInteractive ? (
|
||||
<span className="game-lineage-node__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="game-lineage-node__fork"
|
||||
aria-label={`Fork ${displayTitle}`}
|
||||
onClick={(event) => {
|
||||
// 次要动作不抢主点击。
|
||||
event.stopPropagation();
|
||||
@@ -165,18 +181,6 @@ function LineageTreeNodeCard({
|
||||
>
|
||||
Fork
|
||||
</button>
|
||||
{onOpenCompare && compareParentGameId && parentAvailable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-lineage-node__compare"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenCompare(compareParentGameId, gameId);
|
||||
}}
|
||||
>
|
||||
与原版对比
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const forkTimerRef = useRef<number | null>(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="适应窗口"
|
||||
>
|
||||
<Expand aria-hidden="true" /> 适应窗口
|
||||
@@ -511,6 +526,11 @@ export function GameLineagePage({
|
||||
母版已不可用,下面按代际关系展示仍可阅读的衍生作品。
|
||||
</p>
|
||||
)}
|
||||
{fitHint && !embedded ? (
|
||||
<p className="game-lineage-notice" role="status">
|
||||
{fitHint}
|
||||
</p>
|
||||
) : null}
|
||||
{nodes.length === 0 ? (
|
||||
<PlatformEmptyState surface="subpanel" size="panel">
|
||||
<span>这部作品还没有可展示的族谱节点。</span>
|
||||
@@ -528,11 +548,26 @@ export function GameLineagePage({
|
||||
/>
|
||||
高亮整棵子树
|
||||
</label>
|
||||
{fitHint && embedded ? (
|
||||
<span className="game-lineage-fork-notice" role="status">
|
||||
{fitHint}
|
||||
</span>
|
||||
) : null}
|
||||
{forkNotice ? (
|
||||
<span className="game-lineage-fork-notice" role="status">
|
||||
{forkNotice}
|
||||
</span>
|
||||
) : null}
|
||||
{forkCopyState === 'copied' ? (
|
||||
<span className="game-lineage-fork-notice" role="status">
|
||||
作品 ID 已复制,可在客户端「从平台作品开始创作」里粘贴。
|
||||
</span>
|
||||
) : null}
|
||||
{forkCopyState === 'failed' ? (
|
||||
<span className="game-lineage-fork-notice" role="status">
|
||||
复制失败:请手动选中作品 ID 文本。
|
||||
</span>
|
||||
) : null}
|
||||
{forkFallbackGameId ? (
|
||||
<CopyFeedbackButton
|
||||
state={forkCopyState}
|
||||
@@ -584,20 +619,6 @@ export function GameLineagePage({
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{layout.axis.map((entry) => (
|
||||
<span
|
||||
key={entry.generation}
|
||||
className="game-lineage-axis"
|
||||
style={{ top: entry.y }}
|
||||
>
|
||||
<span className="game-lineage-axis__label">
|
||||
{entry.label}
|
||||
</span>
|
||||
<span className="game-lineage-axis__count">
|
||||
{entry.count}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{layout.nodes.map((layoutNode) => (
|
||||
<LineageTreeNodeCard
|
||||
key={layoutNode.gameId}
|
||||
@@ -606,18 +627,35 @@ export function GameLineagePage({
|
||||
isDimmed={Boolean(
|
||||
highlighted && !highlighted.has(layoutNode.gameId),
|
||||
)}
|
||||
parentAvailable={Boolean(
|
||||
layoutNode.node?.parentGameId &&
|
||||
!stubIds.has(layoutNode.node.parentGameId),
|
||||
)}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenCompare={onOpenCompare}
|
||||
onActivate={setActiveGameId}
|
||||
onDeactivate={() => setActiveGameId('')}
|
||||
onOpenFork={launchForkFromNode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/*
|
||||
代际轴是**固定层**:在 transform 之外,位置按视口换算,字号不随缩放变小,
|
||||
因此放大或平移后轴标签始终在画布左侧可读,而不会被内容层带出去裁掉。
|
||||
*/}
|
||||
<div className="game-lineage-canvas__axis" aria-hidden="true">
|
||||
{layout.axis.map((entry) => (
|
||||
<span
|
||||
key={entry.generation}
|
||||
className="game-lineage-axis"
|
||||
style={{
|
||||
top: viewport.offsetY + entry.y * viewport.scale,
|
||||
}}
|
||||
>
|
||||
<span className="game-lineage-axis__label">
|
||||
{entry.label}
|
||||
</span>
|
||||
<span className="game-lineage-axis__count">
|
||||
{entry.count}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{derivedCount === 0 ? (
|
||||
<PlatformEmptyState surface="subpanel" size="panel">
|
||||
|
||||
@@ -795,22 +795,41 @@
|
||||
}
|
||||
.game-lineage-edge {
|
||||
fill: none;
|
||||
stroke: var(--platform-surface-border);
|
||||
stroke-width: 2;
|
||||
stroke: var(--platform-text-soft);
|
||||
/* 缩到 35% 时线不能细到看不见:non-scaling-stroke 让线宽与屏幕像素绑定,不随缩放变化。 */
|
||||
vector-effect: non-scaling-stroke;
|
||||
stroke-width: 2.5;
|
||||
opacity: 0.85;
|
||||
transition:
|
||||
stroke 160ms ease,
|
||||
opacity 160ms ease;
|
||||
}
|
||||
.game-lineage-edge--active {
|
||||
stroke: var(--platform-accent-strong);
|
||||
stroke-width: 2.5;
|
||||
stroke-width: 4;
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 3px rgba(199, 101, 61, 0.35));
|
||||
}
|
||||
.game-lineage-edge--dimmed {
|
||||
opacity: 0.18;
|
||||
opacity: 0.1;
|
||||
}
|
||||
/* 代际轴:画布左侧的固定层(在内容层的 transform 之外),放大/平移后仍然可读、不被裁。 */
|
||||
.game-lineage-canvas__axis {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 92px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--platform-surface, #fff) 72%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.game-lineage-axis {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
left: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
@@ -852,13 +871,30 @@
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-lineage-node__main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.game-lineage-node__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.game-lineage-node .game-cover {
|
||||
flex: 0 0 auto;
|
||||
width: 3.2rem;
|
||||
height: 3.2rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.game-lineage-node__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.game-lineage-node__fork,
|
||||
.game-lineage-node__compare {
|
||||
.game-lineage-node__fork {
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.14rem 0.6rem;
|
||||
@@ -869,8 +905,7 @@
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.game-lineage-node__fork:hover,
|
||||
.game-lineage-node__compare:hover {
|
||||
.game-lineage-node__fork:hover {
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
.game-lineage-zoom {
|
||||
|
||||
Reference in New Issue
Block a user