详情页按观看者角色分游客/共创者:游客不渲染血缘图与族谱入口、不显示共创标签,「开始共创」降为次要小按钮(isCoCreator 谓词驱动)
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user