游玩次数:详情页 hero/共创卡、主题页母版行、热门衍生栏改显示子树合计并给出分解;拿不到树保持自身值
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:
@@ -60,7 +60,7 @@
|
||||
| 作者署名 | 字段存在但创建时写死 `None`,公开页兜底「创作者」 | `api-server/.../game_distribution.rs:1001-1003`、`:2390` |
|
||||
| 作者主页 / 关注 / 粉丝 | **不存在**(无任何社交关系表) | `migration.rs:149-200` 无相关表 |
|
||||
| 收藏 / 收录 | **已落地**(2026-10-06):`game_distribution_collection` 表 + `PUT/DELETE /api/game-distribution/games/{gameId}/collection`、`GET /api/game-distribution/my-collections`、公开详情 `collected` 字段,是**服务端真实投影**而非前端本地状态(`server-rs/crates/spacetime-module/src/game_distribution.rs`、`server-rs/crates/api-server/src/modules/game_distribution.rs`) | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md:74` 的「不要虚构收藏状态」条款照旧成立 |
|
||||
| 播放次数 | 本分支上 `play_count` 仍**从无递增路径**却被前端展示;PR #565 已新增游玩计数上报 procedure,合并后以 master 结果为准 | `game_distribution.rs:1759`(仅初值);`GameDetailPage.tsx:292` |
|
||||
| 播放次数 | 本分支上 `play_count` 仍**从无递增路径**却被前端展示;PR #565 已新增游玩计数上报 procedure,合并后以 master 结果为准。**显示口径见 §3.6.3**:公开字段仍是自身值,界面上的「游玩次数」按子树合计显示(前端用已有树算,服务端零改动) | `game_distribution.rs:1759`(仅初值);`GameDetailPage.tsx:292` |
|
||||
| 收益 / 分成 | **不存在**;`PuzzleAuthorIncentiveClaim` 有枚举无写入方 | `module-runtime/src/domain.rs:1147` |
|
||||
| 从包建项(客户端) | **存在且完整**:下载→SHA-256 校验→解压(拒绝绝对路径/`..`/盘符/反斜杠/符号链接,条目 ≤4096、单文件 ≤256 MiB)→建项→失败删半成品 | `template_library.rs`、`:815-850`、`:240-342` |
|
||||
| 工程包排除规则 | **存在**:禁止 `.agent` / `.git` / `.svn` / `node_modules` 段与根 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode` | `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`(后台模板上传章节)、`docs/【模板规范】AGC模板包组织指南-2026-09-21.md` |
|
||||
@@ -494,6 +494,24 @@ A 路线里那个必须提前知道的互斥点已经按既有做法绕开:`cr
|
||||
- **改动前的断点就在这一层**:`.launcher-main` 原是 `display:grid` + `grid-template-rows: auto auto auto` + `overflow:hidden`——行高被内容撑到 1494px、容器只有 570px,`overflow:hidden` 又让用户滚不动(实测:`overflow-y: hidden`、`userScrollable=false`、最后一行 `bottom=1512 > viewport 620`)。因此「共创」列表最后一行与确认页底部按钮都被裁掉、够不到。
|
||||
- **版式令牌**:两页共用 `src/features/platform-fork/forkPageStyles.ts`(内容列 `min(860px, 100vw - 124px)`、正文 `72ch` 上限、4/8 步进间距、状态用**徽章**而非灰字、只用 `packages/shared/src/theme.css` 既有 `--platform-*` 变量、`:focus-visible` 统一 `3px solid var(--platform-warm-text)`)。
|
||||
|
||||
#### 3.6.3 游玩次数的三种口径(2026-10-07 追加,纯前端显示层)
|
||||
|
||||
需求方口径:「游玩次数的**显示**必须是**子代叠加到父代**」——父代展示的是**自己 + 全部后代的合计**。
|
||||
于是同一份 `playCount` 有三种口径,**必须分清、不要合并**:
|
||||
|
||||
| 口径 | 值 | 落点 |
|
||||
| --- | --- | --- |
|
||||
| 公开接口字段 `playCount` | **自身值**(服务端**不做**子树遍历) | `/lineage` 节点、`/derived` 节点、公开详情 DTO、我的作品列表 |
|
||||
| **显示** | **子树合计** = 自身 + 全部后代,并给出分解(自身 M + 衍生 K) | 详情页 hero 与「共创」卡、主题页母版行、血缘图节点(若显示游玩数)、热门衍生栏 |
|
||||
| **主干线判据 / 热度排序** | **自身值** | 族谱「每代热度最高」的主干线判据(裁定:主干线表达「**这一版自己**有多热」,用合计会让深层子树压制真正热的叶子)、热门衍生栏排序 |
|
||||
|
||||
- **不在服务端加字段、也不让公开接口做子树遍历**(列表页会因此 N 次遍历,正是要省掉的算力成本):
|
||||
客户端用**已经拿到的那棵树**一次自底向上遍历即可,纯函数 `collectLineagePlayCountTotals`
|
||||
满足与 §3.11 贡献归集**同一条不变量** `total(W) = own(W) + Σ 直接子代 total(C)`,**零额外请求**。
|
||||
- 主题页母版行复用内嵌族谱页**同一次** `/lineage`(`GameLineagePage.onPlayCountTotals` 回传),不额外请求。
|
||||
- **拿不到树的界面继续显示自身值**(我的作品列表、广场卡片等),并且**不暗示含衍生**;详情页在树回来前
|
||||
也是自身值,「合计 + 分解」只在拿到树后出现(截断/失败时同样回退)。
|
||||
|
||||
### 3.7 灰度与可观测
|
||||
|
||||
- **M1 实现口径**:共创写入复用现役 `game-distribution:publish` 开关(`ensure_publish_enabled`),不新增独立灰度键——本功能与发布能力同批上线,先少一个开关减少漂移面。独立键 `game-distribution:fork`(含 `/api/runtime/frontend-config` 的 `gameDistributionForkEnabled` 与前端入口门禁)作为后续增强保留,接入前需要同时改 `module-runtime`、`api-server` 与前端配置读取。
|
||||
@@ -774,6 +792,11 @@ pub struct GameDistributionThemeMember {
|
||||
|
||||
#### 3.11.1 递归定义
|
||||
|
||||
> **前端显示也复用这一条**(2026-10-07,§3.6.3):公开接口的 `playCount` 仍是**自身值**,
|
||||
> 但界面上显示的「游玩次数」按**同样的不变量**在客户端用已有的那棵树算(`collectLineagePlayCountTotals`),
|
||||
> 所以展示口径与本节的服务端归集**永远一致**,且不为显示新增任何服务端字段或子树遍历。
|
||||
> 唯一例外:**主干线与热度排序的判据**用自身值(裁定见 §3.6.3)。
|
||||
|
||||
```text
|
||||
inherited(W) = Σ_{c ∈ 直接子代(W)} total(c)
|
||||
total(W) = own(W) + inherited(W)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GameDistributionLineageNode } from '../../services/gameDistributionClient';
|
||||
import { coCreationGenerationShortLabel } from './coCreationCopy';
|
||||
import { GameCover } from './GameCover';
|
||||
import type { LineagePlayCountBreakdown } from './lineageTreeLayout';
|
||||
|
||||
/**
|
||||
* 热门衍生栏:当前作品下热度最高的 5 个分支作品(横向卡片)。
|
||||
@@ -8,18 +9,34 @@ import { GameCover } from './GameCover';
|
||||
* 热度口径:**`playCount` 倒序**,同热度按创建顺序(服务端顺序)稳定兜底。
|
||||
* `/derived` 目前不保证服务端排序,所以排序在客户端做;后端若补了排序,
|
||||
* 这段逻辑仍然成立(只是排序变成幂等)。
|
||||
*
|
||||
* 显示口径(2026-10-06):「游玩次数」的**显示**一律是**子树合计**(父代展示自己 + 全部后代)。
|
||||
* 传了 `playCountTotals` 时排序与显示都用合计(这样列表顺序与卡片上的数字一致);
|
||||
* 传不进来(拿不到树)时回退服务端的**自身值**,不暗示含衍生。
|
||||
*/
|
||||
export const CO_CREATION_HOT_DERIVED_LIMIT = 5;
|
||||
|
||||
export function CoCreationHotDerived({
|
||||
nodes,
|
||||
playCountTotals,
|
||||
onOpenGameDetail,
|
||||
}: {
|
||||
nodes: readonly GameDistributionLineageNode[];
|
||||
/**
|
||||
* 每个节点的游玩次数分解(详情页用同一棵 `/lineage` 在前端算好的,零额外请求)。
|
||||
* 缺失时显示自身值。
|
||||
*/
|
||||
playCountTotals?: ReadonlyMap<string, LineagePlayCountBreakdown> | null;
|
||||
onOpenGameDetail: (gameId: string) => void;
|
||||
}) {
|
||||
const shownPlayCount = (gameId: string, own: number) =>
|
||||
playCountTotals?.get(gameId)?.total ?? own;
|
||||
const hottest = [...nodes]
|
||||
.sort((left, right) => right.playCount - left.playCount)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
shownPlayCount(right.gameId, right.playCount) -
|
||||
shownPlayCount(left.gameId, left.playCount),
|
||||
)
|
||||
.slice(0, CO_CREATION_HOT_DERIVED_LIMIT);
|
||||
if (hottest.length === 0) return null;
|
||||
|
||||
@@ -34,12 +51,16 @@ export function CoCreationHotDerived({
|
||||
onClick={() => onOpenGameDetail(node.gameId)}
|
||||
>
|
||||
<span className="co-creation-hot__cover">
|
||||
<GameCover game={gameForCover(node)} objectKey={node.coverObjectKey} />
|
||||
<GameCover
|
||||
game={gameForCover(node)}
|
||||
objectKey={node.coverObjectKey}
|
||||
/>
|
||||
</span>
|
||||
<span className="co-creation-hot__title">{node.title}</span>
|
||||
<span className="co-creation-hot__meta">
|
||||
{coCreationGenerationShortLabel(node.generation)} ·{' '}
|
||||
{node.authorName?.trim() || '未署名作者'} · {node.playCount} 次游玩
|
||||
{node.authorName?.trim() || '未署名作者'} ·{' '}
|
||||
{shownPlayCount(node.gameId, node.playCount)} 次游玩
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -60,7 +60,9 @@ import {
|
||||
} from './gameVersionHistoryModel';
|
||||
import {
|
||||
collectLineageAncestorChain,
|
||||
collectLineagePlayCountTotals,
|
||||
collectLineageSubtreeDepth,
|
||||
type LineagePlayCountBreakdown,
|
||||
} from './lineageTreeLayout';
|
||||
import { useGameAuthorDisplayName } from './useGameAuthorName';
|
||||
import { useGameReviews } from './useGameReviews';
|
||||
@@ -544,9 +546,23 @@ function GameDetailContent({
|
||||
* (见 useGameAuthorName);查不到时不显示昵称,仍保留头像、主页入口和关注操作。
|
||||
*/
|
||||
const authorName = useGameAuthorDisplayName(game.author);
|
||||
/**
|
||||
* 游玩次数的**显示**口径(2026-10-06):父代展示的是「自己 + 全部后代的合计」。
|
||||
* 用详情页本来就拿到的这棵 `/lineage` 树**在前端算**(零额外请求、服务端零改动),
|
||||
* 整棵树的分解都留着,因为「热门衍生栏」也要显示各自子树的合计。
|
||||
* 树还没回来 / 拿不到时为 `null`,各处回退成服务端的**自身值**,并且不说「含衍生」。
|
||||
*/
|
||||
const [lineageTotals, setLineageTotals] = useState<ReadonlyMap<
|
||||
string,
|
||||
LineagePlayCountBreakdown
|
||||
> | null>(null);
|
||||
const playCountBreakdown = lineageTotals?.get(game.id) ?? null;
|
||||
const displayGame = {
|
||||
...game,
|
||||
author: { ...game.author, name: authorName ?? '' },
|
||||
// hero 的「N 次游玩」按同口径显示**子树合计**;树还没回来时回退服务端自身值
|
||||
// (公开接口的 `playCount` 字段本身仍是自身值,这里只改展示)。
|
||||
playCount: playCountBreakdown?.total ?? game.playCount,
|
||||
};
|
||||
/** 作者视角的条目没有公开投影 `currentVersion`,可玩版本取版本历史里的已发布版本。 */
|
||||
const publishedVersion =
|
||||
@@ -597,6 +613,7 @@ function GameDetailContent({
|
||||
if (!publiclyVisible) {
|
||||
setLineageChain([]);
|
||||
setDerivedNodes([]);
|
||||
setLineageTotals(null);
|
||||
return;
|
||||
}
|
||||
// 每个调用各自兜底(含同步抛错):附加数据失败不能影响作品主信息。
|
||||
@@ -623,6 +640,9 @@ function GameDetailContent({
|
||||
tree ? collectLineageSubtreeDepth(tree.nodes, game.id) : null,
|
||||
);
|
||||
setDerivedNodes(derived?.nodes ?? []);
|
||||
// 游玩次数显示口径(2026-10-06):父代展示「自己 + 全部后代的合计」;
|
||||
// 用同一棵 /lineage 在前端算,零额外请求、服务端零改动。
|
||||
setLineageTotals(tree ? collectLineagePlayCountTotals(tree.nodes) : null);
|
||||
}, [game.id, publiclyVisible]);
|
||||
useEffect(() => {
|
||||
void loadCoCreationData();
|
||||
@@ -883,6 +903,21 @@ function GameDetailContent({
|
||||
) : null}
|
||||
{/* 数据概览:累计衍生作品数 + 累计创作世代数(子树深度,前端用同一份 lineage 数据算)。 */}
|
||||
<span className="co-creation-stats">
|
||||
{/*
|
||||
游玩次数按「子代叠加到父代」显示:合计 = 自身 + 全部后代,并**给出分解**,
|
||||
避免只给一个会让人误解的数字(数据来自同一棵 /lineage,零额外请求)。
|
||||
树还没回来 / 拿不到时不显示这一项(不伪造、也不暗示含衍生)。
|
||||
*/}
|
||||
{playCountBreakdown ? (
|
||||
<span
|
||||
className="co-creation-stat"
|
||||
aria-label="游玩次数合计"
|
||||
>
|
||||
游玩 {playCountBreakdown.total} 次(自身{' '}
|
||||
{playCountBreakdown.own} + 衍生{' '}
|
||||
{playCountBreakdown.inherited})
|
||||
</span>
|
||||
) : null}
|
||||
<span className="co-creation-stat">
|
||||
累计衍生作品 {forkCount}
|
||||
</span>
|
||||
@@ -1061,6 +1096,7 @@ function GameDetailContent({
|
||||
{derivedNodes.length > 0 && onOpenGameDetail ? (
|
||||
<CoCreationHotDerived
|
||||
nodes={derivedNodes}
|
||||
playCountTotals={lineageTotals}
|
||||
onOpenGameDetail={onOpenGameDetail}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -2191,6 +2191,68 @@ describe('GameThemePage', () => {
|
||||
},
|
||||
});
|
||||
|
||||
it('母版行的「N 次游玩」用子树合计(复用内嵌树那一次 /lineage,不多发请求)', async () => {
|
||||
getThemeMock.mockResolvedValue({
|
||||
themeId: 'theme-1',
|
||||
name: '精选共创',
|
||||
badge: null,
|
||||
summary: null,
|
||||
memberCount: 1,
|
||||
roots: [
|
||||
createGame({
|
||||
id: 'game-root-1',
|
||||
title: '根一',
|
||||
playCount: 3,
|
||||
lineage: { generation: 0 },
|
||||
}),
|
||||
],
|
||||
});
|
||||
getGameLineageMock.mockResolvedValue({
|
||||
rootGameId: 'game-root-1',
|
||||
root: createLineageNode({
|
||||
gameId: 'game-root-1',
|
||||
title: '根一',
|
||||
generation: 0,
|
||||
parentGameId: null,
|
||||
playCount: 3,
|
||||
}),
|
||||
nodes: [
|
||||
createLineageNode({
|
||||
gameId: 'game-root-1',
|
||||
title: '根一',
|
||||
generation: 0,
|
||||
parentGameId: null,
|
||||
playCount: 3,
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'game-child-1',
|
||||
title: '衍生一',
|
||||
generation: 1,
|
||||
parentGameId: 'game-root-1',
|
||||
playCount: 4,
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
render(
|
||||
<GameThemePage
|
||||
themeId="theme-1"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 合计 = 3 + 4 = 7(不是服务端的自身值 3),并给出分解。
|
||||
const total = await screen.findByLabelText('游玩次数合计');
|
||||
await waitFor(() =>
|
||||
expect(total.textContent).toBe('7 次游玩(自身 3 + 衍生 4)'),
|
||||
);
|
||||
expect(screen.queryByText('3 次游玩')).toBeNull();
|
||||
// 合计来自内嵌树那一次请求:主题页自己没有额外再拉一份 lineage。
|
||||
expect(getGameLineageMock).toHaveBeenCalledTimes(1);
|
||||
expect(getGameLineageMock).toHaveBeenCalledWith('game-root-1');
|
||||
});
|
||||
|
||||
it('渲染主题头与逐根的内嵌作品树(复用族谱页)', async () => {
|
||||
getThemeMock.mockResolvedValue({
|
||||
themeId: 'theme-1',
|
||||
@@ -3700,6 +3762,212 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* 游玩次数合计的数据集:root(10) → mid(20) → current(7) → child(5) → grandchild(3)。
|
||||
* 「当前作品」的合计 = 7 + 5 + 3 = 15(自身 7 + 衍生 8)。
|
||||
*/
|
||||
const playCountTree = {
|
||||
rootGameId: 'game-root',
|
||||
root: null,
|
||||
nodes: [
|
||||
createLineageNode({
|
||||
gameId: 'game-root',
|
||||
title: '根原创',
|
||||
generation: 0,
|
||||
parentGameId: null,
|
||||
playCount: 10,
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'game-mid',
|
||||
title: '中间代',
|
||||
generation: 1,
|
||||
parentGameId: 'game-root',
|
||||
playCount: 20,
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'game-current',
|
||||
title: '当前作品',
|
||||
generation: 2,
|
||||
parentGameId: 'game-mid',
|
||||
playCount: 7,
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'game-child',
|
||||
title: '子代',
|
||||
generation: 3,
|
||||
parentGameId: 'game-current',
|
||||
playCount: 5,
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'game-grandchild',
|
||||
title: '孙代',
|
||||
generation: 4,
|
||||
parentGameId: 'game-child',
|
||||
playCount: 3,
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
it('游玩次数按「子代叠加到父代」显示:卡里给合计 + 分解,hero 同口径', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
id: 'game-current',
|
||||
playCount: 7,
|
||||
forkCount: 3,
|
||||
lineage: {
|
||||
rootGameId: 'game-root',
|
||||
parentGameId: 'game-mid',
|
||||
parentTitle: '中间代',
|
||||
parentAuthorName: '原作者',
|
||||
generation: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
getGameLineageMock.mockResolvedValue(playCountTree);
|
||||
render(
|
||||
<GameDetailPage
|
||||
gameId="game-current"
|
||||
onBack={vi.fn()}
|
||||
onPlay={vi.fn()}
|
||||
onOpenGameDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 卡里的概览:合计 + 分解(不是只给一个会让人误解的数字)。
|
||||
const breakdown = await screen.findByLabelText('游玩次数合计');
|
||||
expect(breakdown.textContent).toBe('游玩 15 次(自身 7 + 衍生 8)');
|
||||
// 自身值只出现在分解里,不再单独显示成「7 次游玩」。
|
||||
expect(screen.queryByText('7 次游玩')).toBeNull();
|
||||
// hero 也是同一口径(子树合计),与卡里一致。
|
||||
expect(await screen.findByText('15 次游玩')).toBeTruthy();
|
||||
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('热门衍生栏按「子树合计」显示与排序;拿不到树时回退自身值', async () => {
|
||||
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-1' }));
|
||||
getDerivedGamesMock.mockResolvedValueOnce({
|
||||
gameId: 'game-1',
|
||||
nodes: [
|
||||
createLineageNode({
|
||||
gameId: 'child-low',
|
||||
title: '低热度',
|
||||
playCount: 1,
|
||||
generation: 1,
|
||||
parentGameId: 'game-1',
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'child-hot',
|
||||
title: '高热度',
|
||||
playCount: 99,
|
||||
generation: 1,
|
||||
parentGameId: 'game-1',
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'child-mid',
|
||||
title: '中热度',
|
||||
playCount: 50,
|
||||
generation: 1,
|
||||
parentGameId: 'game-1',
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
// 树里「低热度」有一个 200 播放的孙代 → 子树合计 201,超过「高热度」的 99。
|
||||
getGameLineageMock.mockResolvedValue({
|
||||
rootGameId: 'game-1',
|
||||
root: null,
|
||||
nodes: [
|
||||
createLineageNode({
|
||||
gameId: 'game-1',
|
||||
title: '母版',
|
||||
generation: 0,
|
||||
parentGameId: null,
|
||||
playCount: 10,
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'child-low',
|
||||
title: '低热度',
|
||||
playCount: 1,
|
||||
generation: 1,
|
||||
parentGameId: 'game-1',
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'child-grand',
|
||||
title: '孙代爆款',
|
||||
playCount: 200,
|
||||
generation: 2,
|
||||
parentGameId: 'child-low',
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const onOpenGameDetail = vi.fn();
|
||||
const { unmount } = render(
|
||||
<GameDetailPage
|
||||
gameId="game-1"
|
||||
onBack={vi.fn()}
|
||||
onPlay={vi.fn()}
|
||||
onOpenGameDetail={onOpenGameDetail}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hot = await screen.findByLabelText('热门衍生作品');
|
||||
// 显示与排序都按合计:低热度 201 → 高热度 99 → 中热度 50。
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
[...hot.querySelectorAll('.co-creation-hot__title')].map(
|
||||
(element) => element.textContent,
|
||||
),
|
||||
).toEqual(['低热度', '高热度', '中热度']),
|
||||
);
|
||||
expect(hot.textContent).toContain('201 次游玩');
|
||||
unmount();
|
||||
|
||||
// 拿不到树 → 回退服务端自身值(不暗示含衍生),排序也回退到自身值。
|
||||
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-1' }));
|
||||
getDerivedGamesMock.mockResolvedValueOnce({
|
||||
gameId: 'game-1',
|
||||
nodes: [
|
||||
createLineageNode({
|
||||
gameId: 'child-low',
|
||||
title: '低热度',
|
||||
playCount: 1,
|
||||
generation: 1,
|
||||
parentGameId: 'game-1',
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'child-hot',
|
||||
title: '高热度',
|
||||
playCount: 99,
|
||||
generation: 1,
|
||||
parentGameId: 'game-1',
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
getGameLineageMock.mockRejectedValue(new Error('拿不到树'));
|
||||
render(
|
||||
<GameDetailPage
|
||||
gameId="game-1"
|
||||
onBack={vi.fn()}
|
||||
onPlay={vi.fn()}
|
||||
onOpenGameDetail={onOpenGameDetail}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hotFallback = await screen.findByLabelText('热门衍生作品');
|
||||
expect(hotFallback.textContent).toContain('99 次游玩');
|
||||
expect(
|
||||
[...hotFallback.querySelectorAll('.co-creation-hot__title')].map(
|
||||
(element) => element.textContent,
|
||||
),
|
||||
).toEqual(['高热度', '低热度']);
|
||||
// 没有树就没有「游玩次数合计」这一项,也不出现「含衍生」的说法。
|
||||
expect(screen.queryByLabelText('游玩次数合计')).toBeNull();
|
||||
expect(document.body.textContent).not.toContain('含衍生');
|
||||
});
|
||||
|
||||
it('热门衍生栏按 playCount 倒序取前 5 个并可在点击后进入作品', async () => {
|
||||
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-1' }));
|
||||
getDerivedGamesMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -33,9 +33,11 @@ import {
|
||||
buildLineageTreeLayout,
|
||||
collectLineageHighlight,
|
||||
collectLineageImmediateScope,
|
||||
collectLineagePlayCountTotals,
|
||||
collectLineageRelationRoles,
|
||||
generationAxisLabel,
|
||||
type LineageLayoutNode,
|
||||
type LineagePlayCountBreakdown,
|
||||
type LineageRelationRole,
|
||||
} from './lineageTreeLayout';
|
||||
|
||||
@@ -54,6 +56,14 @@ type GameLineagePageProps = {
|
||||
* 供主题页逐根渲染多棵树复用同一份树查询与渲染(不新建第二套树逻辑)。
|
||||
*/
|
||||
embedded?: boolean;
|
||||
/**
|
||||
* 树加载完成后把「每个节点的游玩次数分解(子树合计)」回传给宿主,供宿主页面显示合计
|
||||
* ——宿主用**同一次** `/lineage` 请求的结果算,不需要自己再发一次(主题页的母版行就用它)。
|
||||
* 回调在内部用 ref 保存,宿主不必包 `useCallback`。
|
||||
*/
|
||||
onPlayCountTotals?: (
|
||||
totals: ReadonlyMap<string, LineagePlayCountBreakdown>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
const MIN_SCALE = 0.35;
|
||||
@@ -233,6 +243,7 @@ export function GameLineagePage({
|
||||
onBack,
|
||||
onOpenDetail,
|
||||
embedded = false,
|
||||
onPlayCountTotals,
|
||||
}: GameLineagePageProps) {
|
||||
const normalizedRootId = rootGameId?.trim() ?? '';
|
||||
const [lineage, setLineage] =
|
||||
@@ -305,6 +316,17 @@ export function GameLineagePage({
|
||||
}, [loadLineage]);
|
||||
|
||||
const nodes = useMemo(() => lineage?.nodes ?? [], [lineage]);
|
||||
/**
|
||||
* 树一变就把「每个节点的游玩次数分解」交给宿主(主题页的母版行要显示子树合计)。
|
||||
* 回调存在 ref 里,所以宿主不必包 `useCallback`,也不会因为回调换身份而反复触发。
|
||||
*/
|
||||
const playCountTotalsCallbackRef = useRef(onPlayCountTotals);
|
||||
playCountTotalsCallbackRef.current = onPlayCountTotals;
|
||||
useEffect(() => {
|
||||
const callback = playCountTotalsCallbackRef.current;
|
||||
if (!callback || !lineage) return;
|
||||
callback(collectLineagePlayCountTotals(lineage.nodes));
|
||||
}, [lineage]);
|
||||
// 高亮锚点(= 「当前作品」,也是「父代 / 子代」的基准)必须早于筛选链路声明。
|
||||
const anchorId = highlightGameId?.trim() || normalizedRootId;
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { GameLineagePage } from './GameLineagePage';
|
||||
import type { LineagePlayCountBreakdown } from './lineageTreeLayout';
|
||||
import { useGameAuthorDisplayName } from './useGameAuthorName';
|
||||
|
||||
type GameThemePageProps = {
|
||||
@@ -24,6 +25,7 @@ type GameThemePageProps = {
|
||||
/** 成员根卡片:作品摘要 + 「查看作品 / 查看作品族谱」两个入口。 */
|
||||
function ThemeRootCard({
|
||||
root,
|
||||
playCountBreakdown,
|
||||
onOpenDetail,
|
||||
onOpenLineage,
|
||||
}: {
|
||||
@@ -34,6 +36,11 @@ function ThemeRootCard({
|
||||
playCount: number;
|
||||
lineage?: { generation: number } | null;
|
||||
};
|
||||
/**
|
||||
* 游玩次数分解(子树合计)。**显示口径 2026-10-06**:父代展示「自己 + 全部后代的合计」。
|
||||
* 拿不到树时为 `null` → 回退服务端的**自身值**,且不说「含衍生」。
|
||||
*/
|
||||
playCountBreakdown?: LineagePlayCountBreakdown | null;
|
||||
onOpenDetail: (gameId: string) => void;
|
||||
onOpenLineage?: (rootGameId: string, fromGameId?: string) => void;
|
||||
}) {
|
||||
@@ -48,7 +55,13 @@ function ThemeRootCard({
|
||||
<p className="game-theme-root__meta">
|
||||
<span>{authorName?.trim() || '未署名作者'}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{root.playCount.toLocaleString()} 次游玩</span>
|
||||
<span aria-label="游玩次数合计">
|
||||
{(playCountBreakdown?.total ?? root.playCount).toLocaleString()}{' '}
|
||||
次游玩
|
||||
{playCountBreakdown && playCountBreakdown.inherited > 0
|
||||
? `(自身 ${playCountBreakdown.own} + 衍生 ${playCountBreakdown.inherited})`
|
||||
: ''}
|
||||
</span>
|
||||
</p>
|
||||
<div className="game-theme-root__actions">
|
||||
<PlatformActionButton
|
||||
@@ -111,9 +124,7 @@ export function GameThemePage({
|
||||
setTheme(detail);
|
||||
} catch (loadError: unknown) {
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: '主题不存在或尚未发布',
|
||||
loadError instanceof Error ? loadError.message : '主题不存在或尚未发布',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -124,6 +135,21 @@ export function GameThemePage({
|
||||
void loadTheme();
|
||||
}, [loadTheme]);
|
||||
|
||||
const [rootPlayCounts, setRootPlayCounts] = useState<ReadonlyMap<
|
||||
string,
|
||||
LineagePlayCountBreakdown
|
||||
> | null>(null);
|
||||
/**
|
||||
* 各根树的「游玩次数分解」由内嵌的族谱页用**它自己那次** `/lineage` 请求回传(不额外请求)。
|
||||
* 节点 id 全局唯一,所以多棵树的结果直接合并。
|
||||
*/
|
||||
const handlePlayCountTotals = useCallback(
|
||||
(totals: ReadonlyMap<string, LineagePlayCountBreakdown>) => {
|
||||
setRootPlayCounts((current) => new Map([...(current ?? []), ...totals]));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="game-page game-theme-page">
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
@@ -204,12 +230,14 @@ export function GameThemePage({
|
||||
>
|
||||
<ThemeRootCard
|
||||
root={root}
|
||||
playCountBreakdown={rootPlayCounts?.get(root.id) ?? null}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenLineage={onOpenLineage}
|
||||
/>
|
||||
{/*
|
||||
内嵌族谱页:树查询与渲染完全复用 M3 实现(embedded 只隐藏页面外壳:
|
||||
返回按钮 / 页头 / 脚注),不在主题侧重建第二套树。
|
||||
`onPlayCountTotals` 把同一棵树的游玩次数分解回传给上面那张母版行。
|
||||
*/}
|
||||
<GameLineagePage
|
||||
embedded
|
||||
@@ -217,6 +245,7 @@ export function GameThemePage({
|
||||
highlightGameId={root.id}
|
||||
onBack={onBack}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onPlayCountTotals={handlePlayCountTotals}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
|
||||
@@ -137,6 +137,19 @@ beforeEach(() => {
|
||||
} as never);
|
||||
});
|
||||
|
||||
test('作者中心列表(拿不到树)仍显示服务端自身游玩数,不暗示含衍生', async () => {
|
||||
vi.mocked(listMyGames).mockResolvedValue([
|
||||
createGame({ status: 'published', playCount: 3 }),
|
||||
]);
|
||||
renderPage(createAuthValue());
|
||||
|
||||
// 「我的作品」没有作品树可算(也不该为此发 /lineage 请求),所以显示服务端的**自身值**。
|
||||
expect(await screen.findByText(/3 次游玩/u)).toBeTruthy();
|
||||
// 不出现「合计 / 含衍生」这类只有拿到树才成立的说法。
|
||||
expect(document.body.textContent).not.toContain('含衍生');
|
||||
expect(document.body.textContent).not.toContain('游玩次数合计');
|
||||
});
|
||||
|
||||
test('未登录时提示登录且不请求作者接口', () => {
|
||||
renderPage(null);
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user