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, }; }