首屏「立即试玩」改为滚动到列表而不是打开第一款游戏

- GameGalleryPage 的 hero 按钮不再取 visibleGames[0] 直接开详情,改为把游戏列表滚进视野
- 滚动按平台页签面板(.platform-tab-panel)算相对偏移并走 scrollTo({behavior:'smooth'}),缺少 scrollTo 时退化成直接赋值
- 新增 gameListRef 锚在列表工具栏,退役只服务该按钮的 featuredGame 派生
- GameDistributionPages.test.tsx 新增「首屏「立即试玩」滚动到列表,而不是打开第一款游戏详情」,断言未触发 onOpenDetail 且滚动落在页签面板上
- 里程碑文档新增「网页侧交互修正(2026-09-30)」记录该修正与验证命令
This commit is contained in:
2026-09-30 11:44:05 +08:00
parent 54e534b101
commit 055a6d6658
3 changed files with 77 additions and 3 deletions
@@ -320,3 +320,8 @@
| 同上路径但带 `Cookie` | **403 Forbidden** + `application/json`(`x-api-version: 2026-06-16`、`x-response-time-ms`)——发行读取**拒绝携带账号凭证**的请求,与本地用例断言一致 |
**生产环境**(`genarrative.world`)同一时刻 `/api/game-distribution/games` 返回 **0 条**:说明当前生产还没有任何已发布的游戏,条目 ④「真实环境整链路」需要在生产发布一个作品后再跑。也就是说此刻能拿到的真实环境证据是「dev 部署的公开读路径 + 发行隔离」,写路径与审核链路的真实环境验收仍需生产账号。
## 网页侧交互修正(2026-09-30)
- **广场首屏「立即试玩」不再替用户选中作品**:`src/components/game-distribution/GameGalleryPage.tsx` 原来是 `onClick={() => featuredGame && openDetail(featuredGame.id)}`,点一下等于直接打开第一款游戏的详情。现在按钮只把列表滚进视野(按平台页签面板计算相对偏移,`scrollTo({ behavior: 'smooth' })`,无 `scrollTo` 时退化成直接赋值),不再触发 `onOpenDetail`;`featuredGame` 派生随之退役。回归用例 `GameDistributionPages.test.tsx`「首屏「立即试玩」滚动到列表,而不是打开第一款游戏详情」断言 `onOpenDetail` 未被调用且滚动落在页签面板上。
- 验证:`npx vitest run src/components/game-distribution/GameDistributionPages.test.tsx`(21 passed)、`npm run typecheck`、`npx eslint`、`npm run check:encoding`、`git diff --check` 通过。视觉与真机走查仍需人工/截图评审(沿用阶段 C 第 8 条未取证口径)。
@@ -375,6 +375,50 @@ describe('GameGalleryPage 筛选与返回上下文', () => {
).toBe('180'),
);
});
it('首屏「立即试玩」滚动到列表,而不是打开第一款游戏详情', async () => {
resetGalleryContext();
const onOpenDetail = vi.fn();
listGamesMock.mockResolvedValue([
createGame({ id: 'game-1', title: '星轨防线' }),
createGame({ id: 'game-2', title: '第二款' }),
]);
const scrollTo = vi.fn();
const originalScrollTo = Object.getOwnPropertyDescriptor(
Element.prototype,
'scrollTo',
);
Object.defineProperty(Element.prototype, 'scrollTo', {
configurable: true,
writable: true,
value: scrollTo,
});
try {
const { container } = render(
<div className="platform-tab-panel">
<GameGalleryPage onOpenDetail={onOpenDetail} />
</div>,
);
expect(await screen.findByText('星轨防线')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /立即试玩/u }));
expect(onOpenDetail).not.toHaveBeenCalled();
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(scrollTo.mock.instances[0]).toBe(
container.querySelector('.platform-tab-panel'),
);
expect(scrollTo.mock.calls[0]?.[0]).toMatchObject({
behavior: 'smooth',
});
} finally {
if (originalScrollTo) {
Object.defineProperty(Element.prototype, 'scrollTo', originalScrollTo);
} else {
delete (Element.prototype as { scrollTo?: unknown }).scrollTo;
}
}
});
});
describe('GamePlayPage 启动超时与旋转提示', () => {
@@ -145,6 +145,7 @@ export function GameGalleryPage({
const [error, setError] = useState('');
const requestSerialRef = useRef(0);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const gameListRef = useRef<HTMLDivElement | null>(null);
const hasRestoredScrollRef = useRef(false);
/**
* 顶栏搜索词只在「真的变了」时覆盖目录筛选。
@@ -167,6 +168,31 @@ export function GameGalleryPage({
container instanceof HTMLElement ? container : null;
}, []);
/**
* 首屏「立即试玩」只把列表滚进视野,不替用户选定某一款游戏。
*
* 滚动容器是平台页签面板而不是窗口,所以按容器相对位置计算,缺少
* `scrollTo` 时退化成直接赋值,保证无动画环境也能生效。
*/
const scrollToGameList = useCallback(() => {
const anchor = gameListRef.current;
if (!anchor) return;
const container = scrollContainerRef.current;
if (!container) {
anchor.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
return;
}
const nextScrollTop =
container.scrollTop +
anchor.getBoundingClientRect().top -
container.getBoundingClientRect().top;
if (typeof container.scrollTo === 'function') {
container.scrollTo({ top: nextScrollTop, behavior: 'smooth' });
return;
}
container.scrollTop = nextScrollTop;
}, []);
const loadGames = useCallback(() => {
const requestSerial = ++requestSerialRef.current;
setIsLoading(true);
@@ -231,7 +257,6 @@ export function GameGalleryPage({
() => games.filter((game) => matchesGameDeviceFilter(game, device)),
[device, games],
);
const featuredGame = useMemo(() => visibleGames[0] ?? null, [visibleGames]);
const hasActiveFilters =
activeKeyword.trim().length > 0 || category !== '全部' || device !== '全部';
const resetFilters = () => {
@@ -270,7 +295,7 @@ export function GameGalleryPage({
<PlatformActionButton
size="md"
shape="pill"
onClick={() => featuredGame && openDetail(featuredGame.id)}
onClick={scrollToGameList}
>
<Gamepad2 aria-hidden="true" /> 立即试玩
</PlatformActionButton>
@@ -282,7 +307,7 @@ export function GameGalleryPage({
</div>
</section>
<div className="game-toolbar">
<div className="game-toolbar" ref={gameListRef}>
<div className="game-toolbar__title">
<span className="game-eyebrow">发现好玩的</span>
<h2>游戏广场</h2>