详情页返回按来源回到上一页并改用浏览器原生返回
- GameDetailPage 左上角「返回游戏广场」简化为「返回」,不再写死跳广场 - 详情/游玩页返回优先 window.history.back(),由 popstate 还原真实来源页(我的游戏/广场) - activeAppPageRoutes 给应用历史条目补深度标记并新增 hasAppHistoryBackEntry() - 深链直开等无可回退条目时用 replaceAppHistoryPath 兜底回广场或详情 - 新增 activeAppPageRoutes 深度语义用例与 PlatformEntryActiveFlowShell「游戏详情返回」两条用例 - 同步里程碑文档 2026-09-30 与 pitfalls 同日条目
This commit is contained in:
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
hasAppHistoryBackEntry,
|
||||
isAppHistoryState,
|
||||
isKnownMainAppPagePath,
|
||||
pushAppHistoryPath,
|
||||
@@ -75,4 +76,16 @@ describe('appPageRoutes', () => {
|
||||
expect(window.location.pathname).toBe('/project');
|
||||
expect(window.location.search).toContain('hostShell=tauri_desktop');
|
||||
});
|
||||
|
||||
it('只有应用内推送过上一页时才算可原生返回', () => {
|
||||
window.history.replaceState(null, '', '/games/detail?id=game-1');
|
||||
expect(hasAppHistoryBackEntry()).toBe(false);
|
||||
|
||||
window.history.replaceState(null, '', '/games/mine');
|
||||
pushAppHistoryPath('/games/detail?id=game-1');
|
||||
expect(hasAppHistoryBackEntry()).toBe(true);
|
||||
|
||||
replaceAppHistoryPath('/games/detail?id=game-2');
|
||||
expect(hasAppHistoryBackEntry()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ const ROUTE_STAGE_BY_PATH = new Map(
|
||||
const APP_RUNTIME_CONTEXT_QUERY_KEYS =
|
||||
HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS;
|
||||
export const APP_HISTORY_STATE_KEY = '__genarrativeAppHistoryEntry';
|
||||
const APP_HISTORY_STATE_DEPTH_KEY = '__genarrativeAppHistoryDepth';
|
||||
|
||||
export function normalizeAppPath(pathname: string) {
|
||||
const trimmedPathname = pathname.trim().toLowerCase();
|
||||
@@ -92,7 +93,7 @@ function writeAppHistoryPath(path: string, mode: 'push' | 'replace') {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = buildAppHistoryState();
|
||||
const state = buildAppHistoryState(mode);
|
||||
if (mode === 'replace') {
|
||||
window.history.replaceState(state, '', nextRelativeUrl);
|
||||
return;
|
||||
@@ -108,14 +109,37 @@ export function isAppHistoryState(state: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildAppHistoryState() {
|
||||
/**
|
||||
* 当前历史条目是不是应用内导航写入的、且存在可回退的上一页。
|
||||
* 直接打开深链时浏览器条目不是应用写入的,此时返回需要走兜底路径,
|
||||
* 否则 `history.back()` 会直接退出应用。
|
||||
*/
|
||||
export function hasAppHistoryBackEntry() {
|
||||
const state = window.history.state;
|
||||
if (!isAppHistoryState(state)) {
|
||||
return false;
|
||||
}
|
||||
return readAppHistoryDepth(state as Record<string, unknown>) > 0;
|
||||
}
|
||||
|
||||
function readAppHistoryDepth(state: Record<string, unknown>) {
|
||||
const depth = state[APP_HISTORY_STATE_DEPTH_KEY];
|
||||
return typeof depth === 'number' && Number.isFinite(depth) ? depth : 0;
|
||||
}
|
||||
|
||||
function buildAppHistoryState(mode: 'push' | 'replace') {
|
||||
const currentState =
|
||||
window.history.state && typeof window.history.state === 'object'
|
||||
? window.history.state
|
||||
: {};
|
||||
const currentDepth = readAppHistoryDepth(
|
||||
currentState as Record<string, unknown>,
|
||||
);
|
||||
return {
|
||||
...currentState,
|
||||
[APP_HISTORY_STATE_KEY]: true,
|
||||
[APP_HISTORY_STATE_DEPTH_KEY]:
|
||||
mode === 'push' ? currentDepth + 1 : currentDepth,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user