详情页按观看者角色分游客/共创者:游客不渲染血缘图与族谱入口、不显示共创标签,「开始共创」降为次要小按钮(isCoCreator 谓词驱动)

This commit is contained in:
2026-10-07 17:51:40 +08:00
parent c99d92665c
commit 30ea1ee246
5 changed files with 472 additions and 56 deletions
@@ -61,6 +61,7 @@ import {
VERSION_STATUS_LABEL,
VERSION_STATUS_NEEDS_AUTHOR_ACTION,
} from './gameVersionHistoryModel';
import { isCoCreator } from './gameViewerRole';
import {
collectLineageAncestorChain,
collectLineagePlayCountTotals,
@@ -538,6 +539,12 @@ function GameDetailContent({
onOpenTheme?: (themeId: string) => void;
onOpenGameDetail?: (gameId: string) => void;
}) {
/**
* 观看者角色(2026-10-07 产品口径:普通用户 = 游客 / 共创者 = 开发游戏的)。
* **判据只有一处**(`isCoCreator`,当前口径 = 登录态,见该模块的「待产品确认」说明),
* 页面上所有角色差异都用这一个布尔量驱动,不在 JSX 里散写 `isAuthenticated`。
*/
const coCreator = isCoCreator(isAuthenticated);
/**
* 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架
* 一律不请求评价接口,封面位改为状态文案。
@@ -599,10 +606,13 @@ function GameDetailContent({
GameDistributionLineageNode[]
>([]);
/**
* 正文之后那块整宽血缘图是否渲染:公开可见 + 有可跳转详情的回调 + **确实有亲戚**
* (有父代链或已有衍生作品)。孤立原创作品的详情页不该多出一块只有一个节点的图。
* 正文之后那块整宽血缘图是否渲染:**共创者**(游客整块不渲染)+ 公开可见 +
* 有可跳转详情的回调 + **确实有亲戚**(有父代链或已有衍生作品)。
* 孤立原创作品的详情页不该多出一块只有一个节点的图。
* 游客口径:血缘/族谱是「共创者」的结构信息,游客(只玩游戏)不渲染图、也不渲染图下的父代/子代清单。
*/
const showLineageBlock =
coCreator &&
publiclyVisible &&
Boolean(onOpenGameDetail) &&
(lineageChain.length > 1 || derivedNodes.length > 0);
@@ -866,7 +876,11 @@ function GameDetailContent({
publishedVersion,
]);
return (
<div className="game-detail-page">
<div
className="game-detail-page"
// 角色差异的可观测面(探针/验收用):游客 / 共创者。
data-viewer-role={coCreator ? 'co-creator' : 'guest'}
>
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回
</button>
@@ -909,23 +923,31 @@ function GameDetailContent({
label: '共创',
value: (
<span className="game-fork-card">
<span className="co-creation-tags">
{/* 世代标签(文档口径:0 代原创 / 1 代二创 / 2 代三创 / N 代迭代)。 */}
<span className="co-creation-tag co-creation-tag--generation">
{coCreationGenerationTagLabel(lineage?.generation ?? 0)}
{/*
游客(只玩游戏)不显示**共创标签**:世代(第 N 代 / 母版)与授权档位
(允许共创 / 非商用 / 禁止共创)都是「共创者视角」的档位标签。
共创者与改动前完全一致。
*/}
{coCreator ? (
<span className="co-creation-tags">
{/* 世代标签(文档口径:0 代原创 / 1 代二创 / 2 代三创 / N 代迭代)。 */}
<span className="co-creation-tag co-creation-tag--generation">
{coCreationGenerationTagLabel(lineage?.generation ?? 0)}
</span>
{/* 授权类型标签:沿用平台既有 constants(含「允许」前缀),不另造一套文案。 */}
<span
className={`co-creation-tag co-creation-tag--authorization game-fork-badge game-fork-badge--${forkAuthorization}`}
>
{
GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
forkAuthorization
]
}
</span>
</span>
{/* 授权类型标签:沿用平台既有 constants(含「允许」前缀),不另造一套文案。 */}
<span
className={`co-creation-tag co-creation-tag--authorization game-fork-badge game-fork-badge--${forkAuthorization}`}
>
{
GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
forkAuthorization
]
}
</span>
</span>
{lineage ? (
) : null}
{/* 血缘关系文字行(改编自父代《…》/ 父代作者)同样只给共创者看。 */}
{coCreator && lineage ? (
<>
{/*
父作品被软删除时服务端不再回传它的标题与作者名(见
@@ -971,7 +993,8 @@ function GameDetailContent({
</span>
)}
</span>
{onOpenLineage && publiclyVisible ? (
{/* 进入血缘视图的入口(族谱 / 与原版对比)也只给共创者。 */}
{coCreator && onOpenLineage && publiclyVisible ? (
/*
族谱入口放在共创信息卡里:它属于「这部作品的改编关系」这件事,
点进去是以母版为顶的整棵族谱树;`from` 带上当前作品供页面高亮。
@@ -987,7 +1010,10 @@ function GameDetailContent({
查看创作族谱
</button>
) : null}
{publiclyVisible && lineage?.parentGameId && onOpenCompare ? (
{coCreator &&
publiclyVisible &&
lineage?.parentGameId &&
onOpenCompare ? (
/*
有父作品(二创作品)时给出「与原版对比」:左侧原版、右侧当前作品,
两侧各自取数与降级(父作品不可读时只降级那一侧)。
@@ -1076,17 +1102,27 @@ function GameDetailContent({
{/*
「一键开始共创」从共创信息卡搬到主操作行(2026-10-07 用户口径):
它是这部作品的主操作之一,和「立即玩 / 收藏」并列;共创卡内不再重复出现该按钮。
视觉权重保持主操作(实心强调色),未登录 → 登录门禁,禁止共创 → 置灰 + 原因。
**角色差异**(判据 `isCoCreator`):
- 共创者:与改动前**完全一致**——实心强调 + lg 尺寸(48px / 0.94rem),文案「一键开始共创」,
禁止共创时置灰 + 原因;
- 游客:**不占用重点色**,缩成次要小按钮(描边 + 次要底色、32px / 0.78rem,见
`.game-fork-remix-guest`),文案如实写「登录后开始共创」——不喊「一键共创」这种大口号。
但**仍然可点可达**:点击走仓库既有的登录门禁(`onLogin`),既不禁用也不隐藏。
*/}
{publiclyVisible ? (
<>
<button
type="button"
className={`game-fork-remix-link co-creation-fork-primary${
forkAuthorization === 'forbidden'
? ' co-creation-fork-primary--blocked'
: ''
}`}
className={
coCreator
? `game-fork-remix-link co-creation-fork-primary${
forkAuthorization === 'forbidden'
? ' co-creation-fork-primary--blocked'
: ''
}`
: 'game-fork-remix-link game-fork-remix-guest'
}
data-viewer-role={coCreator ? 'co-creator' : 'guest'}
aria-disabled={forkAuthorization === 'forbidden'}
aria-expanded={
isAuthenticated && forkAuthorization !== 'forbidden'
@@ -1117,7 +1153,11 @@ function GameDetailContent({
handleForkLaunch();
}}
>
{isAuthenticated ? '一键开始共创' : '登录后一键开始共创'}
{coCreator
? '一键开始共创'
: forkAuthorization === 'forbidden'
? '禁止共创'
: '登录后开始共创'}
</button>
{isAuthenticated && isForkPanelOpen
? createPortal(
@@ -1,5 +1,7 @@
/* @vitest-environment jsdom */
import { readFileSync } from 'node:fs';
import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components';
import {
act,
@@ -2316,7 +2318,7 @@ describe('GameDetailPage 开始共创入口', () => {
expect(screen.getByText(/当前设备无法直接唤起客户端/u)).toBeTruthy();
});
it('未登录时入口显示「登录后一键开始共创」,点击走仓库既有登录门禁而不是静默隐藏', async () => {
it('游客(未登录)入口是次要小按钮「登录后开始共创」,点击走仓库既有登录门禁而不是静默隐藏', async () => {
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
getGameMock.mockResolvedValueOnce(
@@ -2327,8 +2329,10 @@ describe('GameDetailPage 开始共创入口', () => {
);
const gate = await screen.findByRole('button', {
name: '登录后一键开始共创',
name: '登录后开始共创',
});
// 文案如实:不给游客喊「一键开始共创」这种大口号。
expect(screen.queryByRole('button', { name: '一键开始共创' })).toBeNull();
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
fireEvent.click(gate);
@@ -2336,6 +2340,37 @@ describe('GameDetailPage 开始共创入口', () => {
// 未登录既不唤起客户端,也不展开获取方式。
expect(openForkProtocolMock).not.toHaveBeenCalled();
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
// 角色差异可观测:根节点带 `data-viewer-role`,按钮走「游客」变体(不占重点色)。
expect(
document
.querySelector('.game-detail-page')
?.getAttribute('data-viewer-role'),
).toBe('guest');
expect(gate.getAttribute('data-viewer-role')).toBe('guest');
expect(gate.className).toContain('game-fork-remix-guest');
expect(gate.className).not.toContain('co-creation-fork-primary');
});
it('游客变体的度量与配色:次要描边 + 32px/0.78rem,明显小于共创者的主操作(48px/0.94rem)', async () => {
// jsdom 不算几何:用 CSS 源码契约把「主次差异」钉死(真机几何由 smoke 记录)。
const css = readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
);
expect(css).toMatch(
/\.game-fork-remix-link\.game-fork-remix-guest \{[\s\S]{0,400}?min-height: 2rem;/u,
);
expect(css).toMatch(
/\.game-fork-remix-link\.game-fork-remix-guest \{[\s\S]{0,400}?font-size: 0\.78rem;/u,
);
expect(css).toMatch(
/\.game-fork-remix-link\.game-fork-remix-guest \{[\s\S]{0,500}?background: var\(--platform-button-secondary-fill\);/u,
);
// 主操作(共创者那份)仍是实心强调 + lg 尺寸,两者不是同一套度量。
expect(css).toMatch(
/\.game-fork-remix-link\.co-creation-fork-primary \{[\s\S]{0,400}?min-height: 3rem;/u,
);
expect(css).toMatch(/background: var\(--platform-button-primary-solid\);/u);
});
});
@@ -2428,23 +2463,24 @@ describe('原版与二创对比入口', () => {
);
expect(await findTreeText('叶节点')).toBeTruthy();
// 口径变更(2026-10-07,用户口径):**节点也显示游玩次数**,显示值 = 该节点的**子树合计**
// (`collectLineagePlayCountTotals().total`),不是自身值。
// 口径变更(2026-10-07):节点显示**子树合计**(`collectLineagePlayCountTotals().total`),
// 不是自身值;但**只显示一次** —— 卡面本体(广场卡)的「N 次游玩」就是它,
// 角标里那枚重复的 `▶ N` 已删除(截图反馈「▶N 同时出现在角标与 meta 行」)。
const rootCard = await cardOf('母版');
// 母版合计 = 自身 1 + 叶节点 4 = 5(孤儿节点挂在缺失的父下,不算它的后代)。
expect(rootCard.querySelector('.game-lineage-node__play-count')).toBeNull();
expect(
rootCard.querySelector('.game-lineage-node__play-count')?.textContent,
).toBe('▶ 5');
cardButtonOf(rootCard)?.querySelector('.game-card__play-count')
?.textContent,
).toBe('5 次游玩');
const leafCard = await cardOf('叶节点');
expect(leafCard.querySelector('.game-lineage-node__play-count')).toBeNull();
expect(
leafCard.querySelector('.game-lineage-node__play-count')?.textContent,
).toBe('▶ 4');
// 角标的 title / aria-label 说明它**含衍生**,避免把合计误解成自身。
const chip = rootCard.querySelector(
'.game-lineage-node__play-count',
) as HTMLElement;
expect(chip.getAttribute('title')).toContain('含衍生作品');
expect(chip.getAttribute('aria-label')).toContain('含衍生作品');
cardButtonOf(leafCard)?.querySelector('.game-card__play-count')
?.textContent,
).toBe('4 次游玩');
// 「含衍生」的口径说明挂在节点容器上(卡面那一行没法挂 title),悬停仍能看到分解。
expect(rootCard.getAttribute('title')).toContain('含衍生作品');
// 「与原版对比」仍然留在详情页,不在树里重复渲染。
expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull();
// 卡面**没有**动作按钮(整卡即入口);游玩数由卡片自身承载(子树合计口径)。
@@ -3335,7 +3371,7 @@ describe('GameLineagePage 可视化与交互', () => {
expect(LINEAGE_COVER_ASPECT).toBeCloseTo(16 / 9, 5);
});
it('初始适配:装得下就全览、装不下就退回可读缩放并明确提示', async () => {
it('初始适配:装得下就全览、装不下就停在可读下限(横向铺满)并明确提示', async () => {
// 情形 1:视口够大 → 宽高同时 fit,所有可见节点都在视口内、无提示。
const wideWidth = vi
.spyOn(Element.prototype, 'clientWidth', 'get')
@@ -3366,7 +3402,7 @@ describe('GameLineagePage 可视化与交互', () => {
expect(screen.queryByText(/其余代际在视口外/u)).toBeNull();
first.unmount();
// 情形 2:视口装不下全览(低于可读下限)→ 停在可读缩放 + 明确提示,不静默丢代际。
// 情形 2:视口装不下全览(低于可读下限)→ 停在可读下限 + 明确提示,不静默丢代际。
wideWidth.mockReturnValue(420);
wideHeight.mockReturnValue(320);
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
@@ -3388,8 +3424,8 @@ describe('GameLineagePage 可视化与交互', () => {
);
const smallView = readCanvasViewport(smallCanvas);
const visible = visibleCanvasCards(smallCanvas, smallView);
const floored = Math.abs(smallView.scale - 0.6) < 0.001;
// 二选一(都不允许「既没全览又没提示」):要么全部可见,要么触底可读缩放 + 提示出现。
const floored = Math.abs(smallView.scale - 0.8) < 0.001;
// 二选一(都不允许「既没全览又没提示」):要么全部可见,要么停在可读下限 + 提示出现。
expect(floored || visible.length === 8).toBe(true);
if (floored) {
expect(
@@ -3397,7 +3433,7 @@ describe('GameLineagePage 可视化与交互', () => {
'树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「总览」看全树。',
),
).toBeTruthy();
// 触底时仍然把当前作品放进视口(不是把它也丢出去)。
// 停在可读下限时仍然把当前作品放进视口(不是把它也丢出去)。
expect(visible).toContain(
smallCanvas.querySelector('.game-lineage-node--anchor'),
);
@@ -3408,6 +3444,53 @@ describe('GameLineagePage 可视化与交互', () => {
}
});
it('停在可读下限时横向铺满视口:树又宽又高时不留左右大片空白', async () => {
// 宽而矮的视口:按宽度适配(>1)比按高度全览(<1)大得多 —— 旧口径会取 0.6 的可读下限,
// 于是内容只占视口宽度的一半(截图反馈「留白过多」);新口径取「宽度适配」把横向铺满。
const widthSpy = vi
.spyOn(Element.prototype, 'clientWidth', 'get')
.mockReturnValue(1600);
const heightSpy = vi
.spyOn(Element.prototype, 'clientHeight', 'get')
.mockReturnValue(380);
try {
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
render(
<GameLineagePage
rootGameId="A0"
highlightGameId="A111"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const canvas = (await screen.findByLabelText(
'创作族谱树',
)) as HTMLElement;
await waitFor(() =>
expect(canvas.querySelectorAll('.game-lineage-node').length).toBe(8),
);
const content = canvas.querySelector(
'.game-lineage-canvas__content',
) as HTMLElement;
const layoutWidth = Number.parseFloat(content.style.width);
const view = readCanvasViewport(canvas);
// 可读下限之上的横向铺满 = 按宽度适配(FIT_PADDING=24 ⇒ 可用宽度 1600-48)。
const fitWidth = (1600 - 48) / layoutWidth;
expect(view.scale).toBeGreaterThan(0.8);
expect(view.scale).toBeCloseTo(fitWidth, 3);
// 内容宽度(含 FIT_PADDING 的一侧)铺到视口边缘附近:空白 ≤ 上下留白量级。
expect(layoutWidth * view.scale).toBeGreaterThan(1500);
expect(
screen.getByText(
'树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「总览」看全树。',
),
).toBeTruthy();
} finally {
widthSpy.mockRestore();
heightSpy.mockRestore();
}
});
it('画布卡片就是广场卡组件:角标走卡面 badge 槽、卡内没有动作按钮、整卡点击进详情', async () => {
getGameLineageMock.mockResolvedValueOnce(relationTree);
const onOpenDetail = vi.fn();
@@ -3429,6 +3512,18 @@ describe('GameLineagePage 可视化与交互', () => {
) as HTMLElement;
expect(badges).toBeTruthy();
expect(within(badges).getByText('当前作品')).toBeTruthy();
// ①' 角标**不再压在封面上**:角标槽排在封面之后、信息区之前(DOM 顺序即渲染顺序)。
const cover = cardButton.querySelector('.game-cover') as HTMLElement;
const body = cardButton.querySelector('.game-card__body') as HTMLElement;
expect(cover.compareDocumentPosition(badges)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(badges.compareDocumentPosition(body)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
// ①'' 竖版画布有左侧代际轴 → 卡上不再重复「第 N 代」;玩数由卡面承载 → 角标里没有 ▶N。
expect(cardButton.querySelector('.game-lineage-node__generation')).toBeNull();
expect(cardButton.querySelector('.game-lineage-node__play-count')).toBeNull();
// ② 卡内**没有任何动作按钮**(打开作品 / 开始共创 都已从卡面移除)。
expect(within(cardButton).queryByRole('button')).toBeNull();
expect(within(cardButton).queryByText('打开作品')).toBeNull();
@@ -3450,6 +3545,17 @@ describe('GameLineagePage 可视化与交互', () => {
expect(css).toMatch(
/\.game-card--lineage \.game-cover \{[\s\S]{0,200}?aspect-ratio: 16 \/ 9;/u,
);
// 角标槽必须离开「卡内绝对定位浮层」(共享卡默认位置)→ 静态流式、排在封面下方。
expect(css).toMatch(
/\.game-card--lineage \.game-card__badges \{[\s\S]{0,300}?position: static;/u,
);
expect(css).toMatch(
/\.game-card--lineage \.game-card__badges > \* \{[\s\S]{0,300}?position: static;/u,
);
// 窄卡上标题独占一行、游玩数不再和标题抢行宽。
expect(css).toMatch(
/\.game-card--lineage \.game-card__title-row \{[\s\S]{0,200}?flex-direction: column;/u,
);
expect(css).toMatch(
/\.game-lineage-node__trunk \{[\s\S]{0,300}?--platform-warm-bg/u,
);
@@ -3537,10 +3643,10 @@ describe('GameLineagePage 可视化与交互', () => {
const wheel = (init: Record<string, unknown>) =>
fireEvent.wheel(canvas, init); // 返回 false 表示事件被 preventDefault
// 先等初始适配稳定(jsdom 下退到可读缩放下限 60%),否则会把「适配后的首次写入」误当成滚轮的效果。
// 先等初始适配稳定(jsdom 下退到可读缩放下限 80%),否则会把「适配后的首次写入」误当成滚轮的效果。
await waitFor(() =>
expect(document.querySelector('.game-lineage-zoom')?.textContent).toBe(
'60%',
'80%',
),
);
const start = read();
@@ -3755,10 +3861,10 @@ describe('GameLineagePage 可视化与交互', () => {
await cardOf('母版');
// 初次适配在 effect 里落地:先等它生效(jsdom 无布局尺寸 → min(scaleX, scaleY) 远小于 1,
// 因此命中可读下限 60%)。
// 因此命中可读下限 80%)。
await waitFor(() =>
expect(document.querySelector('.game-lineage-zoom')?.textContent).toBe(
'60%',
'80%',
),
);
expect(screen.getByText(/已按可读缩放显示/u)).toBeTruthy();
@@ -3895,12 +4001,12 @@ describe('GameLineagePage 可视化与交互', () => {
await cardOf('母版');
const zoomLabel = () =>
document.querySelector('.game-lineage-zoom')?.textContent ?? '';
// 初次适配是 effect 里跑的:先等它落定(jsdom 无布局尺寸 → 命中可读下限 60%)。
await waitFor(() => expect(zoomLabel()).toBe('60%'));
// 初次适配是 effect 里跑的:先等它落定(jsdom 无布局尺寸 → 命中可读下限 80%)。
await waitFor(() => expect(zoomLabel()).toBe('80%'));
fireEvent.click(screen.getByRole('button', { name: '放大' }));
expect(zoomLabel()).toBe('72%');
expect(zoomLabel()).toBe('96%');
fireEvent.click(screen.getByRole('button', { name: '缩小' }));
expect(zoomLabel()).toBe('60%');
expect(zoomLabel()).toBe('80%');
// 总览(强制全览)在 jsdom 下退到最小缩放下限。
fireEvent.click(screen.getByRole('button', { name: '总览' }));
expect(zoomLabel()).toBe('35%');
@@ -4343,6 +4449,74 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
expect(crumbs[crumbs.length - 1]?.textContent).toContain('深代作品');
});
it('视图形态是分段控件:当前形态与 aria-pressed 一一对应(不再「画面是画布、按钮却是大纲已按下」)', async () => {
getGameLineageMock.mockResolvedValueOnce(tree);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const canvasButton = await screen.findByRole('button', {
name: '图谱视图',
});
const outlineButton = screen.getByRole('button', { name: '大纲视图' });
// 桌面默认画布 → 「图谱视图」按下、「大纲视图」未按下,且两个选项常驻(不是一个翻转按钮)。
expect(document.querySelector('.game-lineage-canvas')).not.toBeNull();
expect(canvasButton.getAttribute('aria-pressed')).toBe('true');
expect(outlineButton.getAttribute('aria-pressed')).toBe('false');
expect(
document.querySelector('.game-lineage-filter__group')?.getAttribute(
'aria-label',
),
).toBe('视图形态');
fireEvent.click(outlineButton);
expect(await screen.findByLabelText('创作族谱大纲')).toBeTruthy();
expect(
screen.getByRole('button', { name: '大纲视图' }).getAttribute(
'aria-pressed',
),
).toBe('true');
expect(
screen.getByRole('button', { name: '图谱视图' }).getAttribute(
'aria-pressed',
),
).toBe('false');
});
it('画布控件栏:缩小 / 读数 / 放大 / 总览 / 刷新 五件齐全,且没有会被读成「搜索」的放大镜图标', async () => {
getGameLineageMock.mockResolvedValueOnce(tree);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const controls = await screen.findByLabelText('画布控件');
const names = [...controls.querySelectorAll('button')].map((button) =>
button.getAttribute('aria-label'),
);
expect(names).toEqual(['缩小', '放大', '总览', '刷新']);
expect(controls.querySelector('.game-lineage-zoom')?.textContent).toMatch(
/^\d+%$/u,
);
// 图标是「−/+」:放大镜(lucide ZoomIn/ZoomOut)在控件栏里会被读成「搜索」,
// 而这里根本没有搜索按钮 —— 用图标名钉住不许退回。
const source = readFileSync(
'src/components/game-distribution/GameLineagePage.tsx',
'utf8',
);
expect(source).toContain('<Minus aria-hidden="true" />');
expect(source).toContain('<Plus aria-hidden="true" />');
expect(source).not.toContain('ZoomIn');
expect(source).not.toContain('ZoomOut');
});
it('图谱视图开关:移动端可切到画布(形态 B)并仍能选中节点', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(tree);
@@ -4396,8 +4570,11 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
)) as HTMLElement;
const view = readCanvasViewport(canvas);
// 窄屏图谱视图是**横版**:代际沿 x 推进(同代同 left、兄弟靠 top 分开),
// 且不再渲染左侧代际轴(角标承担代际信息)。
// 且不再渲染左侧代际轴(角标承担代际信息 —— 因此卡上**保留**世代角标)。
expect(canvas.querySelector('.game-lineage-canvas__axis')).toBeNull();
expect(
canvas.querySelector('.game-lineage-node__generation')?.textContent,
).toBe('母版');
const cards = [...canvas.querySelectorAll('.game-lineage-node')].map(
(card) => card as HTMLElement,
);
@@ -4751,6 +4928,82 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
).toBeTruthy();
});
it('游客与共创者(同一份数据):游客看不到血缘图/族谱入口/共创标签,共创者完全保留', async () => {
const gameWithLineage = () =>
createGame({
id: 'game-current',
forkAuthorization: 'nonCommercial',
forkCount: 3,
themes: [{ themeId: 'theme-1', name: '精选共创', badge: '精选' }],
lineage: {
generation: 2,
rootGameId: 'game-root',
rootTitle: '根原创',
parentGameId: 'game-mid',
parentTitle: '原初之光',
parentAuthorName: '原作者',
},
});
const renderDetail = () =>
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
onOpenLineage={vi.fn()}
onOpenCompare={vi.fn()}
onOpenTheme={vi.fn()}
/>,
);
// ① 游客(未登录):血缘相关内容与共创标签一律不渲染,但公开数据与主题入口保留。
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
getGameMock.mockResolvedValueOnce(gameWithLineage());
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
const guest = renderDetail();
await screen.findByText('累计衍生作品 3');
expect(screen.queryByLabelText('创作血缘')).toBeNull();
expect(screen.queryByRole('button', { name: '全屏查看族谱' })).toBeNull();
expect(screen.queryByRole('button', { name: '查看创作族谱' })).toBeNull();
expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull();
expect(screen.queryByText('2 代三创')).toBeNull();
expect(screen.queryByText('允许非商用共创')).toBeNull();
expect(screen.queryByText(/改编自父代/u)).toBeNull();
expect(document.querySelector('.game-fork-lineage__author')).toBeNull();
// 公开数据与主题(浏览入口,不是血缘结构)仍在。
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
expect(screen.getByRole('button', { name: /精选共创/u })).toBeTruthy();
expect(
document
.querySelector('.game-detail-page')
?.getAttribute('data-viewer-role'),
).toBe('guest');
// 图谱页自己的「图下方父代/子代清单」也不会出现(整块不渲染)。
expect(document.querySelector('.lineage-relation__grid')).toBeNull();
guest.unmount();
// ② 共创者(已登录,默认 mock):与改动前一致 —— 图、入口、标签都在。
authUiMock.value.user = { id: 'author-1' };
authUiMock.value.canAccessProtectedData = true;
getGameMock.mockResolvedValueOnce(gameWithLineage());
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
renderDetail();
expect(await screen.findByLabelText('创作血缘')).toBeTruthy();
expect(screen.getByText('2 代三创')).toBeTruthy();
expect(screen.getByText('允许非商用共创')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy();
expect(screen.getByText('父代作者 原作者')).toBeTruthy();
expect(screen.getByRole('button', { name: '查看创作族谱' })).toBeTruthy();
expect(screen.getByRole('button', { name: '与原版对比' })).toBeTruthy();
expect(
document
.querySelector('.game-detail-page')
?.getAttribute('data-viewer-role'),
).toBe('co-creator');
});
it('「全屏查看族谱」= 真·全屏 overlay(portal 到 body / Esc 与关闭按钮 / 焦点与滚动恢复)', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
@@ -2534,6 +2534,15 @@
background: rgba(199, 101, 61, 0.12);
color: var(--platform-accent-strong);
}
/*
* 视图形态分段控件(图谱视图 / 大纲视图 两个常驻按钮):
* 挨在一起、间距收紧,读起来是「一组里选一个」,而不是两个互不相干的开关。
*/
.game-lineage-filter__group {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
/*
* 「父代 / 子代」角标:**可读文本**(不是只靠颜色深浅),所以可断言、读屏也能读到;
@@ -2732,6 +2741,35 @@
.game-fork-remix-link.co-creation-fork-primary:hover {
background: var(--platform-accent-strong, var(--platform-accent));
}
/*
* 游客(只玩游戏)的「开始共创」入口:**不占重点色**、缩成次要小按钮。
*
* 为什么:产品口径里普通用户是「玩游戏的」,共创是共创者(开发游戏的人)的主操作,
* 游客不该被一个实心强调的大按钮抢走注意力。但它**仍然可点**(点击走登录门禁),
* 所以这不是 disabled 样式 —— 用平台既有的次要按钮变量(描边 + 次要底色),
* 尺寸降到 32px 高 / 0.78rem(共创者那份主操作是 48px / 0.94rem),差异可量。
*/
.game-fork-remix-link.game-fork-remix-guest {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: center;
/* 与主操作行里的「收藏」(sm) 同量级,明显小于「立即玩」(lg)。 */
min-height: 2rem;
padding: 0 0.75rem;
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
background: var(--platform-button-secondary-fill);
color: var(--platform-button-secondary-text);
font: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
}
.game-fork-remix-link.game-fork-remix-guest:hover {
border-color: var(--platform-surface-hover-border);
background: var(--platform-surface-muted, rgba(0, 0, 0, 0.04));
}
/* 禁止共创:置灰但可点(点了给原因),与平台 disabled 观感一致。 */
.co-creation-fork-primary--blocked,
.game-fork-remix-link.co-creation-fork-primary--blocked {
@@ -2957,6 +2995,64 @@
object-fit: cover;
object-position: center;
}
/*
* 卡面角标槽:从「压在封面上」搬回**封面下方**的信息区第一行(截图反馈:母版 / 第 N 代 / 主干 /
* 当前作品 盖在封面图上,既遮挡又和卡内信息重复)。
*
* 为什么必须在这里覆盖:共享卡(广场卡)给角标槽的默认位置是卡内的绝对定位浮层
* (`top/left/right: .55rem`),那是为广场卡设计的;族谱卡沿用后角标又回到了封面上。
* 改成文档流里的静态行后,角标不可能再盖住封面,也不参与绝对定位。
* 组内尺寸/圆角/不换行仍按族谱自己的角标表(`.game-lineage-node__badges > *` 那套同值),
* 所以同一个语义在画布卡与大纲行里长得一样。
*/
.game-card--lineage .game-card__badges {
position: static;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
/* 与卡内信息区同侧内边距(卡身左右 padding 是 1rem,角标行跟标题左对齐即可)。 */
padding: 0.5rem 1rem 0;
}
.game-card--lineage .game-card__badges > * {
position: static;
margin: 0;
border-radius: 999px;
padding: 0.05rem 0.4rem;
font-size: 0.6rem;
line-height: 1.6;
white-space: nowrap;
}
/*
* 族谱卡是**窄卡**(240 / 176 宽):标题独占一行,游玩数落到标题下方 ——
* 截图反馈「标题、游玩次数、作者挤在一起」,挤是因为三者抢同一行宽。
* 标题拿到整行后 2 行截断的触发也更少,卡片反而更矮。
*/
.game-card--lineage .game-card__title-row {
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
}
/* 游玩数与作者是同一级的次要信息,挨近一点读成一组(共享卡默认 0.85rem 是为广场宽卡留的)。 */
.game-card--lineage .game-card__meta {
margin-top: 0.25rem;
}
/* 角标行已经在封面与标题之间,信息区顶部就不用再留共享卡那份 0.95rem(会变成双重留白)。 */
.game-card--lineage .game-card__body {
padding-top: 0.55rem;
}
/*
* 窄屏紧凑卡(176 宽节点 → 卡内 148):角标行与角标都再收紧一点,
* 让「第 N 代 + 主干 + 子代」三枚角标留在**一行**里(真机实测:1rem 行内边距 + 0.4rem 角标内边距时
* 三枚共 127.2px > 可用 114.4px,会折成两行、把卡片顶高 23px,层高估算就不准了)。
*/
.game-lineage-node--compact .game-card--lineage .game-card__badges {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.game-lineage-node--compact .game-card--lineage .game-card__badges > * {
padding: 0.05rem 0.3rem;
}
/* 图下方「父代 / 子代」清单:与广场一致的卡片网格。 */
.lineage-relation__grid {
display: grid;
@@ -2973,6 +3069,7 @@
.lineage-relation__item > .game-card--lineage {
width: 100%;
}
/*
共创页卡片的「共创 N」次数角标(2026-10-07)。
位置由共享卡自己的 `.game-card__badges` 槽决定(绝对定位在封面上沿,见
@@ -0,0 +1,18 @@
/**
* 观看者角色(2026-10-07 产品口径):**普通用户 = 游客**(主要是玩游戏的),
* **共创者 = 开发游戏的**;两者看到的**游戏详情页**有细微差异(见 `GameDetailPage`)。
*
* ⚠️ **待产品确认的口径**:仓库现有信号里没有能表达「共创者」的那一位 ——
* - `capabilities`(`src/services/hostBridge.ts`)是 **AGC 宿主桥**的能力清单(剪贴板 / 分享 / 导航),
* 与「这个人在平台上开不开发游戏」无关;
* - `forkAuthorization` 是**作品级**的共创授权(禁止 / 非商用 / 全开放),是作品的属性而不是观看者的;
* - 钱包 / 会员档位(`usePlatformWalletStore`)与「是否共创者」也没有既有的推导关系;
* - 后端 DTO 里没有任何「账号能力位」字段(没有 `isCreator` / `creatorLevel` 之类)。
*
* 所以这里**临时**用登录态近似:**未登录 = 游客,已登录 = 共创者**。
* 不新增后端字段、不自己造权限系统:产品给出真正的判据(能力位 / 是否绑定客户端 / 是否发行过作品…)后,
* **只需要改这一个函数**——详情页里所有「游客 / 共创者」差异都收敛在这一处判断上。
*/
export function isCoCreator(isAuthenticated: boolean): boolean {
return isAuthenticated;
}
@@ -1453,6 +1453,14 @@ describe('创作族谱', () => {
});
it('详情页的族谱入口带上根作品与当前作品', async () => {
// 族谱入口只给**共创者**(2026-10-07 角色口径:游客只玩游戏,看不到血缘入口),
// 所以这条用例先把观看者设成已登录(`isCoCreator` 当前口径 = 登录态)。
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试用户',
} as AuthUser;
authUiMock.value.canAccessProtectedData = true;
window.history.replaceState(null, '', '/games/detail?id=game-1');
gameDistributionMock.getGame.mockResolvedValue({
id: 'game-1',