详情页返回按来源回到上一页并改用浏览器原生返回

- GameDetailPage 左上角「返回游戏广场」简化为「返回」,不再写死跳广场
- 详情/游玩页返回优先 window.history.back(),由 popstate 还原真实来源页(我的游戏/广场)
- activeAppPageRoutes 给应用历史条目补深度标记并新增 hasAppHistoryBackEntry()
- 深链直开等无可回退条目时用 replaceAppHistoryPath 兜底回广场或详情
- 新增 activeAppPageRoutes 深度语义用例与 PlatformEntryActiveFlowShell「游戏详情返回」两条用例
- 同步里程碑文档 2026-09-30 与 pitfalls 同日条目
This commit is contained in:
2026-09-30 13:05:55 +08:00
parent 0d3937b7eb
commit e976a69f9f
7 changed files with 155 additions and 12 deletions
@@ -193,7 +193,7 @@ export function GameDetailPage({
return (
<div className="game-detail-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回游戏广场
<ArrowLeft /> 返回
</button>
<PlatformStatusMessage
tone="error"
@@ -214,7 +214,7 @@ export function GameDetailPage({
return (
<div className="game-detail-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回游戏广场
<ArrowLeft /> 返回
</button>
<section className="game-detail-hero">
<GameShowcase game={game} />
@@ -12,6 +12,7 @@ import { type ReactElement, type ReactNode, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import { pushAppHistoryPath } from '../../routing/activeAppPageRoutes';
import {
usePlatformWalletLifecycle,
usePlatformWalletStore,
@@ -938,3 +939,76 @@ describe('游戏发布灰度入口', () => {
).not.toBeNull();
});
});
describe('游戏详情返回', () => {
function mockDetailGame() {
gameDistributionMock.getGame.mockResolvedValue({
id: 'game-1',
title: '星轨防线',
summary: '守住轨道城',
description: '一款测试游戏。',
category: '动作',
tags: ['塔防'],
coverColor: '#d77a51',
icon: '✦',
author: { id: 'author-1', name: '测试作者' },
deviceSupport: { desktop: true, mobile: true, touch: true },
status: 'published',
currentVersion: {
id: 'version-1',
version: '1.0.0',
entryUrl: 'https://play.example.test/releases/version-1/index.html',
sha256: 'sha256',
publishedAt: '2026-09-18T00:00:00.000Z',
controls: ['键盘'],
},
playCount: 12,
createdAt: '2026-09-18T00:00:00.000Z',
});
}
async function clickDetailBack() {
fireEvent.click(await screen.findByRole('button', { name: '返回' }));
}
it('返回按钮不再写死广场文案与目标,改走浏览器原生返回', async () => {
mockDetailGame();
window.history.replaceState(null, '', '/games/mine');
pushAppHistoryPath('/games/detail?id=game-1');
const backSpy = vi
.spyOn(window.history, 'back')
.mockImplementation(() => {});
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="game-detail"
setSelectionStage={setSelectionStage}
/>,
);
await clickDetailBack();
expect(backSpy).toHaveBeenCalledTimes(1);
expect(setSelectionStage).not.toHaveBeenCalled();
backSpy.mockRestore();
});
it('直接打开详情链接时返回兜底回游戏广场', async () => {
mockDetailGame();
window.history.replaceState(null, '', '/games/detail?id=game-1');
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="game-detail"
setSelectionStage={setSelectionStage}
/>,
);
await clickDetailBack();
expect(setSelectionStage).toHaveBeenCalledWith('games', {
path: '/games',
});
expect(window.location.pathname).toBe('/games');
});
});
@@ -22,6 +22,7 @@ import { PlatformProfileRechargeModal } from '../../../packages/shared/src/compo
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
import {
hasAppHistoryBackEntry,
pushAppHistoryPath,
replaceAppHistoryPath,
} from '../../routing/activeAppPageRoutes';
@@ -457,6 +458,33 @@ export function PlatformEntryFlowShellImpl({
[setSelectionStage],
);
/**
* 详情页可能从广场或「我的游戏」进入,返回优先走浏览器原生返回,这样能回到
* 用户真正的来源页;直接打开详情链接等没有可回退的应用内历史时再兜底回广场。
*/
const backFromGameDetail = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
replaceAppHistoryPath('/games');
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
/** 游玩页同理,兜底是它自己的详情页,避免详情↔游玩互相塞历史条目。 */
const backFromGamePlay = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
const currentGameId = new URLSearchParams(window.location.search).get('id');
const path = currentGameId
? `/games/detail?id=${encodeURIComponent(currentGameId)}`
: '/games';
replaceAppHistoryPath(path);
setSelectionStage(currentGameId ? 'game-detail' : 'games', { path });
}, [setSelectionStage]);
const openEditorProject = useCallback(
(projectId: string, options?: { guide?: boolean; tool?: string }) => {
if (!isDesktopLayout) {
@@ -786,16 +814,11 @@ export function PlatformEntryFlowShellImpl({
) : selectionStage === 'game-detail' ? (
<GameDetailPage
gameId={gameId}
onBack={openGames}
onBack={backFromGameDetail}
onPlay={openGamePlay}
/>
) : (
<GamePlayPage
gameId={gameId}
onBack={() =>
gameId ? openGameDetail(gameId) : openGames()
}
/>
<GamePlayPage gameId={gameId} onBack={backFromGamePlay} />
)}
</Suspense>
) : (