diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md
index 11051faee..d533dfc83 100644
--- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md
+++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md
@@ -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 条未取证口径)。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index 29c47e11a..832c9f958 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -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`。
diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx
index 243c052e9..d66abbcb7 100644
--- a/src/components/game-distribution/GameDetailPage.tsx
+++ b/src/components/game-distribution/GameDetailPage.tsx
@@ -193,7 +193,7 @@ export function GameDetailPage({
return (
diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
index 68cec511c..aff31a85a 100644
--- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
+++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
@@ -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(
+ ,
+ );
+ 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(
+ ,
+ );
+ await clickDetailBack();
+
+ expect(setSelectionStage).toHaveBeenCalledWith('games', {
+ path: '/games',
+ });
+ expect(window.location.pathname).toBe('/games');
+ });
+});
diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
index 6a4f6ba7d..83b762b25 100644
--- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
+++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
@@ -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' ? (
) : (
-
- gameId ? openGameDetail(gameId) : openGames()
- }
- />
+
)}
) : (
diff --git a/src/routing/activeAppPageRoutes.test.ts b/src/routing/activeAppPageRoutes.test.ts
index d4512bd42..11e141e2a 100644
--- a/src/routing/activeAppPageRoutes.test.ts
+++ b/src/routing/activeAppPageRoutes.test.ts
@@ -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);
+ });
});
diff --git a/src/routing/activeAppPageRoutes.ts b/src/routing/activeAppPageRoutes.ts
index edba4f6b2..5da7357e4 100644
--- a/src/routing/activeAppPageRoutes.ts
+++ b/src/routing/activeAppPageRoutes.ts
@@ -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) > 0;
+}
+
+function readAppHistoryDepth(state: Record) {
+ 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,
+ );
return {
...currentState,
[APP_HISTORY_STATE_KEY]: true,
+ [APP_HISTORY_STATE_DEPTH_KEY]:
+ mode === 'push' ? currentDepth + 1 : currentDepth,
};
}