@@ -909,23 +923,31 @@ function GameDetailContent({
label: '共创',
value: (
-
- {/* 世代标签(文档口径:0 代原创 / 1 代二创 / 2 代三创 / N 代迭代)。 */}
-
- {coCreationGenerationTagLabel(lineage?.generation ?? 0)}
+ {/*
+ 游客(只玩游戏)不显示**共创标签**:世代(第 N 代 / 母版)与授权档位
+ (允许共创 / 非商用 / 禁止共创)都是「共创者视角」的档位标签。
+ 共创者与改动前完全一致。
+ */}
+ {coCreator ? (
+
+ {/* 世代标签(文档口径:0 代原创 / 1 代二创 / 2 代三创 / N 代迭代)。 */}
+
+ {coCreationGenerationTagLabel(lineage?.generation ?? 0)}
+
+ {/* 授权类型标签:沿用平台既有 constants(含「允许」前缀),不另造一套文案。 */}
+
+ {
+ GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
+ forkAuthorization
+ ]
+ }
+
- {/* 授权类型标签:沿用平台既有 constants(含「允许」前缀),不另造一套文案。 */}
-
- {
- GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
- forkAuthorization
- ]
- }
-
-
- {lineage ? (
+ ) : null}
+ {/* 血缘关系文字行(改编自父代《…》/ 父代作者)同样只给共创者看。 */}
+ {coCreator && lineage ? (
<>
{/*
父作品被软删除时服务端不再回传它的标题与作者名(见
@@ -971,7 +993,8 @@ function GameDetailContent({
)}
- {onOpenLineage && publiclyVisible ? (
+ {/* 进入血缘视图的入口(族谱 / 与原版对比)也只给共创者。 */}
+ {coCreator && onOpenLineage && publiclyVisible ? (
/*
族谱入口放在共创信息卡里:它属于「这部作品的改编关系」这件事,
点进去是以母版为顶的整棵族谱树;`from` 带上当前作品供页面高亮。
@@ -987,7 +1010,10 @@ function GameDetailContent({
查看创作族谱
) : 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 ? (
<>
{isAuthenticated && isForkPanelOpen
? createPortal(
diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx
index eb3da1789..5524b33f1 100644
--- a/src/components/game-distribution/GameDistributionPages.test.tsx
+++ b/src/components/game-distribution/GameDistributionPages.test.tsx
@@ -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(
+
,
+ );
+ 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
) =>
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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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('');
+ expect(source).toContain('');
+ 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(
+ ,
+ );
+
+ // ① 游客(未登录):血缘相关内容与共创标签一律不渲染,但公开数据与主题入口保留。
+ 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({
diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css
index 3f9639fe4..cc7fd2010 100644
--- a/src/components/game-distribution/gameDistribution.css
+++ b/src/components/game-distribution/gameDistribution.css
@@ -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` 槽决定(绝对定位在封面上沿,见
diff --git a/src/components/game-distribution/gameViewerRole.ts b/src/components/game-distribution/gameViewerRole.ts
new file mode 100644
index 000000000..5fe428de6
--- /dev/null
+++ b/src/components/game-distribution/gameViewerRole.ts
@@ -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;
+}
diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
index 1c57d90f7..8a68b5da4 100644
--- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
+++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
@@ -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',