游玩次数:详情页 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

This commit is contained in:
2026-10-07 11:18:31 +08:00
parent 7dbe2a9b5e
commit 0b6c7a110e
7 changed files with 420 additions and 8 deletions
@@ -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(