From d94d689b40808ed83515053f3e330ced13a41024 Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Sat, 25 Jul 2026 02:28:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=B2=E8=A7=A3=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E8=BF=94=E5=9B=9E=E5=88=86=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/explain/list.vue | 43 +++++++++---- .../WechatMiniProgramBridgeService.ts | 46 ++++++++++++++ tests/e2e/explain-hall-page.spec.ts | 15 ++--- tests/unit/ExplainListNavigation.spec.ts | 63 +++++++++++-------- .../WechatMiniProgramBridgeService.spec.ts | 21 +++++++ 5 files changed, 140 insertions(+), 48 deletions(-) diff --git a/src/pages/explain/list.vue b/src/pages/explain/list.vue index 7d4e4dc..0b154ec 100644 --- a/src/pages/explain/list.vue +++ b/src/pages/explain/list.vue @@ -36,11 +36,13 @@ import type { } from '@/domain/museum' import { guideTopTabUrl, + isGuideTopTab, navigateToGuideTopTab, type GuideTopTab } from '@/utils/guideTopTabs' import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment' import { explainGuideStopListUrl } from '@/utils/explainNavigation' +import { returnToWechatMiniProgram } from '@/services/WechatMiniProgramBridgeService' const explainHallItems = ref([]) const explainLoading = ref(false) @@ -71,8 +73,6 @@ const buildExplainHallItems = ( const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram()) -const GUIDE_HOME_URL = '/pages/index/index?tab=guide' - type ExplainListHistoryState = { museumGuidePage?: 'explain-list' [key: string]: unknown @@ -93,9 +93,7 @@ const getPageStack = () => ( typeof getCurrentPages === 'function' ? getCurrentPages() as PageStackEntry[] : [] ) -const guideHomeUrl = () => ( - shouldUseHostNavigation.value ? guideTopTabUrl('guide') : GUIDE_HOME_URL -) +const guideHomeUrl = (tab: GuideTopTab = 'guide') => guideTopTabUrl(tab) const isExplainListHistoryState = (state: unknown): state is ExplainListHistoryState => ( Boolean( @@ -120,6 +118,25 @@ const reLaunchGuideHome = () => { }) } +const reLaunchPreviousGuideHome = (tab: GuideTopTab) => { + uni.reLaunch({ + url: guideHomeUrl(tab), + fail: showGuideHomeReturnError + }) +} + +const returnToHostMiniProgram = async () => { + isReturningToGuideHome = true + const result = await returnToWechatMiniProgram() + if (result.status === 'returned') return + + isReturningToGuideHome = false + uni.showToast({ + title: '返回小程序失败,请重试', + icon: 'none' + }) +} + const consumeExplainListHistory = () => { if ( typeof window === 'undefined' @@ -144,25 +161,27 @@ const returnToGuideHome = () => { 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') - ) + const canNavigateBackToGuideHome = previousRoute === 'pages/index/index' + const previousGuideHomeTab = isGuideTopTab(previousTab) ? previousTab : 'guide' if (canNavigateBackToGuideHome) { uni.navigateBack({ delta: 1, - fail: reLaunchGuideHome + fail: () => reLaunchPreviousGuideHome(previousGuideHomeTab) }) return } + if (shouldUseHostNavigation.value) { + void returnToHostMiniProgram() + return + } + reLaunchGuideHome() } const pushExplainListHistory = () => { - if (typeof window === 'undefined' || getPageStack().length > 1) return + if (typeof window === 'undefined' || shouldUseHostNavigation.value || getPageStack().length > 1) return if (explainListHistoryPushed) return if (isExplainListHistoryState(window.history.state)) { diff --git a/src/services/WechatMiniProgramBridgeService.ts b/src/services/WechatMiniProgramBridgeService.ts index 88a0243..f85b4bc 100644 --- a/src/services/WechatMiniProgramBridgeService.ts +++ b/src/services/WechatMiniProgramBridgeService.ts @@ -8,6 +8,7 @@ import { interface MiniProgramBridge { getEnv?: (callback: (result: { miniprogram?: boolean }) => void) => void navigateTo?: (options: { url: string; success?: () => void; fail?: (error: { errMsg?: string }) => void }) => void + navigateBack?: (options: { delta?: number; success?: () => void; fail?: (error: { errMsg?: string }) => void }) => void } export type WechatMiniProgramBridgeResult = @@ -15,6 +16,11 @@ export type WechatMiniProgramBridgeResult = | { status: 'not-embedded' } | { status: 'failed'; reason: 'invalid-target' | 'bridge-unavailable' | 'not-in-mini-program' | 'navigation-failed' | 'page-not-found' | 'timeout' } +export type WechatMiniProgramReturnResult = + | { status: 'returned' } + | { status: 'not-embedded' } + | { status: 'failed'; reason: 'bridge-unavailable' | 'not-in-mini-program' | 'navigation-failed' | 'timeout' } + const SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.3.2.js' const BRIDGE_WAIT_TIMEOUT = 2500 const BRIDGE_POLL_INTERVAL = 50 @@ -22,6 +28,7 @@ const GET_ENV_TIMEOUT = 1500 const NAVIGATION_TIMEOUT = 3000 let pendingNavigation: Promise | null = null +let pendingReturn: Promise | null = null let sdkLoadPromise: Promise | null = null const getBridge = (): MiniProgramBridge | null => { @@ -108,6 +115,31 @@ const navigateToHostLocationPage = (bridge: MiniProgramBridge, target: WechatOpe } }) +const navigateBackToHostPage = (bridge: MiniProgramBridge) => new Promise((resolve) => { + if (!bridge.navigateBack) return resolve({ status: 'failed', reason: 'bridge-unavailable' }) + let settled = false + const finish = (result: WechatMiniProgramReturnResult) => { + if (settled) return + settled = true + clearTimeout(timeout) + resolve(result) + } + const timeout = setTimeout(() => finish({ status: 'failed', reason: 'timeout' }), NAVIGATION_TIMEOUT) + try { + bridge.navigateBack({ + delta: 1, + success: () => finish({ status: 'returned' }), + fail: (error) => { + console.error('返回微信小程序失败:', error) + finish({ status: 'failed', reason: 'navigation-failed' }) + } + }) + } catch (error) { + console.error('调用微信小程序返回桥接失败:', error) + finish({ status: 'failed', reason: 'navigation-failed' }) + } +}) + const openOnce = async (target: WechatOpenLocationTarget): Promise => { const validation = validateWechatOpenLocationTarget(target) if (!validation.ok) return { status: 'failed', reason: 'invalid-target' } @@ -118,8 +150,22 @@ const openOnce = async (target: WechatOpenLocationTarget): Promise => { + if (!isEmbeddedInWechatMiniProgram()) return { status: 'not-embedded' } + const bridge = await waitForBridge() + if (!bridge) return { status: 'failed', reason: 'bridge-unavailable' } + if (!await isMiniProgramWebView(bridge)) return { status: 'failed', reason: 'not-in-mini-program' } + return navigateBackToHostPage(bridge) +} + export const openWechatMiniProgramLocation = (target: WechatOpenLocationTarget) => { if (pendingNavigation) return pendingNavigation pendingNavigation = openOnce(target).finally(() => { pendingNavigation = null }) return pendingNavigation } + +export const returnToWechatMiniProgram = () => { + if (pendingReturn) return pendingReturn + pendingReturn = returnOnce().finally(() => { pendingReturn = null }) + return pendingReturn +} diff --git a/tests/e2e/explain-hall-page.spec.ts b/tests/e2e/explain-hall-page.spec.ts index d773b2f..12b9e08 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 hall list leaves title and return navigation 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?')) { @@ -78,29 +78,24 @@ test('embedded hall list exposes an H5 return control that keeps host markers', 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(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 expect(page.locator('.explain-page-header')).toHaveCount(0) }) 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 095ea64..90821c7 100644 --- a/tests/unit/ExplainListNavigation.spec.ts +++ b/tests/unit/ExplainListNavigation.spec.ts @@ -9,6 +9,10 @@ const hostEnvironmentMocks = vi.hoisted(() => ({ embeddedInWechatMiniProgram: false })) +const miniProgramBridgeMocks = vi.hoisted(() => ({ + returnToWechatMiniProgram: vi.fn() +})) + const testState = vi.hoisted(() => ({ onLoadHandler: null as null | (() => void), onBackPressHandler: null as null | (() => boolean) @@ -27,6 +31,10 @@ vi.mock('@/utils/hostEnvironment', () => ({ isEmbeddedInWechatMiniProgram: () => hostEnvironmentMocks.embeddedInWechatMiniProgram })) +vi.mock('@/services/WechatMiniProgramBridgeService', () => ({ + returnToWechatMiniProgram: miniProgramBridgeMocks.returnToWechatMiniProgram +})) + vi.mock('@/usecases/explainUseCase', () => ({ explainUseCase: { loadExplainHalls: async () => [], @@ -60,6 +68,7 @@ const mountExplainList = () => mount(ExplainListPage, { beforeEach(() => { hostEnvironmentMocks.embeddedInWechatMiniProgram = false + miniProgramBridgeMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'not-embedded' }) testState.onLoadHandler = null testState.onBackPressHandler = null window.location.hash = '#/pages/explain/list' @@ -78,9 +87,9 @@ afterEach(() => { }) describe('讲解展厅列表返回', () => { - it('首页通过内部页面栈进入时,顶部返回使用 navigateBack 回到导览首页', async () => { + it('首页讲解 Tab 进入时,顶部返回使用 navigateBack 保留讲解 Tab', 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() @@ -91,6 +100,7 @@ describe('讲解展厅列表返回', () => { expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 })) expect(uni.reLaunch).not.toHaveBeenCalled() + expect(miniProgramBridgeMocks.returnToWechatMiniProgram).not.toHaveBeenCalled() wrapper.unmount() }) @@ -106,38 +116,38 @@ describe('讲解展厅列表返回', () => { wrapper.unmount() }) - it('微信嵌入态由宿主导航提供标题与返回,不渲染重复的 H5 页头', async () => { + it('微信嵌入态直达页面由宿主提供标题与返回,并返回小程序', async () => { hostEnvironmentMocks.embeddedInWechatMiniProgram = true + miniProgramBridgeMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' }) window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program&weapp=1' window.history.replaceState({}, '', window.location.href) const wrapper = mountExplainList() expect(wrapper.getComponent(ExplainHallSelectStub).props('forceInternalHeader')).toBe(false) await wrapper.get('[data-testid="explain-list-back"]').trigger('click') + await flushPromises() expect(uni.navigateBack).not.toHaveBeenCalled() - expect(uni.reLaunch).toHaveBeenCalledWith(expect.objectContaining({ - url: '/pages/index/index?tab=guide&embedded=wechat-mini-program&weapp=1' - })) + expect(uni.reLaunch).not.toHaveBeenCalled() + expect(miniProgramBridgeMocks.returnToWechatMiniProgram).toHaveBeenCalledTimes(1) wrapper.unmount() }) - it('微信嵌入态即使前置页是导览首页也不调用宿主返回', async () => { + it('微信嵌入态从本项目首页进入时仍返回本项目首页', async () => { hostEnvironmentMocks.embeddedInWechatMiniProgram = true window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program' window.history.replaceState({}, '', window.location.href) 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() 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(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 })) + expect(uni.reLaunch).not.toHaveBeenCalled() + expect(miniProgramBridgeMocks.returnToWechatMiniProgram).not.toHaveBeenCalled() wrapper.unmount() }) @@ -157,23 +167,27 @@ describe('讲解展厅列表返回', () => { wrapper.unmount() }) - it('上一页是首页但不是导览 Tab 时重启到导览首页', async () => { + it('首页返回失败时重启到原讲解 Tab', async () => { vi.stubGlobal('getCurrentPages', vi.fn(() => [ { route: 'pages/index/index', options: { tab: 'explain' } }, { route: 'pages/explain/list', options: {} } ])) + vi.stubGlobal('uni', { + navigateBack: vi.fn((options: { fail?: () => void }) => options.fail?.()), + reLaunch: vi.fn(), + showToast: vi.fn() + }) 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' + url: '/pages/index/index?tab=explain' })) wrapper.unmount() }) - it('navigateBack 失败后重启到导览首页', async () => { + it('导览首页返回失败后重启到原导览 Tab', async () => { vi.stubGlobal('getCurrentPages', vi.fn(() => [ { route: 'pages/index/index', options: { tab: 'guide' } }, { route: 'pages/explain/list', options: {} } @@ -210,8 +224,9 @@ describe('讲解展厅列表返回', () => { wrapper.unmount() }) - it('根栈只创建一条历史标记,浏览器返回会回到导览首页', async () => { + it('微信嵌入态根栈不创建 H5 history 标记,平台返回交给宿主', async () => { hostEnvironmentMocks.embeddedInWechatMiniProgram = true + miniProgramBridgeMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' }) window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program&weapp=1' window.history.replaceState({}, '', window.location.href) const wrapper = mountExplainList() @@ -220,17 +235,13 @@ describe('讲解展厅列表返回', () => { testState.onLoadHandler?.() await flushPromises() - window.dispatchEvent(new PopStateEvent('popstate', { state: {} })) - await flushPromises() + expect(pushState).not.toHaveBeenCalled() 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(uni.reLaunch).not.toHaveBeenCalled() + expect(testState.onBackPressHandler?.()).toBe(true) + await flushPromises() + expect(miniProgramBridgeMocks.returnToWechatMiniProgram).toHaveBeenCalledTimes(1) wrapper.unmount() }) diff --git a/tests/unit/WechatMiniProgramBridgeService.spec.ts b/tests/unit/WechatMiniProgramBridgeService.spec.ts index 0d81827..40194d9 100644 --- a/tests/unit/WechatMiniProgramBridgeService.spec.ts +++ b/tests/unit/WechatMiniProgramBridgeService.spec.ts @@ -31,6 +31,27 @@ describe('微信小程序 web-view 桥接', () => { expect(document.querySelector('[data-wechat-js-sdk="true"]')).toBeNull() }) + it('嵌入小程序时通过 navigateBack 返回宿主页', async () => { + setEmbeddedRuntime() + const { returnToWechatMiniProgram } = await loadService() + const navigateBack = vi.fn(({ success }) => success()) + Object.assign(window, { + wx: { + miniProgram: { + getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), + navigateBack + } + } + }) + + await expect(returnToWechatMiniProgram()).resolves.toEqual({ status: 'returned' }) + expect(navigateBack).toHaveBeenCalledWith({ + delta: 1, + success: expect.any(Function), + fail: expect.any(Function) + }) + }) + it('等待延迟注入的 bridge 后导航到编码后的宿主页', async () => { setEmbeddedRuntime() const { openWechatMiniProgramLocation } = await loadService()