diff --git a/src/pages/explain/list.vue b/src/pages/explain/list.vue index e220cab..7397ee8 100644 --- a/src/pages/explain/list.vue +++ b/src/pages/explain/list.vue @@ -38,7 +38,10 @@ import { navigateToGuideTopTab, type GuideTopTab } from '@/utils/guideTopTabs' -import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment' +import { + isEmbeddedInWechatMiniProgram, + navigateBackToWechatMiniProgram +} from '@/utils/hostEnvironment' import { syncHostNavigationTitle } from '@/utils/hostNavigationTitle' import { explainGuideStopListUrl } from '@/utils/explainNavigation' @@ -71,8 +74,6 @@ const buildExplainHallItems = ( const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram()) -const GUIDE_HOME_URL = '/pages/index/index?tab=guide' - type ExplainListHistoryState = { museumGuidePage?: 'explain-list' [key: string]: unknown @@ -80,7 +81,7 @@ type ExplainListHistoryState = { let explainListHistoryPushed = false let isConsumingExplainListHistory = false -let isReturningToGuideHome = false +let isReturningFromExplainList = false type PageStackEntry = { route?: string @@ -93,9 +94,8 @@ const getPageStack = () => ( typeof getCurrentPages === 'function' ? getCurrentPages() as PageStackEntry[] : [] ) -const guideHomeUrl = () => ( - shouldUseHostNavigation.value ? guideTopTabUrl('guide') : GUIDE_HOME_URL -) +const guideHomeUrl = () => guideTopTabUrl('guide') +const explainHomeUrl = () => guideTopTabUrl('explain') const isExplainListHistoryState = (state: unknown): state is ExplainListHistoryState => ( Boolean( @@ -106,7 +106,7 @@ const isExplainListHistoryState = (state: unknown): state is ExplainListHistoryS ) const showGuideHomeReturnError = () => { - isReturningToGuideHome = false + isReturningFromExplainList = false uni.showToast({ title: '返回导览首页失败,请重试', icon: 'none' @@ -120,6 +120,13 @@ const reLaunchGuideHome = () => { }) } +const reLaunchExplainHome = () => { + uni.reLaunch({ + url: explainHomeUrl(), + fail: showGuideHomeReturnError + }) +} + const consumeExplainListHistory = () => { if ( typeof window === 'undefined' @@ -133,31 +140,55 @@ const consumeExplainListHistory = () => { return true } -const returnToGuideHome = () => { - if (isReturningToGuideHome || isConsumingExplainListHistory) return - if (consumeExplainListHistory()) return - - isReturningToGuideHome = true +const getPreviousPage = () => { const pages = getPageStack() - const previousPage = pages.length > 1 ? pages[pages.length - 2] : null - const previousRoute = previousPage?.route || '' + return pages.length > 1 ? pages[pages.length - 2] : null +} + +const cameFromProjectExplainHome = () => { + const previousPage = getPreviousPage() const previousTab = Array.isArray(previousPage?.options?.tab) ? previousPage.options.tab[0] : previousPage?.options?.tab - const canNavigateBackToGuideHome = ( - !shouldUseHostNavigation.value - && previousRoute === 'pages/index/index' - && (!previousTab || previousTab === 'guide') - ) - if (canNavigateBackToGuideHome) { - uni.navigateBack({ - delta: 1, - fail: reLaunchGuideHome - }) + return previousPage?.route === 'pages/index/index' && previousTab === 'explain' +} + +const returnToProjectExplainHome = () => { + isReturningFromExplainList = true + uni.navigateBack({ + delta: 1, + fail: reLaunchExplainHome + }) +} + +const returnFromStandaloneMiniProgramEntry = () => { + isReturningFromExplainList = true + if (navigateBackToWechatMiniProgram()) return + + isReturningFromExplainList = false + uni.showToast({ + title: '返回小程序失败,请重试', + icon: 'none' + }) +} + +const returnToGuideHome = () => { + if (isReturningFromExplainList || isConsumingExplainListHistory) return + if (consumeExplainListHistory()) return + + // 首页讲解 Tab 是项目内入口,优先恢复原页面而不是退出小程序。 + if (cameFromProjectExplainHome()) { + returnToProjectExplainHome() return } + if (shouldUseHostNavigation.value) { + returnFromStandaloneMiniProgramEntry() + return + } + + isReturningFromExplainList = true reLaunchGuideHome() } @@ -178,7 +209,7 @@ const pushExplainListHistory = () => { } const handleExplainListHistoryPopState = (event: PopStateEvent) => { - if (isReturningToGuideHome) return + if (isReturningFromExplainList) return if (!explainListHistoryPushed) return if (isExplainListHistoryState(event.state)) { diff --git a/src/utils/hostEnvironment.ts b/src/utils/hostEnvironment.ts index 02808a7..9f974d5 100644 --- a/src/utils/hostEnvironment.ts +++ b/src/utils/hostEnvironment.ts @@ -43,6 +43,28 @@ export const isEmbeddedInWechatMiniProgram = () => { return /miniProgram/i.test(window.navigator?.userAgent || '') } +type WechatMiniProgramNavigationBridge = { + navigateBack?: (options: { delta: number }) => void +} + +export const navigateBackToWechatMiniProgram = () => { + if (!isEmbeddedInWechatMiniProgram() || typeof window === 'undefined') return false + + const bridge = (window as Window & { + wx?: { miniProgram?: WechatMiniProgramNavigationBridge } + }).wx?.miniProgram + + if (typeof bridge?.navigateBack !== 'function') return false + + try { + bridge.navigateBack({ delta: 1 }) + return true + } catch (error) { + console.error('返回小程序失败:', error) + return false + } +} + export const isWechatMiniProgramRuntime = () => ( isNativeWechatMiniProgram() || isEmbeddedInWechatMiniProgram() ) diff --git a/tests/e2e/explain-hall-page.spec.ts b/tests/e2e/explain-hall-page.spec.ts index d773b2f..7e60d3e 100644 --- a/tests/e2e/explain-hall-page.spec.ts +++ b/tests/e2e/explain-hall-page.spec.ts @@ -66,7 +66,7 @@ test('hall goes directly to paged guide objects without outline requests', async expect(requests.filter((url) => url.includes('/stops/page')).map((url) => new URL(url).searchParams.get('pageNo'))).toEqual(['1', '2']) }) -test('embedded hall list exposes an H5 return control that keeps host markers', async ({ page }) => { +test('embedded direct hall list delegates browser return to the host mini program', async ({ page }) => { await page.route('**/app-api/gis/guide/catalog/**', async (route) => { const url = route.request().url() if (url.includes('/catalog/halls?')) { @@ -76,31 +76,38 @@ test('embedded hall list exposes an H5 return control that keeps host markers', await route.fulfill({ json: { code: 0, data: {} } }) }) + await page.addInitScript(() => { + document.documentElement.dataset.wechatNavigateBackCount = '0' + ;(window as Window & { wx?: unknown }).wx = { + miniProgram: { + navigateBack: () => { + const count = Number(document.documentElement.dataset.wechatNavigateBackCount || '0') + document.documentElement.dataset.wechatNavigateBackCount = String(count + 1) + } + } + } + }) + await page.goto('/#/pages/explain/list?embedded=wechat-mini-program&weapp=1') - const back = page.locator('.header-back') - await expect(back).toHaveCount(1) - await expect(back).toBeVisible() + await expect(page.locator('.header-back')).toHaveCount(0) await expect(page.locator('.hall-overview-card')).toHaveCount(1) for (const width of [375, 390, 430]) { await page.setViewportSize({ width, height: 844 }) const layout = await page.locator('.explain-hall-select').evaluate((container) => { - const header = container.querySelector('.explain-page-header') const card = container.querySelector('.hall-overview-card') - if (!header || !card) throw new Error('missing header or hall card') - const headerRect = header.getBoundingClientRect() + if (!card) throw new Error('missing hall card') const cardRect = card.getBoundingClientRect() return { clientWidth: container.clientWidth, scrollWidth: container.scrollWidth, - headerBottom: headerRect.bottom, cardTop: cardRect.top } }) expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth) - expect(layout.cardTop).toBeGreaterThanOrEqual(layout.headerBottom) + expect(layout.cardTop).toBeGreaterThanOrEqual(0) } - await back.click() - await expect(page).toHaveURL(/#\/\?tab=guide&embedded=wechat-mini-program&weapp=1$/) + await page.evaluate(() => window.history.back()) + await expect.poll(() => page.locator('html').getAttribute('data-wechat-navigate-back-count')).toBe('1') }) test('ordinary H5 hall list return reaches the guide home', async ({ page }) => { diff --git a/tests/unit/ExplainListNavigation.spec.ts b/tests/unit/ExplainListNavigation.spec.ts index 440972c..a0b0341 100644 --- a/tests/unit/ExplainListNavigation.spec.ts +++ b/tests/unit/ExplainListNavigation.spec.ts @@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ExplainListPage from '@/pages/explain/list.vue' const hostEnvironmentMocks = vi.hoisted(() => ({ - embeddedInWechatMiniProgram: false + embeddedInWechatMiniProgram: false, + navigateBackToWechatMiniProgram: vi.fn(() => false) })) const testState = vi.hoisted(() => ({ @@ -24,7 +25,8 @@ vi.mock('@dcloudio/uni-app', () => ({ })) vi.mock('@/utils/hostEnvironment', () => ({ - isEmbeddedInWechatMiniProgram: () => hostEnvironmentMocks.embeddedInWechatMiniProgram + isEmbeddedInWechatMiniProgram: () => hostEnvironmentMocks.embeddedInWechatMiniProgram, + navigateBackToWechatMiniProgram: () => hostEnvironmentMocks.navigateBackToWechatMiniProgram() })) vi.mock('@/usecases/explainUseCase', () => ({ @@ -60,6 +62,8 @@ const mountExplainList = () => mount(ExplainListPage, { beforeEach(() => { hostEnvironmentMocks.embeddedInWechatMiniProgram = false + hostEnvironmentMocks.navigateBackToWechatMiniProgram.mockReset() + hostEnvironmentMocks.navigateBackToWechatMiniProgram.mockReturnValue(false) testState.onLoadHandler = null testState.onBackPressHandler = null window.location.hash = '#/pages/explain/list' @@ -78,9 +82,9 @@ afterEach(() => { }) describe('讲解展厅列表返回', () => { - it('首页通过内部页面栈进入时,顶部返回使用 navigateBack 回到导览首页', async () => { + it('首页讲解 Tab 进入时,顶部返回使用 navigateBack 回到项目首页', async () => { vi.stubGlobal('getCurrentPages', vi.fn(() => [ - { route: 'pages/index/index', options: { tab: 'guide' } }, + { route: 'pages/index/index', options: { tab: 'explain' } }, { route: 'pages/explain/list', options: {} } ])) const wrapper = mountExplainList() @@ -94,6 +98,22 @@ describe('讲解展厅列表返回', () => { wrapper.unmount() }) + it('小程序中的首页讲解 Tab 进入时仍优先回到项目首页', async () => { + hostEnvironmentMocks.embeddedInWechatMiniProgram = true + hostEnvironmentMocks.navigateBackToWechatMiniProgram.mockReturnValue(true) + vi.stubGlobal('getCurrentPages', vi.fn(() => [ + { route: 'pages/index/index', options: { tab: 'explain' } }, + { route: 'pages/explain/list', options: {} } + ])) + const wrapper = mountExplainList() + + await wrapper.get('[data-testid="explain-list-back"]').trigger('click') + + expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 })) + expect(hostEnvironmentMocks.navigateBackToWechatMiniProgram).not.toHaveBeenCalled() + wrapper.unmount() + }) + it('页面栈只有展厅列表时使用 reLaunch 回到导览首页', async () => { const wrapper = mountExplainList() @@ -106,8 +126,9 @@ describe('讲解展厅列表返回', () => { wrapper.unmount() }) - it('独立页在微信嵌入态强制提供 H5 返回,并保留宿主识别参数', async () => { + it('小程序直达独立入口时返回小程序,而不是重启项目首页', async () => { hostEnvironmentMocks.embeddedInWechatMiniProgram = true + hostEnvironmentMocks.navigateBackToWechatMiniProgram.mockReturnValue(true) window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program&weapp=1' window.history.replaceState({}, '', window.location.href) const wrapper = mountExplainList() @@ -116,14 +137,14 @@ describe('讲解展厅列表返回', () => { await wrapper.get('[data-testid="explain-list-back"]').trigger('click') expect(uni.navigateBack).not.toHaveBeenCalled() - expect(uni.reLaunch).toHaveBeenCalledWith(expect.objectContaining({ - url: '/pages/index/index?tab=guide&embedded=wechat-mini-program&weapp=1' - })) + expect(hostEnvironmentMocks.navigateBackToWechatMiniProgram).toHaveBeenCalledTimes(1) + expect(uni.reLaunch).not.toHaveBeenCalled() wrapper.unmount() }) - it('微信嵌入态即使前置页是导览首页也不调用宿主返回', async () => { + it('微信嵌入态从非讲解首页进入时返回小程序', async () => { hostEnvironmentMocks.embeddedInWechatMiniProgram = true + hostEnvironmentMocks.navigateBackToWechatMiniProgram.mockReturnValue(true) window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program' window.history.replaceState({}, '', window.location.href) vi.stubGlobal('getCurrentPages', vi.fn(() => [ @@ -135,9 +156,8 @@ describe('讲解展厅列表返回', () => { await wrapper.get('[data-testid="explain-list-back"]').trigger('click') expect(uni.navigateBack).not.toHaveBeenCalled() - expect(uni.reLaunch).toHaveBeenCalledWith(expect.objectContaining({ - url: '/pages/index/index?tab=guide&embedded=wechat-mini-program' - })) + expect(hostEnvironmentMocks.navigateBackToWechatMiniProgram).toHaveBeenCalledTimes(1) + expect(uni.reLaunch).not.toHaveBeenCalled() wrapper.unmount() }) @@ -157,27 +177,11 @@ describe('讲解展厅列表返回', () => { wrapper.unmount() }) - it('上一页是首页但不是导览 Tab 时重启到导览首页', async () => { + it('首页讲解 Tab 返回失败后重启到项目首页讲解 Tab', async () => { vi.stubGlobal('getCurrentPages', vi.fn(() => [ { route: 'pages/index/index', options: { tab: 'explain' } }, { route: 'pages/explain/list', options: {} } ])) - const wrapper = mountExplainList() - - await wrapper.get('[data-testid="explain-list-back"]').trigger('click') - - expect(uni.navigateBack).not.toHaveBeenCalled() - expect(uni.reLaunch).toHaveBeenCalledWith(expect.objectContaining({ - url: '/pages/index/index?tab=guide' - })) - wrapper.unmount() - }) - - it('navigateBack 失败后重启到导览首页', async () => { - vi.stubGlobal('getCurrentPages', vi.fn(() => [ - { route: 'pages/index/index', options: { tab: 'guide' } }, - { route: 'pages/explain/list', options: {} } - ])) vi.stubGlobal('uni', { navigateBack: vi.fn((options: { fail?: () => void }) => options.fail?.()), reLaunch: vi.fn(), @@ -188,7 +192,7 @@ describe('讲解展厅列表返回', () => { await wrapper.get('[data-testid="explain-list-back"]').trigger('click') expect(uni.reLaunch).toHaveBeenCalledWith(expect.objectContaining({ - url: '/pages/index/index?tab=guide' + url: '/pages/index/index?tab=explain' })) wrapper.unmount() }) @@ -210,8 +214,9 @@ describe('讲解展厅列表返回', () => { wrapper.unmount() }) - it('根栈只创建一条历史标记,浏览器返回会回到导览首页', async () => { + it('小程序直达入口的浏览器返回会委托给小程序宿主', async () => { hostEnvironmentMocks.embeddedInWechatMiniProgram = true + hostEnvironmentMocks.navigateBackToWechatMiniProgram.mockReturnValue(true) window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program&weapp=1' window.history.replaceState({}, '', window.location.href) const wrapper = mountExplainList() @@ -225,12 +230,8 @@ describe('讲解展厅列表返回', () => { expect(uni.navigateBack).not.toHaveBeenCalled() expect(pushState).toHaveBeenCalledTimes(1) - const url = vi.mocked(uni.reLaunch).mock.calls[0]?.[0]?.url || '' - const query = new URLSearchParams(url.split('?')[1]) - expect(url.split('?')[0]).toBe('/pages/index/index') - expect(query.get('tab')).toBe('guide') - expect(query.get('embedded')).toBe('wechat-mini-program') - expect(query.get('weapp')).toBe('1') + expect(hostEnvironmentMocks.navigateBackToWechatMiniProgram).toHaveBeenCalledTimes(1) + expect(uni.reLaunch).not.toHaveBeenCalled() wrapper.unmount() }) diff --git a/tests/unit/hostEnvironment.spec.ts b/tests/unit/hostEnvironment.spec.ts index 3a82f05..dd0d522 100644 --- a/tests/unit/hostEnvironment.spec.ts +++ b/tests/unit/hostEnvironment.spec.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment' +import { + isEmbeddedInWechatMiniProgram, + navigateBackToWechatMiniProgram +} from '@/utils/hostEnvironment' const createWindow = (overrides: Record = {}) => ({ location: { search: '', hash: '' }, @@ -47,3 +50,24 @@ describe('微信小程序宿主环境识别', () => { expect(isEmbeddedInWechatMiniProgram()).toBe(false) }) }) + +describe('小程序宿主返回', () => { + it('嵌入态调用小程序 navigateBack', () => { + const navigateBack = vi.fn() + vi.stubGlobal('window', createWindow({ + __wxjs_environment: 'miniprogram', + wx: { miniProgram: { navigateBack } } + })) + + expect(navigateBackToWechatMiniProgram()).toBe(true) + expect(navigateBack).toHaveBeenCalledWith({ delta: 1 }) + }) + + it('非嵌入态不会调用小程序返回桥接', () => { + const navigateBack = vi.fn() + vi.stubGlobal('window', createWindow({ wx: { miniProgram: { navigateBack } } })) + + expect(navigateBackToWechatMiniProgram()).toBe(false) + expect(navigateBack).not.toHaveBeenCalled() + }) +})