共创页卡片指标改「被共创 N 次」;图谱下父代子代清单改响应式多列;总览图标改 Minimize;详情页两按钮改名开始游戏/开始共创
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:
@@ -1,5 +1,17 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-10-07 共创页「被共创 N 次」指标、图谱下清单改响应式网格、总览图标、详情页两个主动作改名
|
||||
|
||||
同一轮四小件(用户逐条口径,均已落地并真机取证):
|
||||
|
||||
1. **共创页卡片指标位 = 「被共创 N 次」(不是游玩次数)**。用户原话:「并非游玩次数,而是共创次数」(截图圈的是卡片标题行右侧那处)。做法:共享卡 `GameCard` 新增**可选 `metric` 槽**(不传 = 原来的「N 次游玩」,默认行为一字不改),只有 `scope="forkable"` 的共创页传;**封面角标「共创 N」删除**(同一张卡只留一处计数)。文案选**被动式「被共创 N 次」**:避免「共创 N 次」读成主动语态,且平台已有「累计被共创次数」词汇;`aria-label` 同名。突出方式:`--platform-accent-strong`(实测 `rgb(199,101,61)`)+ `700` + `0.72rem`(默认指标 `0.66rem` / `--platform-text-soft`)+ `Sparkles` 12.8px 图标 + `tabular-nums`。**0 要显示**(「被共创 0 次」= 还没人接着做);只有两个字段都缺省才退回默认游玩数、不伪造 0。取值 `coCreationCount ?? forkCount`(服务端 `ffd751014` 已上线该键,实测 45/45 条带键,分布 0/1/2/3/5/7;「富树分支 B1」`forkCount=1` 而 `coCreationCount=2` ⇒ 两键口径确实不同)。
|
||||
2. **图谱下方「父代 / 子代」清单改响应式多列网格**。用户原话:「一行一个太奢侈了,你整小一点,响应式布局」。根因:两段清单用了两套容器——父代是 `grid`、子代是 `flex column + 54rem`,后者一张卡占满一行、16:9 封面被拉到 ~850×480。现在两段共用 `.lineage-relation__grid`(`repeat(auto-fill, minmax(10rem, 1fr))` + 容器 `max-width: 54rem`),实测 1440 **5 列** / 1024 **5 列** / 390 窄屏 **2 列**;卡宽 160–200px(比画布卡 212/148 窄一档,仍**同一个** `GameCard`,差异只由 `.lineage-relation__item` wrapper 表达);卡内标题单行省略、留白与字号收紧;封面仍 16:9 `object-fit: cover`。删掉随之失效的 `.lineage-relation__list` / `__row` / `__main`。
|
||||
3. **「总览」图标换成向内收拢**。用户原话:「改成向中间的箭头,因为现在这个就是起到这个作用的」。原用 lucide `Expand`(四角向外)与动作 `fitToView(true)`(缩小到装得下整棵树)**方向相反**;换成同一对反向图标里的 **`Minimize`**(箭头指向中间)。文案 / `aria-label` / `title` 保持「总览」不变(描述结果而非动作)。同栏逐个核对:缩小 `Minus` ✅、读数 `NNN%` ✅、放大 `Plus` ✅、刷新 `RefreshCcw` ✅、无搜索(`ZoomIn/ZoomOut` 已被测试钉住不得回归);测试新增 `Minimize` 必在 / `Expand` 不得回归的源码级断言。
|
||||
4. **详情页主操作行两个按钮定名**:`立即玩` → **`开始游戏`**(与游玩页启动按钮同一词表)、`一键开始共创` → **`开始共创`**(与玩家侧同一词表,两侧差别只在样式:共创者侧实心 lg / 玩家侧次要小按钮);未登录侧 `登录后开始共创` 不变。两个按钮都没有单独 `aria-label`,无障碍名 = 可见文本,因此随之自动一致。**未改**(另一件事,待确认):共创广场 hero 的「立即试玩」——它是滚动到列表的浏览入口,不是启动某个作品。
|
||||
|
||||
- 影响面:`packages/shared/src/components/GameCard/index.tsx`(新增 `metric` 槽)、`src/components/game-distribution/{GameGalleryPage,LineageRelationLists,GameLineagePage,GameDetailPage}.tsx`、`src/components/game-distribution/gameDistribution.css`、`packages/shared/src/components/GameDetailDisplay/index.tsx`(注释)、`GameDistributionPages.test.tsx`、`docs/【技术方案】游戏共创与作品Fork-2026-10-03.md`、`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):共创页 5 张不同值卡(0/1/2/3/5)的文本+颜色+字重、游戏页同作品仍是「N 次游玩」、清单在 1440/1024/390 三档的列数/卡宽/封面比例/溢出、控件栏改前后截图、详情页共创者侧与玩家侧的可见文本与无障碍名。
|
||||
|
||||
## 2026-10-07 「共创」从游戏页子页签升为侧边栏顶层入口;共创页卡片显示「共创 N」次数
|
||||
|
||||
- 用户口径(原话):「共创页不应该是游戏页里的子标签,而应该是**侧边栏里的大标签**」;「共创页里的游戏卡片要突出一个**共创次数**(即它有多少个子项目)」。
|
||||
|
||||
@@ -321,7 +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 脚本。 |
|
||||
| 公开作品投影(`public_game_payload`,**2026-10-07 增量**) | **已有** `forkCount` = **被改编次数(直接子代数量)**,公开目录与公开详情都发。**新增(2026-10-07 定义定稿 + **已上线**(提交 `ffd751014`))** `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 脚本。**运行栈实测(2026-10-07,`GET /games?limit=100&forkable=1`)**:45 条**全部**带该键,分布 `0×25 / 1×9 / 2×5 / 3×1 / 5×4 / 7×1`;且「富树分支 B1 258237」`forkCount=1` 而 `coCreationCount=2`(1 直接子代 + 1 孙代)⇒ 两键口径确实不同,前端指标位显示的是**后者**。 |
|
||||
| `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) |
|
||||
|
||||
@@ -441,7 +441,7 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr
|
||||
| 文件 | 改动 |
|
||||
| --- | --- |
|
||||
| `src/routing/activeAppPageRoutes.ts` | 新增 `['game-lineage', '/games/lineage']`;同步 `SelectionStage` 类型与 `PlatformEntryActiveFlowShell` 的 stage 分支 |
|
||||
| `src/components/game-distribution/GameDetailPage.tsx` | 世代/授权标签、数据概览(累计衍生作品 + 累计创作世代数)、血缘链(纵向滚动单链)、热门衍生栏、族谱入口、主题入口、「一键开始共创」主行动作 |
|
||||
| `src/components/game-distribution/GameDetailPage.tsx` | 世代/授权标签、数据概览(累计衍生作品 + 累计创作世代数)、血缘链(纵向滚动单链)、热门衍生栏、族谱入口、主题入口、「开始共创」主行动作 |
|
||||
| `src/components/game-distribution/GameLineagePage.tsx`(新) | 族谱页:桌面画布(节点-连线树)、移动端大纲模式、筛选器、主干线、信息面板 / bottom sheet |
|
||||
| `src/components/game-distribution/LineageNodeContent.tsx`(新) | 族谱**节点内容**(封面 + 代际角标 + 标题 + 作者)唯一实现,画布卡片与血缘链共用(避免第二套省略规则导致逐字换行) |
|
||||
| `src/components/game-distribution/LineageOutlineList.tsx`(新) | 移动端**大纲式缩进树**:全宽行 + 左缩进 + 肘形导线,无横向滚动 |
|
||||
@@ -455,7 +455,7 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr
|
||||
#### 3.6.1 交互口径(2026-10-06 补充)
|
||||
|
||||
- **世代与授权标签**:世代按文档三档命名 `0 代原创 / 1 代二创 / 2 代三创 / N 代迭代`(`coCreationCopy.ts`);授权类型沿用平台既有常量(禁止共创 / 允许非商用共创 / 允许全开放共创)。
|
||||
- **主操作「一键开始共创」始终显示**:授权为「禁止共创」时**置灰(`aria-disabled`)并给出原因**(悬停 `title` + 点击可读提示),不再静默隐藏;未登录走既有登录门禁。
|
||||
- **主操作「开始共创」始终显示**:授权为「禁止共创」时**置灰(`aria-disabled`)并给出原因**(悬停 `title` + 点击可读提示),不再静默隐藏;未登录走既有登录门禁。
|
||||
- **衍生作品的授权不可单独提升**:服务端会拒绝对衍生作品的授权 PUT,因此我的作品页对衍生作品**隐藏提升入口**,改为只读展示「共创授权继承自父作品(当前档位)」。
|
||||
- **血缘链(详情页共创卡)**:只画**祖先链**(根 → … → 当前),**纵向滚动**(`overflow-y: auto` / `overflow-x: hidden`)、**初次进入与当前作品变化时自动滚到底**;节点卡片为「横版封面缩略 + 标题 + 代际角标」的矮宽行,标题 2 行截断(`min-width: 0` + line-clamp),**不允许逐字换行**。整棵树由「查看创作族谱」入口承担——右栏只有 200 多像素,塞整棵树必然不可读。
|
||||
- **「累计创作世代数」在前端计算**:=本作品子树深度(子树最大代际 − 本作品代际),用详情页**已经请求的同一份 `/lineage` 数据**算,不新增请求、也不加服务端字段。
|
||||
@@ -477,7 +477,15 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr
|
||||
- **代际轴按层高画**:`LineageGenerationAxisEntry` 增加 `height`(该层层高),轴的每一行高度跟着层高走(不再拿「第一个节点的高度」凑)。
|
||||
- **角标组移到封面下方**(不再用深色药丸压在封面图上),并且**上色**——每类语义一色、**全部取自平台既有 `--platform-*` 变量,不新增色值**:代际 = 中性(neutral)|**主干 = 暖色(warm,强调)**|**当前作品 = 主色实心(accent solid)**|父代 = cool|子代 = success|▶游玩数 = 中性。组内仍是 flex + gap + 可换行(**绝不重叠**),并有 1px 边框保证在浅色卡片上有边界。
|
||||
- **卡片底部重做**:作者行独立一行、更小更淡(`--platform-text-muted`);操作区是**两个等权重按钮**(`flex: 1 1 0`、`min-height: 2rem` = 32px 触控目标、同圆角同内边距),`打开作品` = 次要(描边),`**开始共创**` = 主要(填充/着色);窄卡允许换行但不裁切。大纲行同口径(`打开作品` 描边 / `开始共创` 填充)。
|
||||
- **动作改名**:树节点(画布 + 大纲)上的 `Fork` 一律改为 **`开始共创`**(`aria-label` 同步为 `开始共创《标题》`)。**2026-10-07 用户拍板对外统一口径后,网页侧全量跟上**:详情页主操作 = 「一键开始共创」(未登录「登录后一键开始共创」)、信息面板针对某版本 = 「开始共创此版本」、提示/引导里引用该动作一律写「开始共创」;`Fork` 只作为内部标识(类名 / 字段 / 路由 / 类型名 / 协议)保留。AGC 客户端与主题页的改名不在本轮网页侧范围内。
|
||||
- **动作改名**:树节点(画布 + 大纲)上的 `Fork` 一律改为 **`开始共创`**(`aria-label` 同步为 `开始共创《标题》`)。**2026-10-07 用户拍板对外统一口径后,网页侧全量跟上**:详情页主操作 = 「开始共创」(未登录「登录后开始共创」)、信息面板针对某版本 = 「开始共创此版本」、提示/引导里引用该动作一律写「开始共创」;`Fork` 只作为内部标识(类名 / 字段 / 路由 / 类型名 / 协议)保留。AGC 客户端与主题页的改名不在本轮网页侧范围内。
|
||||
- **详情页主操作行两个按钮定名(2026-10-07 用户口径:「文本改成开始游戏和开始共创」)**:
|
||||
| 位置 | 改前 | 改后 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 详情页主操作(游玩) | `立即玩` | **`开始游戏`** | 与**游玩页**(`GamePlayPage` 的启动按钮本就是「开始游戏」)统一词表;图标仍 `Gamepad2`、lg / pill / 位置 / 颜色一个不动 |
|
||||
| 详情页主操作(共创者侧) | `一键开始共创` | **`开始共创`** | 与玩家侧同一词表;两侧的差别只在**样式**(共创者侧实心强调 lg,玩家侧次要小按钮 `.game-fork-remix-guest`),不再靠「一键」二字区分 |
|
||||
| 详情页主操作(玩家侧) | `开始共创` / `登录后开始共创` | **不变** | 核对一致性即可(未登录 = 「登录后开始共创」,点击走登录门禁) |
|
||||
两个按钮都**没有单独的 `aria-label`**,无障碍名 = 可见文本,因此随文案自动一致;`title` 只在「禁止共创」时出现(内容与新词一致)。**未改**:共创广场 hero 的「立即试玩」(那是**滚动到列表**的浏览入口,不是启动某个作品,属另一件事,已单列在报告里待确认)。
|
||||
|
||||
- **图谱视图为可选开关**(形态 B = **横版画布**,`orientation: 'horizontal'`:根在左、代际向右、兄弟分支沿纵向排开,窄屏上比顶→下更省横向空间):切到画布后支持拖拽平移 / 缩放;横版**不渲染左侧代际轴**,代际信息由节点自带角标承担。**初次进入按宽度适配**(整棵树横向看完;触及最小缩放时给出「可拖拽平移」提示),并把**当前作品**用最小位移拉进视口——贴最近的边,父代 / 兄弟仍留在视口里,不会出现「停在 100% 缩放、一个节点都看不到」的空屏。桌面仍为纵向画布 + 宽高同时适配并居中。
|
||||
- **信息面板用 bottom sheet**:从底部升起(含简介 / 授权 / 核心改动说明 / 前往体验 / 开始共创此版本),遮罩点击或关闭按钮收起;桌面保持画布右上浮层。
|
||||
- **父代 / 子代区分与「只看父代与子代」开关**(2026-10-06 追加):
|
||||
@@ -488,13 +496,26 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr
|
||||
- **筛选行口径**(2026-10-06 追加):`高亮整棵子树`复选框**已删除**——它恒为真、没有用户可见意义,子树高亮改为**恒定开启**(`collectLineageHighlight` 默认即含子树;页面不再持有该开关状态,也不再有恒为 `true` 的布尔往下传)。`改动类型`下拉**保留但禁用**并在旁边写明原因(`changeSummary` 是自由文本、服务端没有可枚举分类),不编造值域。
|
||||
- **移动端门限豁免(2026-10-06)**:平台原有的「移动端仅支持作品展示」欢迎弹窗只看 `!isDesktopLayout`、忽略路由,会把公开只读的族谱树 / 共创主题页首屏盖住。现在按**显式白名单**豁免这几个只读浏览 stage:`game-lineage` / `game-themes` / `game-theme` / `game-co-creation`(清单在 `PlatformActiveMobileWelcomeDialog.tsx` 的 `MOBILE_WELCOME_EXEMPT_STAGES`,键名即 stage 名,不做前缀/范围匹配)。**创作 / 项目 / 发布 / 我的 / 平台首页仍保持门限**——那些才是「移动端不支持体验创作工具」的部分;弹窗文案未改(与豁免后的事实不矛盾:族谱与主题页本身就是作品展示)。
|
||||
- **画布交互硬化(2026-10-07,用户口径)**:① **滚轮只缩放画布**——React 的 `onWheel` 挂在根容器上且是 **passive** 的(`preventDefault` 无效 ⇒ 页面跟着滚),因此改为在**画布元素本身**挂原生 `wheel` 监听并显式 `{ passive: false }`(不绑 `window`);② **拖动不选中/不拖走文本**——画布容器 `user-select: none` + `touch-action: none` + `overscroll-behavior: contain`,拖动期间把 `user-select: none` 压到 `document.body`(跨出画布也有效,松手恢复),并对 `selectstart` / `dragstart` 挂原生阻止、画布内图片 `-webkit-user-drag: none; pointer-events: none`;指针捕获(`setPointerCapture`)保证移出容器仍跟手;③ **平移边界**:`clampLineagePan`(纯函数)保证任何时刻至少 `LINEAGE_PAN_KEEP_PX = 96` 像素内容可见,拖到底/拖到顶不会把内容拖飞。真机实测(Chromium,1440×900):滚轮后缩放 60%→65%、`window.scrollY` 与平台滚动容器 `scrollTop` **都不变**;两次连续拖动各位移 +120px 且 `window.getSelection()` 始终为空字符串。
|
||||
- **「一键开始共创」位置与未安装检测(2026-10-07,用户口径)**:按钮从共创信息卡**搬到主操作行**(与「立即玩 / 收藏」并列,保持主操作视觉权重;卡内不再重复出现)。未安装检测:纯 web **拿不到系统级安装信息**,只能靠「唤起后浏览器是否被接管」间接判断(`blur` / `visibilitychange`,超时 `FORK_LAUNCH_FALLBACK_DELAY_MS = 1200ms`);**未接管 / 移动端视为「没有客户端」→ 打开与顶栏「下载客户端」同一个下载入口**(`requestClientDownload()` 事件 → `ClientDownloadEntry` 弹窗),并保留手动复制作品 ID 的降级路径与一句人话提示。**局限**:慢启动的客户端/被用户取消的系统确认框/某些不派发 blur 的环境都会误判成「未安装」,代价只是多打开一个下载入口(用户可关掉重试),不会阻断正常路径;老客户端(不响应深链)走同一条兜底。
|
||||
- **「开始共创」位置与未安装检测(2026-10-07,用户口径)**:按钮从共创信息卡**搬到主操作行**(与「开始游戏 / 收藏」并列,保持主操作视觉权重;卡内不再重复出现)。未安装检测:纯 web **拿不到系统级安装信息**,只能靠「唤起后浏览器是否被接管」间接判断(`blur` / `visibilitychange`,超时 `FORK_LAUNCH_FALLBACK_DELAY_MS = 1200ms`);**未接管 / 移动端视为「没有客户端」→ 打开与顶栏「下载客户端」同一个下载入口**(`requestClientDownload()` 事件 → `ClientDownloadEntry` 弹窗),并保留手动复制作品 ID 的降级路径与一句人话提示。**局限**:慢启动的客户端/被用户取消的系统确认框/某些不派发 blur 的环境都会误判成「未安装」,代价只是多打开一个下载入口(用户可关掉重试),不会阻断正常路径;老客户端(不响应深链)走同一条兜底。
|
||||
- **共创信息卡只留「标签 + 数据 + 入口」(2026-10-07,用户口径)**:卡里保留世代/授权标签、父代文案、数据概览(游玩合计与分解、累计衍生物、累计创作世代数)、「查看创作族谱」与所属主题 tag;**派生作品卡列表(含原「热门衍生栏」)整块移除**(`CoCreationHotDerived` 组件与其 `.co-creation-hot*` 样式已删除,零引用),改由族谱页**图下方**的「父代 / 子代」两块清单承担(父代 = 祖先链根→直接父;子代 = **直接子代**、按**热度(自身 `playCount`)降序**、同热度保持服务端创建序)。选「直接子代」的理由:与 `forkCount`/「被改编 N」同口径(数字与卡片数一致)、与画布「只看父代与子代」开关同义、更深的层级已由上面的树表达。
|
||||
- **交互口径(2026-10-07 二次修正,来自真机反馈)**:
|
||||
- **滚轮 = 画布竖向平移**(不再是缩放):原生非 passive 监听仍绑在**画布元素**上并 `preventDefault`,所以外部页面与平台滚动容器都**不滚**;平移到边界过 `clampLineagePan`。**`Ctrl`/`⌘` + 滚轮 = 缩放**(保留通用手势与触控板缩放能力),**`Shift` + 滚轮 = 横向平移**(内容超宽时用)。两个按钮(`ZoomIn` / `ZoomOut`)与「总览」(原「适应窗口」,2026-10-07 改名)负责缩放与全览。
|
||||
- **滚轮 = 画布竖向平移**(不再是缩放):原生非 passive 监听仍绑在**画布元素**上并 `preventDefault`,所以外部页面与平台滚动容器都**不滚**;平移到边界过 `clampLineagePan`。**`Ctrl`/`⌘` + 滚轮 = 缩放**(保留通用手势与触控板缩放能力),**`Shift` + 滚轮 = 横向平移**(内容超宽时用)。两个按钮(现在是 `Minus` / `Plus`,见下条)与「总览」(原「适应窗口」,2026-10-07 改名)负责缩放与全览。
|
||||
- **画布控件栏图标语义逐个定标(2026-10-07 用户口径:「改成向中间的箭头,因为现在这个就是起到这个作用的」)**:整条控件栏 = 缩小 / 读数 / 放大 / 总览 / 刷新,逐个核对「图标造型 ↔ 真实行为」:
|
||||
|
||||
| 控件 | 图标(lucide) | 语义 | 与行为是否一致 |
|
||||
| --- | --- | --- | --- |
|
||||
| 缩小 | `Minus`(−) | 减号 = 缩小 | ✅ 一致(`zoomBy(1/1.2)`) |
|
||||
| 读数 | 文本 `NNN%`(`role="status"`) | 当前缩放值 | ✅ 一致(不随字号缩放,始终可读) |
|
||||
| 放大 | `Plus`(+) | 加号 = 放大 | ✅ 一致(`zoomBy(1.2)`) |
|
||||
| **总览** | **`Minimize`(箭头指向中间)** | 收拢/适配到一屏 | **本轮修正**:原先用 `Expand`(四角向外)——造型表达「放大/展开」,而动作是 `fitToView(true)`(**缩小到装得下整棵树**),方向相反。`Minimize` 与 `Expand` 是 lucide 里同一对反向图标,换成它才对得上行为 |
|
||||
| 刷新 | `RefreshCcw` | 重新拉取 | ✅ 一致(`loadLineage()`) |
|
||||
| (无搜索) | — | — | ✅ 这条栏里**没有**放大镜,也没有搜索动作(曾有截图误读成搜索,已用测试钉住 `ZoomIn` / `ZoomOut` 不得回归) |
|
||||
|
||||
「总览」的**文案与 `aria-label` / `title` 保持「总览」不变**(它描述的是结果:整棵树都在视野里),只改图标造型;提示文案里的「点「总览」看全树」同步成立。
|
||||
|
||||
- **拖动阈值**:按下时**不捕获指针、不拦默认**;移动超过 `DRAG_START_THRESHOLD_PX = 4` 才算拖动,此时才 `setPointerCapture` + 禁选 + `preventDefault`。原因:在**容器**上按下即捕获会把 `pointerup`/`click` 重定向到容器,**卡片按钮点不动**(真机复现:鼠标点「打开作品」8s 超时、键盘 Enter 正常 ⇒ 纯指针路径被吃掉)。纯点击完全走浏览器原生 `click`。
|
||||
- **「全屏查看族谱」= 真·全屏 overlay**:`createPortal` 到 `document.body` + `position: fixed; inset: 0`(实测 overlay 矩形 1440×900 == `innerWidth/innerHeight`),`role="dialog"` + `aria-modal="true"`,`Esc` 与「关闭」都可关,打开时焦点进 overlay、关闭后回触发按钮,`body` 滚动锁定并在关闭时恢复;移动端同样整屏并留 `env(safe-area-inset-*)`;里面就是 `GameLineagePage embedded`(**同一套画布**,点选/拖动/滚轮/封面全部同一份实现),打开时卸载内嵌那棵树避免双画布。独立 `/games/lineage` 页面与共创卡的「查看创作族谱」仍是页面级入口。
|
||||
- **被搬出信息卡的按钮必须脱离卡内作用域**:`.game-fork-remix-*` / `.co-creation-fork-primary*` / `.game-fork-lineage-link` 原先都写成 `.game-detail-info-card .xxx`,搬到主操作行/正文区块后**选择器不再命中**,按钮退回浏览器默认样式(细边框、无内边距)。现已把这些选择器**去作用域化**,并按平台度量对齐主操作行:主操作「一键开始共创」= 实心强调、高 48 / 圆角 16 / 内边距 0 16(与「立即玩」逐项一致,实测数值相同);次级「全屏查看族谱」= 描边 pill。
|
||||
- **被搬出信息卡的按钮必须脱离卡内作用域**:`.game-fork-remix-*` / `.co-creation-fork-primary*` / `.game-fork-lineage-link` 原先都写成 `.game-detail-info-card .xxx`,搬到主操作行/正文区块后**选择器不再命中**,按钮退回浏览器默认样式(细边框、无内边距)。现已把这些选择器**去作用域化**,并按平台度量对齐主操作行:主操作「开始共创」= 实心强调、高 48 / 圆角 16 / 内边距 0 16(与「开始游戏」逐项一致,实测数值相同);次级「全屏查看族谱」= 描边 pill。
|
||||
- **封面固定 16:9 且铺满**:`.game-lineage-node__cover` 强制 `aspect-ratio: 16 / 9` + `flex: 0 0 auto`(不再被纵向 flex 拉伸),内层 `.game-cover` / `img` `100%×100%` + `object-fit: cover; object-position: center`。三处(画布卡片 / 大纲行 / 图谱下方父代子代清单)共用同一套;无封面时仍由 `GameCover` 给渐变占位(兜底未丢)。真机实测封面比例 **1.778 = 16:9**。
|
||||
- **图谱交互**:节点卡片与图下方父代/子代清单**每项都可点开详情**(真机鼠标点击与键盘 `Enter` 都验证过),焦点样式保持。
|
||||
- **封面盒几何口径(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`)。
|
||||
@@ -505,8 +526,20 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr
|
||||
- **高亮互斥**:进共创时高亮「共创」而**不再**高亮「游戏」——`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`,就**不强行全览**,改用可读缩放 + 平移,并给出明确提示「树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「总览」看全树。」——两种情况下都**不会**在没有提示的前提下把最深的代际丢在视口外。窄屏「图谱视图」另按**宽度**适配(见上文)。
|
||||
- **卡片指标位 = 「被共创 N 次」(2026-10-07 用户口径:「并非游玩次数,而是共创次数」)**:共创页卡片的**标题行右侧指标位**从默认的「N 次游玩」换成 **「被共创 N 次」**,**封面角标「共创 N」已删除**——同一张卡上只留这一处计数。
|
||||
- **为什么选 meta 而不是封面角标**:用户截图圈的就是 meta 这处;且角标与 meta 并存是两处重复计数,而 meta 位有固定的 0.66rem 次要信息字号可被提升成强调样式,改动面最小。
|
||||
- **为什么文案是「被共创 N 次」而不是「共创 N 次」**:「共创 N 次」有主动语态歧义(读成「这个作品共创了 N 次」),而数字表达的是「**别人**接着做了 N 次」;平台已有词汇「累计被共创次数」(我的作品页)因此沿用被动式。与卡片上其它文案一致:`aria-label` 同为「被共创 N 次」。
|
||||
- **突出方式**(真机实测值):平台既有强调色 `--platform-accent-strong`(`rgb(199,101,61)`,与详情页世代标签同色)+ `font-weight: 700` + 字号 `0.72rem`(默认游玩数是 `0.66rem` / `--platform-text-soft`)+ 前置 `Sparkles` 小图标 `12.8px`(与共创 hero、侧边栏同一套语汇)+ `tabular-nums` 让同列数字竖直对齐。
|
||||
- **只在共创页出现**:`metric` 是共享卡 `GameCard` 的**可选槽**(不传 = 原来那行「N 次游玩」,一个字不改),只有 `scope="forkable"` 传它 ⇒ 广场 / 游戏页 / 我的游戏不受影响。
|
||||
- **0 要显示**:「被共创 0 次」= 还没人接着做,是信息不是噪声;只有**两个字段都缺省**(作者侧同形响应 / 旧响应)才不传 `metric`、退回默认游玩数——**不伪造 0**。
|
||||
- **取值**:`resolveCoCreationCount(game) = game.coCreationCount ?? game.forkCount`(`>= 0` 才取,否则 `null`),口径同 §3.4 公开作品投影行。
|
||||
- **图下方「父代 / 子代」清单 = 响应式多列网格(2026-10-07 用户口径:「一行一个太奢侈了,你整小一点,响应式布局」)**:
|
||||
- **问题**:两段清单曾经用**两套**容器——父代是 `grid`,子代是 `flex column + max-width: 54rem`,后者一张卡占满一行,16:9 封面被拉到 ~850×480,一屏只看得到一张半。
|
||||
- **现在**:两段共用同一个 `.lineage-relation__grid` = `repeat(auto-fill, minmax(10rem, 1fr))` + `gap: 0.75rem`,外层 `.lineage-relations` 限宽 **54rem**(与大纲列 / 族谱内容列同一档,**不再 full-bleed**)。卡宽由列数决定 ⇒ 有上限,封面不可能再被拉成整页宽。
|
||||
- **卡宽与画布卡的关系**:清单卡 160–200px(实测 1440 视口 **5 列**、1024 视口 **5 列**、390 窄屏 **2 列**),比画布卡(桌面 212 / 窄屏 148)**窄一档**;仍然复用**同一个** `GameCard`(`className="game-card--lineage"`),尺寸差异**只由 wrapper 表达**(`.lineage-relation__item` 作用域内)。
|
||||
- **小卡内文字**:标题**单行省略**(`white-space: nowrap` + `text-overflow: ellipsis`,覆盖族谱卡默认的 2 行 clamp)、信息区与角标行的留白按卡宽收紧(`0.7rem` 侧内边距、游玩数/作者 `0.64rem`)。**封面仍是 16:9 + `object-fit: cover`**(实测比例 1.778),卡面无动作按钮、整卡可点。
|
||||
- **保留**:父代为 0 时仍是「这部作品是母版,没有父代(它的后代都在上面的树里)。」空态。
|
||||
|
||||
- **主干线唯一性复核(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`)。
|
||||
|
||||
#### 3.6.2 AGC 客户端:「共创」列表页与「开始共创」确认页(2026-10-07 补充)
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
- 提议网页根路径 `/` 进入 `/games`;桌面一级导航为“游戏 / 创作 / 项目 / 我的”,移动端为“游戏 / 我的”。图片创作及项目编辑仍遵守原有桌面端边界,移动端不再以仅支持作品展示的欢迎遮罩阻断游戏浏览和游玩。该变更仅在本节通过评审并完成验收后生效。
|
||||
- 游戏目录 `/games`、详情 `/games/detail?id=<gameId>`、游玩 `/games/play?id=<gameId>`;作者管理 `/games/mine` 只列当前账号的游戏和发行状态。平台壳及既有账号页提供入口,不建立第二套账号或作品系统。
|
||||
- 目录使用重点推荐位和紧凑卡片网格,首版推荐位只消费后台明确设置且已公开的游戏;其余列表按发布时间排列。支持关键词、分类与设备筛选,筛选写入 URL,返回列表恢复筛选和滚动位置;没有内容时展示真实空态。
|
||||
- 详情的主动作是“立即玩”,展示封面、标题、作者、短简介、设备标识、截图和简短操作方式;不虚构评分、玩家数、排名和收藏状态。游客浏览详情及启动游戏均无需登录;上传、更新、撤回和作者管理需要当前平台登录态。
|
||||
- 详情的主动作是“开始游戏”,展示封面、标题、作者、短简介、设备标识、截图和简短操作方式;不虚构评分、玩家数、排名和收藏状态。游客浏览详情及启动游戏均无需登录;上传、更新、撤回和作者管理需要当前平台登录态。
|
||||
- 游玩页先展示封面与开始按钮,用户点击后加载游戏,避免列表自动加载多个 iframe 或自动播音;提供加载、超时、重试、返回和全屏。iframe 的 `load` 事件只表示文档已加载,不宣称已经通过运行验证;首版不把 iframe 消息当平台业务状态。
|
||||
- 移动端优先采用全宽内容、底部主要动作、至少 44px 点击区域,覆盖安全区、横竖屏和键盘焦点。游戏声明仅支持桌面时,手机详情保留可浏览内容并禁用开始,明确显示“请在电脑上游玩”;横屏游戏提供旋转提示,但不能把系统方向锁定成功作为启动前提。
|
||||
- 视觉延续当前暖色主题 token、米白/浅暖背景、橙色主动作与现有字体;通过封面占比、留白、圆角和清晰状态层级现代化呈现,不另建配色系统。通用卡片动作、弹窗、上传、状态反馈、空态与媒体预览优先复用共享组件。发布打开独立弹窗/抽屉,不在当前页面末尾追加表单;技术信息仅在失败诊断或确需用户决策时展示。
|
||||
@@ -233,7 +233,7 @@
|
||||
|
||||
### 页面布局与交互
|
||||
|
||||
游戏详情保留“立即玩”和现有资料,在游戏介绍后增加“我的评价”“全部评价”两个区块;平均分与人数显示在详情资料区,并在评价区域共用同一份后端摘要。
|
||||
游戏详情保留“开始游戏”和现有资料,在游戏介绍后增加“我的评价”“全部评价”两个区块;平均分与人数显示在详情资料区,并在评价区域共用同一份后端摘要。
|
||||
|
||||
| 状态 | 我的评价区域 | 操作结果 |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -26,6 +26,7 @@ export function GameCard({
|
||||
game,
|
||||
cover,
|
||||
badges,
|
||||
metric,
|
||||
onOpen,
|
||||
className,
|
||||
}: {
|
||||
@@ -36,6 +37,14 @@ export function GameCard({
|
||||
* 不承载动作按钮 —— 卡本身就是一个整体入口。
|
||||
*/
|
||||
badges?: ReactNode;
|
||||
/**
|
||||
* 标题行右侧指标(可选):不传时是默认的「N 次游玩」;传了就用它**替换**整块。
|
||||
*
|
||||
* 为什么做成槽而不是加字段:同一个指标位在不同列表里要表达的东西不同——「共创广场」上
|
||||
* 用户要看的是「这个作品被多少人接着做了」(共创次数),不是游玩数(2026-10-07 用户口径:
|
||||
* 「并非游玩次数,而是共创次数」)。槽让调用方给任意节点,默认行为一字不改。
|
||||
*/
|
||||
metric?: ReactNode;
|
||||
onOpen: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -60,9 +69,11 @@ export function GameCard({
|
||||
<span className="game-card__body">
|
||||
<span className="game-card__title-row">
|
||||
<strong>{game.title}</strong>
|
||||
<span className="game-card__play-count">
|
||||
{game.playCount.toLocaleString()} 次游玩
|
||||
</span>
|
||||
{metric ?? (
|
||||
<span className="game-card__play-count">
|
||||
{game.playCount.toLocaleString()} 次游玩
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{hasField('summary') || summary ? (
|
||||
<span className="game-card__summary">{game.summary}</span>
|
||||
|
||||
@@ -66,7 +66,7 @@ export interface GameDetailDisplayProps extends GameDetailShowcaseProps {
|
||||
infoCards?: GameDetailDisplayInfoCard[];
|
||||
/** hero 里的评分摘要(公开详情传评分,后台可省略)。 */
|
||||
rating?: ReactNode;
|
||||
/** hero 里的主行动作区(公开详情是「立即玩」,后台是「试玩待审版本」)。 */
|
||||
/** hero 里的主行动作区(公开详情是「开始游戏」,后台是「试玩待审版本」)。 */
|
||||
actions?: ReactNode;
|
||||
/** hero 与正文之间的内容(作者作品状态、后台版本与审核信息等)。 */
|
||||
children?: ReactNode;
|
||||
|
||||
@@ -703,7 +703,7 @@ function GameDetailContent({
|
||||
const serverCollected = game.collected;
|
||||
/**
|
||||
* 操作行的状态/提示一律走 toast(portal + fixed):**不在行内占位**,否则会把
|
||||
* 「立即玩 / 收藏 / 一键开始共创」挤成两行甚至竖排(2026-10-07 用户口径)。
|
||||
* 「开始游戏 / 收藏 / 开始共创」挤成两行甚至竖排(2026-10-07 用户口径)。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!collectionNotice) return;
|
||||
@@ -854,7 +854,7 @@ function GameDetailContent({
|
||||
: !hasPlayableEntry || mobilePlaybackBlocked;
|
||||
/**
|
||||
* 付费作品在旧响应缺 `priceMudPoints` 时会被判成免费,同时服务端又没下发入口,
|
||||
* 于是「立即玩」只是灰着——必须说明原因,否则用户只看到一个点不动的按钮。
|
||||
* 于是「开始游戏」只是灰着——必须说明原因,否则用户只看到一个点不动的按钮。
|
||||
*/
|
||||
const isEntryMissing = !isPaidLocked && !ownerView && !hasPlayableEntry;
|
||||
const playbackBlockedReasonId = mobilePlaybackBlocked
|
||||
@@ -1089,11 +1089,11 @@ function GameDetailContent({
|
||||
playbackBlocked ? playbackBlockedReasonId : undefined
|
||||
}
|
||||
>
|
||||
<Gamepad2 /> 立即玩
|
||||
<Gamepad2 /> 开始游戏
|
||||
</PlatformActionButton>
|
||||
)}
|
||||
{/*
|
||||
收藏是玩家级动作:放在主行动作旁边(与「立即玩」同一区),而不是共创信息卡里
|
||||
收藏是玩家级动作:放在主行动作旁边(与「开始游戏」同一区),而不是共创信息卡里
|
||||
——那张卡讲的是「这部作品的改编关系」。未登录时不伪造收藏态:点击走登录门禁。
|
||||
*/}
|
||||
<PlatformActionButton
|
||||
@@ -1115,11 +1115,11 @@ function GameDetailContent({
|
||||
{isCollected ? '已收藏' : '收藏'}
|
||||
</PlatformActionButton>
|
||||
{/*
|
||||
「一键开始共创」从共创信息卡搬到主操作行(2026-10-07 用户口径):
|
||||
它是这部作品的主操作之一,和「立即玩 / 收藏」并列;共创卡内不再重复出现该按钮。
|
||||
「开始共创」从共创信息卡搬到主操作行(2026-10-07 用户口径):
|
||||
它是这部作品的主操作之一,和「开始游戏 / 收藏」并列;共创卡内不再重复出现该按钮。
|
||||
**入口上下文差异**(判据 `isCoCreator`,来源写在 URL 的 `from` 上):
|
||||
- 共创者侧(共创 Tab / 族谱 / 对比页进来):与改动前**完全一致**——实心强调 + lg 尺寸
|
||||
(48px / 0.94rem),文案「一键开始共创」,禁止共创时置灰 + 原因;
|
||||
(48px / 0.94rem),文案「开始共创」(与玩家侧同一词表),禁止共创时置灰 + 原因;
|
||||
- 玩家侧(游戏 Tab / 广场 / 搜索 / 收藏 / 我的游戏 / 默认深链):**不占用重点色**,缩成
|
||||
次要小按钮(描边 + 次要底色、32px / 0.78rem,见 `.game-fork-remix-guest`),
|
||||
文案如实写「开始共创」/未登录时「登录后开始共创」——不喊「一键共创」这种大口号。
|
||||
@@ -1171,7 +1171,7 @@ function GameDetailContent({
|
||||
}}
|
||||
>
|
||||
{coCreator
|
||||
? '一键开始共创'
|
||||
? '开始共创'
|
||||
: forkAuthorization === 'forbidden'
|
||||
? '禁止共创'
|
||||
: isAuthenticated
|
||||
|
||||
@@ -222,7 +222,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
/*
|
||||
* 详情页的默认 fixture = **共创者侧**(`?from=co-creation`):这是 2026-10-07 之前
|
||||
* 唯一被实现/验收过的一侧,绝大多数用例(血缘图、共创标签、一键开始共创)讲的都是它。
|
||||
* 唯一被实现/验收过的一侧,绝大多数用例(血缘图、共创标签、开始共创)讲的都是它。
|
||||
* 玩家侧的用例各自把 URL 显式设回 `/games/detail?id=…`(见「入口上下文」那两条)。
|
||||
*/
|
||||
window.history.replaceState(
|
||||
@@ -423,9 +423,12 @@ describe('GameGalleryPage', () => {
|
||||
expect(screen.queryByRole('tab', { name: '共创' })).toBeNull();
|
||||
// 分类 / 设备筛选页签仍在。
|
||||
expect(screen.getByRole('tablist', { name: '游戏分类' })).toBeTruthy();
|
||||
// ④ 封面角标已删除:同一张卡上只留指标位那一处共创计数。
|
||||
expect(document.querySelector('.game-card__badges')).toBeNull();
|
||||
expect(document.querySelector('.game-card__co-creation-badge')).toBeNull();
|
||||
});
|
||||
|
||||
it('scope=forkable:卡片带「共创 N」角标,优先 coCreationCount、回退 forkCount、0 不渲染', async () => {
|
||||
it('scope=forkable:指标位换成「被共创 N 次」,优先 coCreationCount、回退 forkCount、0 也显示', async () => {
|
||||
listGamesMock.mockResolvedValueOnce({
|
||||
games: [
|
||||
{
|
||||
@@ -462,28 +465,58 @@ describe('GameGalleryPage', () => {
|
||||
const newFieldCard = (await screen.findByText('服务端新字段')).closest(
|
||||
'.game-card',
|
||||
) as HTMLElement;
|
||||
// 新字段优先:7(不是 forkCount 的 3)。
|
||||
expect(
|
||||
newFieldCard.querySelector('.game-card__co-creation-badge')?.textContent,
|
||||
).toBe('共创 7');
|
||||
newFieldCard.querySelector('.game-card__co-creation-count')?.textContent,
|
||||
).toBe('被共创 7 次');
|
||||
expect(newFieldCard.querySelector('.game-card__play-count')).toBeNull();
|
||||
const legacyCard = screen
|
||||
.getByText('老字段回退')
|
||||
.closest('.game-card') as HTMLElement;
|
||||
// 老响应回退到 forkCount:3。
|
||||
expect(
|
||||
legacyCard.querySelector('.game-card__co-creation-badge')?.textContent,
|
||||
).toBe('共创 3');
|
||||
legacyCard.querySelector('.game-card__co-creation-count')?.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();
|
||||
// 0 是信息(还没人接着做)→ 照常显示,不是缺省。
|
||||
expect(
|
||||
zeroCard.querySelector('.game-card__co-creation-count')?.textContent,
|
||||
).toBe('被共创 0 次');
|
||||
// 无障碍名带口径,不是只有一个数字。
|
||||
expect(
|
||||
zeroCard
|
||||
.querySelector('.game-card__co-creation-count')
|
||||
?.getAttribute('aria-label'),
|
||||
).toBe('被共创 0 次');
|
||||
});
|
||||
|
||||
it('游戏广场(scope=all)卡片不带「共创 N」角标', async () => {
|
||||
it('scope=forkable:两个计数字段都没有时退回默认的「N 次游玩」而不是伪造 0', async () => {
|
||||
const bare = createGame({
|
||||
id: 'game-bare',
|
||||
title: '没有计数字段',
|
||||
forkAuthorization: 'full',
|
||||
});
|
||||
delete (bare as { forkCount?: number }).forkCount;
|
||||
delete (bare as { coCreationCount?: number }).coCreationCount;
|
||||
listGamesMock.mockResolvedValueOnce({ games: [bare], nextCursor: null });
|
||||
render(<GameGalleryPage scope="forkable" onOpenDetail={vi.fn()} />);
|
||||
|
||||
const card = (await screen.findByText('没有计数字段')).closest(
|
||||
'.game-card',
|
||||
) as HTMLElement;
|
||||
expect(card.querySelector('.game-card__co-creation-count')).toBeNull();
|
||||
expect(card.querySelector('.game-card__play-count')?.textContent).toContain(
|
||||
'次游玩',
|
||||
);
|
||||
});
|
||||
|
||||
it('游戏广场(scope=all)**保持**「N 次游玩」,不显示共创计数、也没有封面角标', async () => {
|
||||
listGamesMock.mockResolvedValueOnce({
|
||||
games: [
|
||||
{
|
||||
...createGame({ id: 'game-plain', title: '广场作品' }),
|
||||
...createGame({ id: 'game-plain', title: '广场作品', playCount: 42 }),
|
||||
forkCount: 5,
|
||||
coCreationCount: 5,
|
||||
},
|
||||
@@ -495,8 +528,11 @@ describe('GameGalleryPage', () => {
|
||||
const card = (await screen.findByText('广场作品')).closest(
|
||||
'.game-card',
|
||||
) as HTMLElement;
|
||||
expect(card.querySelector('.game-card__co-creation-count')).toBeNull();
|
||||
expect(card.querySelector('.game-card__badges')).toBeNull();
|
||||
expect(card.querySelector('.game-card__co-creation-badge')).toBeNull();
|
||||
expect(
|
||||
card.querySelector('.game-card__play-count')?.textContent?.trim(),
|
||||
).toBe('42 次游玩');
|
||||
});
|
||||
|
||||
it('scope=forkable:客户端兜底过滤掉禁止共创的作品(与服务端 forkable 同义)', async () => {
|
||||
@@ -675,7 +711,7 @@ describe('GameDetailPage', () => {
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
const playButton = await screen.findByRole('button', { name: /立即玩/u });
|
||||
const playButton = await screen.findByRole('button', { name: /开始游戏/u });
|
||||
expect((playButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(
|
||||
within(await screen.findByTestId('platform-toast-host')).getByText(
|
||||
@@ -732,7 +768,7 @@ describe('GameDetailPage', () => {
|
||||
expect(await screen.findByText('暂未发布可玩版本')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('展示共创授权、代际与溯源信息,并给出「一键开始共创」入口', async () => {
|
||||
it('展示共创授权、代际与溯源信息,并给出「开始共创」入口', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
forkAuthorization: 'nonCommercial',
|
||||
@@ -755,8 +791,8 @@ describe('GameDetailPage', () => {
|
||||
expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy();
|
||||
expect(screen.getByText('父代作者 原作者')).toBeTruthy();
|
||||
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
|
||||
// 已公开 + 授权开放 → 「一键开始共创」入口出现(M1 时它还不存在,这里钉住新契约)。
|
||||
expect(screen.getByRole('button', { name: '一键开始共创' })).toBeTruthy();
|
||||
// 已公开 + 授权开放 → 「开始共创」入口出现(M1 时它还不存在,这里钉住新契约)。
|
||||
expect(screen.getByRole('button', { name: '开始共创' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('旧数据缺少授权与血缘字段时只按禁止共创渲染', async () => {
|
||||
@@ -798,14 +834,14 @@ describe('GameDetailPage', () => {
|
||||
expect(document.querySelector('.game-fork-lineage__author')).toBeNull();
|
||||
});
|
||||
|
||||
it('付费已购买时显示立即玩并可进入游玩页', async () => {
|
||||
it('付费已购买时显示开始游戏并可进入游玩页', async () => {
|
||||
const onPlay = vi.fn();
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({ priceMudPoints: 120, purchased: true }),
|
||||
);
|
||||
render(<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={onPlay} />);
|
||||
|
||||
const playButton = await screen.findByRole('button', { name: /立即玩/u });
|
||||
const playButton = await screen.findByRole('button', { name: /开始游戏/u });
|
||||
expect((playButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.queryByRole('button', { name: /泥点购买/u })).toBeNull();
|
||||
expect(screen.getByText('120 泥点')).toBeTruthy();
|
||||
@@ -814,7 +850,7 @@ describe('GameDetailPage', () => {
|
||||
expect(onPlay).toHaveBeenCalledWith('game-1');
|
||||
});
|
||||
|
||||
it('付费未购买但服务端已下发公开入口(作者/管理员)时显示立即玩而不是购买', async () => {
|
||||
it('付费未购买但服务端已下发公开入口(作者/管理员)时显示开始游戏而不是购买', async () => {
|
||||
const onPlay = vi.fn();
|
||||
// 作者本人与管理员不会被记为已购买(purchased=false),但后端仍授予可玩入口。
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
@@ -822,7 +858,7 @@ describe('GameDetailPage', () => {
|
||||
);
|
||||
render(<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={onPlay} />);
|
||||
|
||||
const playButton = await screen.findByRole('button', { name: /立即玩/u });
|
||||
const playButton = await screen.findByRole('button', { name: /开始游戏/u });
|
||||
expect((playButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.queryByRole('button', { name: /泥点购买/u })).toBeNull();
|
||||
expect(screen.getByText('120 泥点')).toBeTruthy();
|
||||
@@ -848,7 +884,7 @@ describe('GameDetailPage', () => {
|
||||
const buyButton = await screen.findByRole('button', {
|
||||
name: /120 泥点购买/u,
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /开始游戏/u })).toBeNull();
|
||||
// 定价资料卡展示价格;入口为空是服务端口径,不是「入口地址无效」。
|
||||
expect(screen.getByText('120 泥点')).toBeTruthy();
|
||||
expect(screen.queryByText('游戏入口地址无效')).toBeNull();
|
||||
@@ -909,7 +945,9 @@ describe('GameDetailPage', () => {
|
||||
expect(
|
||||
await screen.findByText('购买成功,现在可以直接开始游玩。'),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByRole('button', { name: /立即玩/u })).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /开始游戏/u }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('余额不足时失败关闭并给出充值入口', async () => {
|
||||
@@ -942,7 +980,7 @@ describe('GameDetailPage', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '去充值' }));
|
||||
expect(onOpenRecharge).toHaveBeenCalledTimes(1);
|
||||
// 失败关闭:既没有游玩入口,也没有伪造成功。
|
||||
expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /开始游戏/u })).toBeNull();
|
||||
expect(screen.queryByText('购买成功,现在可以直接开始游玩。')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /120 泥点购买/u })).toBeTruthy();
|
||||
});
|
||||
@@ -976,7 +1014,7 @@ describe('GameDetailPage', () => {
|
||||
expect(screen.getByRole('button', { name: /120 泥点购买/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('旧响应缺价格且没有可玩入口时说明「立即玩」为何不可用', async () => {
|
||||
it('旧响应缺价格且没有可玩入口时说明「开始游戏」为何不可用', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
// 旧响应没有买断价:按免费兜底会被判成可玩,但服务端并没有下发入口。
|
||||
@@ -989,7 +1027,7 @@ describe('GameDetailPage', () => {
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
const playButton = await screen.findByRole('button', { name: /立即玩/u });
|
||||
const playButton = await screen.findByRole('button', { name: /开始游戏/u });
|
||||
await waitFor(() =>
|
||||
expect((playButton as HTMLButtonElement).disabled).toBe(true),
|
||||
);
|
||||
@@ -1067,7 +1105,7 @@ describe('GameDetailPage 作者视角', () => {
|
||||
expect(getMyGameMock).toHaveBeenCalledWith('game-1');
|
||||
expect(getGameMock).not.toHaveBeenCalled();
|
||||
|
||||
const playButton = screen.getByRole('button', { name: /立即玩/u });
|
||||
const playButton = screen.getByRole('button', { name: /开始游戏/u });
|
||||
expect((playButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(
|
||||
within(await screen.findByTestId('platform-toast-host')).getByText(
|
||||
@@ -1195,7 +1233,7 @@ describe('GameDetailPage 作者视角', () => {
|
||||
expect(listGameReviewsMock).toHaveBeenCalledWith('game-1', 1),
|
||||
);
|
||||
expect(getMyGameReviewMock).toHaveBeenCalledWith('game-1');
|
||||
const playButton = screen.getByRole('button', { name: /立即玩/u });
|
||||
const playButton = screen.getByRole('button', { name: /开始游戏/u });
|
||||
expect((playButton as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -2119,7 +2157,7 @@ describe('GameDetailPage 开始共创入口', () => {
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
const entry = await screen.findByRole('button', { name: '一键开始共创' });
|
||||
const entry = await screen.findByRole('button', { name: '开始共创' });
|
||||
// 未展开前不露出获取方式。
|
||||
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
|
||||
|
||||
@@ -2171,7 +2209,7 @@ describe('GameDetailPage 开始共创入口', () => {
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
const entry = await screen.findByRole('button', { name: '一键开始共创' });
|
||||
const entry = await screen.findByRole('button', { name: '开始共创' });
|
||||
vi.useFakeTimers();
|
||||
fireEvent.click(entry);
|
||||
expect(openForkProtocolMock).toHaveBeenCalledWith('game-1');
|
||||
@@ -2200,9 +2238,7 @@ describe('GameDetailPage 开始共创入口', () => {
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '一键开始共创' }),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '开始共创' }));
|
||||
|
||||
expect(openForkProtocolMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/当前设备无法直接唤起客户端/u)).toBeTruthy();
|
||||
@@ -2219,7 +2255,7 @@ describe('GameDetailPage 开始共创入口', () => {
|
||||
);
|
||||
|
||||
const blocked = await screen.findByRole('button', {
|
||||
name: '一键开始共创',
|
||||
name: '开始共创',
|
||||
});
|
||||
// 不静默隐藏:按钮在,但是 aria-disabled + 有原因(悬停 title)。
|
||||
expect(blocked.getAttribute('aria-disabled')).toBe('true');
|
||||
@@ -2250,10 +2286,8 @@ describe('GameDetailPage 开始共创入口', () => {
|
||||
);
|
||||
|
||||
await screen.findByText('作品状态');
|
||||
expect(screen.queryByRole('button', { name: '一键开始共创' })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '登录后一键开始共创' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '开始共创' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '登录后开始共创' })).toBeNull();
|
||||
});
|
||||
|
||||
it('未安装客户端:唤起后没被接管 → 打开同一个下载入口 + 给出人话提示', async () => {
|
||||
@@ -2265,7 +2299,7 @@ describe('GameDetailPage 开始共创入口', () => {
|
||||
);
|
||||
|
||||
// 先拿到按钮再开假定时器:findBy* 依赖真实定时器。
|
||||
const entry = await screen.findByRole('button', { name: '一键开始共创' });
|
||||
const entry = await screen.findByRole('button', { name: '开始共创' });
|
||||
vi.useFakeTimers();
|
||||
fireEvent.click(entry);
|
||||
// 唤起已发出,但还没有任何「客户端接手」信号。
|
||||
@@ -2294,7 +2328,7 @@ describe('GameDetailPage 开始共创入口', () => {
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
const entry = await screen.findByRole('button', { name: '一键开始共创' });
|
||||
const entry = await screen.findByRole('button', { name: '开始共创' });
|
||||
vi.useFakeTimers();
|
||||
fireEvent.click(entry);
|
||||
// 浏览器把焦点交给外部协议处理器 → 视为客户端接手。
|
||||
@@ -2319,9 +2353,7 @@ describe('GameDetailPage 开始共创入口', () => {
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '一键开始共创' }),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '开始共创' }));
|
||||
|
||||
expect(openForkProtocolMock).not.toHaveBeenCalled();
|
||||
expect(requestClientDownloadMock).toHaveBeenCalledTimes(1);
|
||||
@@ -2343,8 +2375,8 @@ describe('GameDetailPage 开始共创入口', () => {
|
||||
const gate = await screen.findByRole('button', {
|
||||
name: '登录后开始共创',
|
||||
});
|
||||
// 文案如实:不给玩家侧喊「一键开始共创」这种大口号。
|
||||
expect(screen.queryByRole('button', { name: '一键开始共创' })).toBeNull();
|
||||
// 文案如实:玩家侧(未登录)只有「登录后开始共创」,没有共创者侧那条主操作词。
|
||||
expect(screen.queryByRole('button', { name: '开始共创' })).toBeNull();
|
||||
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
|
||||
|
||||
fireEvent.click(gate);
|
||||
@@ -4531,6 +4563,10 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
|
||||
expect(source).toContain('<Plus aria-hidden="true" />');
|
||||
expect(source).not.toContain('ZoomIn');
|
||||
expect(source).not.toContain('ZoomOut');
|
||||
// 「总览」的动作是**收拢**回全览,图标必须是向内收的 `Minimize`;
|
||||
// `Expand`(四角向外)与行为相反,钉住不许回来。
|
||||
expect(source).toContain('<Minimize aria-hidden="true" />');
|
||||
expect(source).not.toContain('<Expand aria-hidden="true" />');
|
||||
});
|
||||
|
||||
it('图谱视图开关:移动端可切到画布(形态 B)并仍能选中节点', async () => {
|
||||
@@ -4861,8 +4897,8 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
expect(screen.getByText('允许非商用共创')).toBeTruthy();
|
||||
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
|
||||
expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy();
|
||||
// 主操作按文档叫「一键开始共创」。
|
||||
expect(screen.getByRole('button', { name: '一键开始共创' })).toBeTruthy();
|
||||
// 主操作按文档叫「开始共创」(2026-10-07 起与玩家侧同一词表)。
|
||||
expect(screen.getByRole('button', { name: '开始共创' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('累计创作世代数字段缺失时不渲染该概览项(不拿别的数字凑数)', async () => {
|
||||
@@ -4915,13 +4951,11 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
expect(
|
||||
within(card).getByRole('button', { name: '查看创作族谱' }),
|
||||
).toBeTruthy();
|
||||
// 2026-10-07:主操作「一键开始共创」已搬到**主操作行**,共创卡内不再重复出现。
|
||||
expect(
|
||||
within(card).queryByRole('button', { name: '一键开始共创' }),
|
||||
).toBeNull();
|
||||
// 2026-10-07:主操作「开始共创」已搬到**主操作行**,共创卡内不再重复出现。
|
||||
expect(within(card).queryByRole('button', { name: '开始共创' })).toBeNull();
|
||||
const actionsRow = card.closest('.game-detail-display') as HTMLElement;
|
||||
expect(
|
||||
within(actionsRow).getByRole('button', { name: '一键开始共创' }),
|
||||
within(actionsRow).getByRole('button', { name: '开始共创' }),
|
||||
).toBeTruthy();
|
||||
// ② 图在**正文之后**:`.game-detail-page` 的直接子级,且排在正文容器之后。
|
||||
const page = document.querySelector('.game-detail-page') as HTMLElement;
|
||||
@@ -4998,7 +5032,7 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
.querySelector('.game-detail-page')
|
||||
?.getAttribute('data-viewer-context'),
|
||||
).toBe('player');
|
||||
// 玩家侧的「开始共创」是次要小按钮:已登录也不再喊「一键」。
|
||||
// 玩家侧的「开始共创」是次要小按钮:登录后与共创者侧同一词表、只是样式更轻。
|
||||
expect(
|
||||
screen.getByRole('button', { name: '开始共创' }).className,
|
||||
).toContain('game-fork-remix-guest');
|
||||
@@ -5028,7 +5062,7 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
?.getAttribute('data-viewer-context'),
|
||||
).toBe('co-creator');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '一键开始共创' }).className,
|
||||
screen.getByRole('button', { name: '开始共创' }).className,
|
||||
).toContain('co-creation-fork-primary');
|
||||
});
|
||||
|
||||
@@ -5130,7 +5164,7 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const playButton = await screen.findByRole('button', { name: /立即玩/u });
|
||||
const playButton = await screen.findByRole('button', { name: /开始游戏/u });
|
||||
const actionsRow = playButton.closest(
|
||||
'.game-detail-hero__actions',
|
||||
) as HTMLElement;
|
||||
@@ -5145,7 +5179,7 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
}
|
||||
|
||||
// 触发唤起 → 面板必须是 portal 到 body 的浮层(不参与行布局),且可关。
|
||||
fireEvent.click(screen.getByRole('button', { name: '一键开始共创' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始共创' }));
|
||||
const panel = await screen.findByRole('dialog', { name: '手动开始共创' });
|
||||
expect(panel.parentElement).toBe(document.body);
|
||||
expect(panel.className).toContain('game-fork-remix-panel--portal');
|
||||
|
||||
@@ -52,20 +52,22 @@ function isForkable(game: GameDistributionGame) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片上的「共创 N」次数(模块私有:只在共创页的卡片上消费)。
|
||||
* 卡片指标位上的「共创次数」(模块私有:只在共创页的卡片上消费)。
|
||||
*
|
||||
* 字段口径(2026-10-07,服务端字段待上线):
|
||||
* - 优先读服务端公开目录**新增**的 `coCreationCount`(以服务端为准);
|
||||
* - 老响应 / 服务端未上线时回退到**已有**的 `forkCount`(契约里写明是「被改编次数 = 直接子代数量」);
|
||||
* - 两者都缺省、或为 0 → 返回 `null`(**不渲染角标**,也不渲染「共创 0」)。
|
||||
* 字段口径(2026-10-07):
|
||||
* - 优先读服务端公开目录的 `coCreationCount`(**整棵子树里公开可见的全部后代数、不含自己**);
|
||||
* - 缺省时回退**已有**的 `forkCount`(契约里写明是「被改编次数 = 直接子代数量」——
|
||||
* 栈里还是旧模块时走这条,不报错);
|
||||
* - 两者都缺省 / 非法 → 返回 `null`(调用方回退「N 次游玩」,**不伪造 0**);
|
||||
* - 真实值 `0` **要显示**(「被共创 0 次」= 还没人接着做,是信息不是噪声)。
|
||||
*
|
||||
* 只在 `scope === 'forkable'` 时使用,广场 / 游戏页卡片不受影响。
|
||||
*/
|
||||
function resolveCoCreationBadgeCount(
|
||||
function resolveCoCreationCount(
|
||||
game: Pick<GameDistributionGame, 'coCreationCount' | 'forkCount'>,
|
||||
): number | null {
|
||||
const raw = game.coCreationCount ?? game.forkCount;
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) return null;
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) return null;
|
||||
return Math.floor(raw);
|
||||
}
|
||||
|
||||
@@ -475,24 +477,30 @@ export function GameGalleryPage({
|
||||
<div className="game-grid">
|
||||
{visibleGames.map((game) => {
|
||||
/**
|
||||
* 「共创 N」次数角标:**只在共创页出现**(`scope === 'forkable'`),
|
||||
* 广场 / 游戏页卡片完全不受影响(不传 `badges` 时共享卡行为不变)。
|
||||
* **共创次数顶掉游玩数**(只在共创页生效):卡片标题行右侧那个指标位,
|
||||
* 在共创广场上要回答的是「这个作品被多少人接着做了」——2026-10-07 用户口径
|
||||
* 「并非游玩次数,而是共创次数」。角标(封面上的那枚)已删除,同一张卡上
|
||||
* 计数只留这一处。
|
||||
*
|
||||
* 取不到任何计数(作者侧 / 旧响应都没有 `coCreationCount` 与 `forkCount`)时
|
||||
* **不传 `metric`**,卡片回到默认的「N 次游玩」而不是渲染一个假 0。
|
||||
*/
|
||||
const coCreationCount = forkableOnly
|
||||
? resolveCoCreationBadgeCount(game)
|
||||
? resolveCoCreationCount(game)
|
||||
: null;
|
||||
return (
|
||||
<GameCard
|
||||
cover={<GameCover game={game} />}
|
||||
key={game.id}
|
||||
game={game}
|
||||
badges={
|
||||
metric={
|
||||
coCreationCount === null ? undefined : (
|
||||
<span
|
||||
className="game-card__co-creation-badge"
|
||||
className="game-card__co-creation-count"
|
||||
aria-label={`被共创 ${coCreationCount} 次`}
|
||||
>
|
||||
共创 {coCreationCount}
|
||||
<Sparkles aria-hidden="true" />
|
||||
被共创 {coCreationCount} 次
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './gameDistribution.css';
|
||||
|
||||
import { GameCard } from '@genarrative/shared/components';
|
||||
import { ArrowLeft, Expand, Minus, Plus, RefreshCcw } from 'lucide-react';
|
||||
import { ArrowLeft, Minimize, Minus, Plus, RefreshCcw } from 'lucide-react';
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
@@ -1369,7 +1369,13 @@ export function GameLineagePage({
|
||||
title="总览"
|
||||
onClick={() => fitToView(true)}
|
||||
>
|
||||
<Expand aria-hidden="true" />
|
||||
{/*
|
||||
「总览」的动作是**把视图收拢回全览**(缩放到装得下整棵树),
|
||||
所以图标用 `Minimize`(箭头指向中间)——它与 `Expand` 是 lucide 里
|
||||
同一对反向图标,语义正好对上「收拢/适配」;原先用 `Expand`(四角向外)
|
||||
与行为相反(用户截图口径:「改成向中间的箭头,因为现在这个就是起到这个作用的」)。
|
||||
*/}
|
||||
<Minimize aria-hidden="true" />
|
||||
<span className="game-lineage-controls__label">总览</span>
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -123,7 +123,7 @@ export function LineageRelationLists({
|
||||
{children.length === 0 ? (
|
||||
<p className="lineage-relation__empty">这部作品还没有直接子代。</p>
|
||||
) : (
|
||||
<ul className="lineage-relation__list">
|
||||
<ul className="lineage-relation__grid">
|
||||
{children.map((node) => renderCard(node, 'descendant'))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -2681,44 +2681,14 @@
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.lineage-relation__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-width: 54rem;
|
||||
}
|
||||
.lineage-relation__row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
.lineage-relation__main {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--platform-surface, #fff);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lineage-relation__main:hover {
|
||||
border-color: var(--platform-surface-hover-border);
|
||||
}
|
||||
|
||||
/*
|
||||
* 「一键开始共创」在主操作行里:**必须与「立即玩」同一套视觉权重**。
|
||||
* 「开始共创」在主操作行里:**必须与「开始游戏」同一套视觉权重**。
|
||||
*
|
||||
* 为什么需要这段:按钮原先住在 `.game-detail-info-card` 里,样式被卡内作用域套着
|
||||
* (`.game-detail-info-card .game-fork-remix-link` / `.co-creation-fork-primary`);
|
||||
* 搬到 `actions` 主操作行后**选择器不再命中**,按钮退回浏览器默认样式(细边框 + 无内边距),
|
||||
* 于是与「立即玩」不是一个量级。这里把它按平台主操作按钮的度量固定下来(实心强调、lg 高度)。
|
||||
* 于是与「开始游戏」不是一个量级。这里把它按平台主操作按钮的度量固定下来(实心强调、lg 高度)。
|
||||
*/
|
||||
.game-fork-remix-link.co-creation-fork-primary {
|
||||
display: inline-flex;
|
||||
@@ -2726,7 +2696,7 @@
|
||||
justify-content: center;
|
||||
align-self: center;
|
||||
gap: 0.35rem;
|
||||
/* 与主操作行里的「立即玩」逐项对齐(实测:高 48 / 圆角 16 / 内边距 0 16 / 字号 15)。 */
|
||||
/* 与主操作行里的「开始游戏」逐项对齐(实测:高 48 / 圆角 16 / 内边距 0 16 / 字号 15)。 */
|
||||
min-height: 3rem;
|
||||
padding: 0 1rem;
|
||||
border: 1px solid var(--platform-button-primary-border);
|
||||
@@ -2754,7 +2724,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: center;
|
||||
/* 与主操作行里的「收藏」(sm) 同量级,明显小于「立即玩」(lg)。 */
|
||||
/* 与主操作行里的「收藏」(sm) 同量级,明显小于「开始游戏」(lg)。 */
|
||||
min-height: 2rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
@@ -2906,7 +2876,7 @@
|
||||
/*
|
||||
* 主操作行「任何状态都不换行、不被挤扁」(2026-10-07 用户口径:提示显示成 toast,别挤占元素空间)。
|
||||
* 行内状态文本已经全部改走 toast / portal 浮层;这里再钉一层样式保险:
|
||||
* 子项不收缩、文字不折行 —— 无论旁边以后再加什么,都不会把「立即玩 / 收藏 / 一键开始共创」压成两行。
|
||||
* 子项不收缩、文字不折行 —— 无论旁边以后再加什么,都不会把「开始游戏 / 收藏 / 开始共创」压成两行。
|
||||
*/
|
||||
.game-detail-hero__actions > * {
|
||||
flex: 0 0 auto;
|
||||
@@ -3053,15 +3023,56 @@
|
||||
.game-lineage-node--compact .game-card--lineage .game-card__badges > * {
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
/* 图下方「父代 / 子代」清单:与广场一致的卡片网格。 */
|
||||
/*
|
||||
* 图下方「父代 / 子代」清单:与画布卡**同一套**卡片,改成**响应式多列网格**
|
||||
* (2026-10-07 用户口径:「一行一个太奢侈了,你整小一点,响应式布局」)。
|
||||
*
|
||||
* 为什么原来很奢侈:子代那段用的是 `.lineage-relation__list`(flex column + 54rem 上限),
|
||||
* 一张卡就占满一行,16:9 封面被拉到 ~850×480,一屏只看得到一张半。现在两段共用同一个网格。
|
||||
*
|
||||
* 尺寸口径:
|
||||
* - 容器限宽 `54rem`(与大纲列 / 族谱内容列同一档),**不再 full-bleed**;
|
||||
* - 卡宽 = `minmax(10rem, 1fr)` ⇒ 实测 160–200px 区间(1440 视口 5 列、1024 视口 5 列、
|
||||
* 390 窄屏 2 列);与画布卡同数量级、比它小一档(画布卡桌面 212 / 窄屏 148)。
|
||||
* - 卡宽是**有上限的**(由列数决定),所以封面不会再被拉成整页宽。
|
||||
*/
|
||||
.lineage-relations {
|
||||
max-width: 54rem;
|
||||
}
|
||||
.lineage-relation__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
/* 最小轨道 9rem(144px):390 窄屏容器实测 316px,需要 2×144+12 = 300 ≤ 316 才能排成
|
||||
**两列**(用户期望量级 ≥2 列;10rem 时 2×160+12 = 332 > 316 只能落成一列)。 */
|
||||
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
/* 清单卡 ~160px,比画布卡(212/148)窄一档:标题压成**单行省略**,信息区与角标留白收紧。
|
||||
尺寸差异只由 wrapper 表达,共享卡与画布卡的口径一个字都不动。 */
|
||||
.lineage-relation__item .game-card--lineage .game-card__title-row strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.lineage-relation__item .game-card--lineage .game-card__body {
|
||||
padding: 0.5rem 0.7rem 0.7rem;
|
||||
}
|
||||
.lineage-relation__item .game-card--lineage .game-card__badges {
|
||||
padding: 0.45rem 0.7rem 0;
|
||||
}
|
||||
.lineage-relation__item .game-card--lineage .game-card__badges > * {
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
.lineage-relation__item .game-card--lineage .game-card__play-count,
|
||||
.lineage-relation__item .game-card--lineage .game-card__meta {
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
.lineage-relation__item .game-card--lineage .game-card__meta {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.lineage-relation__item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -3071,20 +3082,27 @@
|
||||
}
|
||||
|
||||
/*
|
||||
共创页卡片的「共创 N」次数角标(2026-10-07)。
|
||||
位置由共享卡自己的 `.game-card__badges` 槽决定(绝对定位在封面上沿,见
|
||||
packages/shared/src/components/GameCard/style.css);这里只给这一枚角标的观感:
|
||||
实心强调色 + 白字,保证在任意封面上都醒目、可读。
|
||||
只有「共创」页(`scope="forkable"`)会传这个角标,广场 / 游戏页卡片不传、零影响。
|
||||
共创页卡片的「被共创 N 次」指标(2026-10-07,取代同日早前的封面角标)。
|
||||
位置由共享卡的 `metric` 槽决定:它就是标题行右侧原来「N 次游玩」那一格
|
||||
(`.game-card__title-row` 里 `flex: none` 的右端),这里只负责**突出**它:
|
||||
强调色(平台既有 `--platform-accent-strong`)+ 700 字重 + 比默认指标大一档
|
||||
(0.72rem vs 0.66rem)+ 一个小星标(与共创 hero / 侧边栏同一套 `Sparkles` 语汇),
|
||||
数字用 tabular-nums 对齐,保证同列多张卡的数字竖着能对齐。
|
||||
只有「共创」页(`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;
|
||||
.game-card__co-creation-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
gap: 0.2rem;
|
||||
color: var(--platform-accent-strong, var(--platform-accent));
|
||||
font-size: 0.72rem;
|
||||
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);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.game-card__co-creation-count svg {
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user