详情页返回按来源回到上一页并改用浏览器原生返回
- GameDetailPage 左上角「返回游戏广场」简化为「返回」,不再写死跳广场 - 详情/游玩页返回优先 window.history.back(),由 popstate 还原真实来源页(我的游戏/广场) - activeAppPageRoutes 给应用历史条目补深度标记并新增 hasAppHistoryBackEntry() - 深链直开等无可回退条目时用 replaceAppHistoryPath 兜底回广场或详情 - 新增 activeAppPageRoutes 深度语义用例与 PlatformEntryActiveFlowShell「游戏详情返回」两条用例 - 同步里程碑文档 2026-09-30 与 pitfalls 同日条目
This commit is contained in:
@@ -329,4 +329,5 @@
|
||||
- **页签面板左右留白对齐**:`.platform-tab-panel` 原本写死 `padding-right`(并在移动端与 workbench 两处再压成 0),而它是不分层规则、优先级高于 Tailwind 的 `padding-inline`,导致面板左 12/24px、右 0,列表左右留白 28/16。三处 `padding-right` 删除后左右统一由使用处的 `px-3`/`sm:px-6` 决定:390 下 12/12(列表 28/28)、820 下 24/24(列表 44/44),无 px 类的舞台仍是 0/0;Chromium 夹具实测「我的游戏」与画廊两张页面同时恢复对称,且横向溢出保持 0。
|
||||
- **广场的「发布游戏」「我的游戏」上移到 hero 右上角**:两个入口原本混在 hero 下方的 `.game-toolbar` 动作区(和搜索框同排),现在提到 `.game-page-actions` 一行、右对齐放在 hero 上方,并留 1.1rem 底部间距;工具栏只保留标题与搜索。未登录或灰度未命中时两者都不渲染,该行随之消失。Chromium 夹具实测 390/360/820 三档都是 `aboveHero=true`、与 hero 间距 18px、右边缘与 hero 对齐。
|
||||
- 真机口径的守卫补在现役 E2E 里:`scripts/check-game-distribution-web-e2e.mjs` 的移动端段新增「移动端游玩页不可滚动(面板与整页都不溢出)」,断言 `.platform-tab-panel` 的 `overflow-y` 为 `hidden` 且面板与整页都不溢出(该脚本需真实本地栈,未在本轮执行)。
|
||||
- 验证:`npx vitest run src/components/game-distribution/GameDistributionPages.test.tsx`(21 passed)、`npx vitest run src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx`(20 passed)、`npm run typecheck`、`npx eslint`(四个改动文件)、`npm run check:encoding`、`git diff --check` 全部通过;启动面板的溢出可达性、「我的游戏」移动端横向溢出都用真实 Chromium 静态夹具量过(见 pitfalls 同日条目)。视觉与真机横竖屏走查仍需人工/截图评审(沿用阶段 C 第 8 条未取证口径)。
|
||||
- **详情页返回按来源回到上一页**:`GameDetailPage` 左上角原来是写死的「返回游戏广场」,并且直接 `setSelectionStage('games')`(等于 push 一条新的 `/games`)。从「我的游戏」点进详情再返回就落到广场,从广场进详情返回也会多压一条重复历史。现在文案简化为「返回」,行为改成优先 `window.history.back()`,由 `ActiveApp` 已有的 `popstate` 同步把舞台还原成真正的来源页(我的游戏 / 广场);游玩页的「返回详情」同样处理,避免详情↔游玩互相 push。协议侧给应用写入的历史条目补了 `__genarrativeAppHistoryDepth`,新增 `hasAppHistoryBackEntry()` 判断「当前条目是应用内导航写入的且存在上一页」;直接打开详情深链、或原生壳里没有可回退条目时,才 `replaceAppHistoryPath` 兜底到广场(游玩页兜底到自己的详情)。回归用例 `activeAppPageRoutes.test.ts` 锁定深度标记语义,`PlatformEntryActiveFlowShell.test.tsx`「游戏详情返回」两条锁定原生返回与深链兜底。
|
||||
- 验证:`npx vitest run src/components/game-distribution/GameDistributionPages.test.tsx`(21 passed)、`npx vitest run src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx`(22 passed)、`npx vitest run src/routing/activeAppPageRoutes.test.ts`、`npm run typecheck`、`npx eslint`(五个改动文件)、`npm run check:encoding`、`git diff --check` 全部通过;启动面板的溢出可达性、「我的游戏」移动端横向溢出都用真实 Chromium 静态夹具量过(见 pitfalls 同日条目),详情返回链在真实 Chromium(dev 栈 390x844)实测 `/games`→详情→返回=`/games`、深链直开详情→返回=`/games`、详情→立即玩→返回详情=`/games/detail?id=…`。视觉与真机横竖屏走查仍需人工/截图评审(沿用阶段 C 第 8 条未取证口径)。
|
||||
|
||||
@@ -6117,3 +6117,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
||||
- **同批修掉左右留白不对称**:面板左留白 12/24px(Tailwind `px-3`/`sm:px-6`),右留白却是 0,列表左右留白因此是 28/16。根因是 `src/index.css` 里 `.platform-tab-panel` 写死了 `padding-right: 0.25rem`——它属于**不分层(unlayered)规则,优先级高于 Tailwind 放进 `@layer utilities` 的 `padding-inline`**,把右内边距钉死在 0;`@media (max-width: 639px) .platform-tab-panel` 与 `.platform-desktop-shell--workbench .platform-tab-panel` 又各写了一次 `padding-right: 0`。三处声明全部删掉,左右统一交给使用处的 `padding-inline`;没有 px 类的舞台(创作主页 / 我的 / 游玩页)维持 0/0。改后实测 390 下 12/12、820 下 24/24,列表左右留白 28/28 与 44/44。
|
||||
- **判据**:真实 Chromium 静态夹具 `tmp/mygames-check/`(`tmp/` 已 gitignore,由 dev 栈 `http://127.0.0.1:10000/tmp/mygames-check/` 托管)同时渲染「我的游戏」和画廊两组卡片,量面板横向溢出与网格轨道宽度:改前 390/360 下轨道 591.891px、卡片 592px、面板横向可滚 230/260px;改后轨道 = 列表 = 卡片 = 可视宽、溢出 0。布局问题 jsdom 量不出来,这类回归目前靠这个夹具手工复核。
|
||||
- **关联**:`src/components/game-distribution/gameDistribution.css`、`src/components/game-distribution/MyGamesPage.tsx`、`.game-grid`/`.game-card`。
|
||||
|
||||
## 2026-09-30 SPA 的「返回」写死目标页,会丢掉用户真实来源
|
||||
|
||||
- **现象**:游戏详情页左上角写死「返回游戏广场」并把舞台设成 `games`。从「我的游戏」点进详情,返回后落在广场而不是我的游戏;从广场进详情,返回还会 push 一条重复的 `/games`,此时浏览器原生后退反而回到刚离开的详情页。
|
||||
- **根因**:`setSelectionStage(stage, { path })` 一律走 `pushAppHistoryPath`,而返回按钮复用它,语义变成「前进到广场」;来源页信息从未被记录,返回只能靠猜。
|
||||
- **处理**:`activeAppPageRoutes` 给应用写入的历史条目补内部深度标记(`push` 时 +1、`replace` 保持),新增 `hasAppHistoryBackEntry()`;详情/游玩页的返回改为「有应用内历史就 `window.history.back()`(`popstate` 已由 `ActiveApp` 同步舞台),否则 `replaceAppHistoryPath` 兜底到广场/详情」。深度标记而不是只看「有没有 state」是必要的:直接打开深链、或原生壳通过 host bridge 补写首条目时,`history.back()` 会直接退出应用。
|
||||
- **判据**:真实 Chromium(dev 栈 390x844)实测 `/games` → 详情 → 返回 = `/games`;深链直开 `/games/detail?id=…` → 返回 = `/games`;详情 → 立即玩 → 返回详情 = `/games/detail?id=…`。jsdom 侧 `activeAppPageRoutes.test.ts` 锁深度标记语义,`PlatformEntryActiveFlowShell.test.tsx`「游戏详情返回」两条锁原生返回与深链兜底。
|
||||
- **关联**:`src/routing/activeAppPageRoutes.ts`、`src/ActiveApp.tsx`、`src/components/platform-entry/PlatformEntryActiveFlowShell.tsx`、`src/components/game-distribution/GameDetailPage.tsx`。
|
||||
|
||||
@@ -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