修复小程序讲解页面返回回退
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-28 10:40:25 +08:00
parent 0b3a31f556
commit 8b3e3f8d55
10 changed files with 281 additions and 82 deletions

View File

@@ -159,7 +159,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { onHide, onLoad, onUnload } from '@dcloudio/uni-app' import { onBackPress, onHide, onLoad, onUnload } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue' import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import GuideFeedbackState from '@/components/navigation/GuideFeedbackState.vue' import GuideFeedbackState from '@/components/navigation/GuideFeedbackState.vue'
import GuideLoadingState from '@/components/navigation/GuideLoadingState.vue' import GuideLoadingState from '@/components/navigation/GuideLoadingState.vue'
@@ -188,6 +188,7 @@ import {
} from '@/utils/placeholders' } from '@/utils/placeholders'
import { normalizeSameOriginPublicUrl } from '@/utils/publicUrl' import { normalizeSameOriginPublicUrl } from '@/utils/publicUrl'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment' import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
import { returnToWechatMiniProgram } from '@/services/WechatMiniProgramBridgeService'
import { normalizeGuideAudioLanguage } from '@/data/adapters/guideStopInfoAdapter' import { normalizeGuideAudioLanguage } from '@/data/adapters/guideStopInfoAdapter'
import { explainGuideStopListUrl } from '@/utils/explainNavigation' import { explainGuideStopListUrl } from '@/utils/explainNavigation'
import { import {
@@ -355,6 +356,7 @@ const isCurrentDetailAudioTarget = computed(() => {
) )
}) })
let detailAudioClosedOnExit = false let detailAudioClosedOnExit = false
let returningToMiniProgram = false
const closeDetailAudioOnExit = () => { const closeDetailAudioOnExit = () => {
if (detailAudioClosedOnExit || !isCurrentDetailAudioTarget.value) return if (detailAudioClosedOnExit || !isCurrentDetailAudioTarget.value) return
@@ -945,15 +947,42 @@ const fallbackToExplainObjectList = () => {
}) })
} }
const returnToExplainObjectList = () => { const cameFromExplainObjectList = () => {
closeDetailAudioOnExit() const pages = getCurrentPages()
uni.navigateBack({ const previousPage = pages[pages.length - 2] as { route?: string } | undefined
delta: 1, return previousPage?.route === 'pages/explain/guide-stop-list'
fail: fallbackToExplainObjectList
})
} }
const handleBack = returnToExplainObjectList const returnToExplainObjectList = async () => {
closeDetailAudioOnExit()
if (cameFromExplainObjectList()) {
uni.navigateBack({
delta: 1,
fail: fallbackToExplainObjectList
})
return
}
if (isEmbeddedInWechatMiniProgram()) {
if (returningToMiniProgram) return
returningToMiniProgram = true
const result = await returnToWechatMiniProgram()
if (result.status === 'returned') return
returningToMiniProgram = false
uni.showToast({ title: '返回小程序失败,请重试', icon: 'none' })
return
}
fallbackToExplainObjectList()
}
const handleBack = () => { void returnToExplainObjectList() }
onBackPress(() => {
void returnToExplainObjectList()
return true
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -20,13 +20,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { onUnmounted, ref } from 'vue' import { onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onBackPress, onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue' import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import ExplainGuideStopCatalog, { type ExplainGuideStopCatalogItem } from '@/components/explain/ExplainGuideStopCatalog.vue' import ExplainGuideStopCatalog, { type ExplainGuideStopCatalogItem } from '@/components/explain/ExplainGuideStopCatalog.vue'
import { explainUseCase } from '@/usecases/explainUseCase' import { explainUseCase } from '@/usecases/explainUseCase'
import type { ExplainGuideStop } from '@/domain/museum' import type { ExplainGuideStop } from '@/domain/museum'
import { normalizeExplainDetailTargetFromGuideStop } from '@/domain/explainDetailTarget' import { normalizeExplainDetailTargetFromGuideStop } from '@/domain/explainDetailTarget'
import { navigateToGuideTopTab, type GuideTopTab } from '@/utils/guideTopTabs' import { navigateToGuideTopTab, type GuideTopTab } from '@/utils/guideTopTabs'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
import { returnToWechatMiniProgram } from '@/services/WechatMiniProgramBridgeService'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
const selectedExplainHallId = ref('') const selectedExplainHallId = ref('')
@@ -40,6 +42,7 @@ const hasMore = ref(false)
const nextPageNo = ref(1) const nextPageNo = ref(1)
let requestVersion = 0 let requestVersion = 0
let disposed = false let disposed = false
let returningToMiniProgram = false
const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopCatalogItem[] => stops.map((stop) => ({ const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopCatalogItem[] => stops.map((stop) => ({
id: stop.id, id: stop.id,
@@ -153,7 +156,7 @@ type ExplainPageStackEntry = {
} }
} }
const returnToHallList = () => { const returnToHallList = async () => {
const pages = getCurrentPages() const pages = getCurrentPages()
const previousPage = pages[pages.length - 2] as ExplainPageStackEntry | undefined const previousPage = pages[pages.length - 2] as ExplainPageStackEntry | undefined
const previousRoute = previousPage?.route const previousRoute = previousPage?.route
@@ -170,11 +173,27 @@ const returnToHallList = () => {
return return
} }
if (isEmbeddedInWechatMiniProgram()) {
if (returningToMiniProgram) return
returningToMiniProgram = true
const result = await returnToWechatMiniProgram()
if (result.status === 'returned') return
returningToMiniProgram = false
uni.showToast({ title: '返回小程序失败,请重试', icon: 'none' })
return
}
fallbackToHallList() fallbackToHallList()
} }
const handleBack = returnToHallList const handleBack = () => { void returnToHallList() }
const handleTopTabChange = (tab: GuideTopTab) => navigateToGuideTopTab(tab) const handleTopTabChange = (tab: GuideTopTab) => navigateToGuideTopTab(tab)
onBackPress(() => {
void returnToHallList()
return true
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -39,9 +39,9 @@ import {
type GuideTopTab type GuideTopTab
} from '@/utils/guideTopTabs' } from '@/utils/guideTopTabs'
import { import {
isEmbeddedInWechatMiniProgram, isEmbeddedInWechatMiniProgram
navigateBackToWechatMiniProgram
} from '@/utils/hostEnvironment' } from '@/utils/hostEnvironment'
import { returnToWechatMiniProgram } from '@/services/WechatMiniProgramBridgeService'
import { syncHostNavigationTitle } from '@/utils/hostNavigationTitle' import { syncHostNavigationTitle } from '@/utils/hostNavigationTitle'
import { explainGuideStopListUrl } from '@/utils/explainNavigation' import { explainGuideStopListUrl } from '@/utils/explainNavigation'
@@ -162,9 +162,10 @@ const returnToProjectExplainHome = () => {
}) })
} }
const returnFromStandaloneMiniProgramEntry = () => { const returnFromStandaloneMiniProgramEntry = async () => {
isReturningFromExplainList = true isReturningFromExplainList = true
if (navigateBackToWechatMiniProgram()) return const result = await returnToWechatMiniProgram()
if (result.status === 'returned') return
isReturningFromExplainList = false isReturningFromExplainList = false
uni.showToast({ uni.showToast({
@@ -184,7 +185,7 @@ const returnToGuideHome = () => {
} }
if (shouldUseHostNavigation.value) { if (shouldUseHostNavigation.value) {
returnFromStandaloneMiniProgramEntry() void returnFromStandaloneMiniProgramEntry()
return return
} }

View File

@@ -8,6 +8,8 @@ import {
interface MiniProgramBridge { interface MiniProgramBridge {
getEnv?: (callback: (result: { miniprogram?: boolean }) => void) => void getEnv?: (callback: (result: { miniprogram?: boolean }) => void) => void
navigateTo?: (options: { url: string; success?: () => void; fail?: (error: { errMsg?: string }) => 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
postMessage?: (options: { data: { type: string } }) => void
} }
export type WechatMiniProgramBridgeResult = export type WechatMiniProgramBridgeResult =
@@ -15,13 +17,20 @@ export type WechatMiniProgramBridgeResult =
| { status: 'not-embedded' } | { status: 'not-embedded' }
| { status: 'failed'; reason: 'invalid-target' | 'bridge-unavailable' | 'not-in-mini-program' | 'navigation-failed' | 'page-not-found' | 'timeout' } | { 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 SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.3.2.js'
export const MINI_PROGRAM_RETURN_MESSAGE = 'museum-guide:return'
const BRIDGE_WAIT_TIMEOUT = 2500 const BRIDGE_WAIT_TIMEOUT = 2500
const BRIDGE_POLL_INTERVAL = 50 const BRIDGE_POLL_INTERVAL = 50
const GET_ENV_TIMEOUT = 1500 const GET_ENV_TIMEOUT = 1500
const NAVIGATION_TIMEOUT = 3000 const NAVIGATION_TIMEOUT = 3000
let pendingNavigation: Promise<WechatMiniProgramBridgeResult> | null = null let pendingNavigation: Promise<WechatMiniProgramBridgeResult> | null = null
let pendingReturn: Promise<WechatMiniProgramReturnResult> | null = null
let sdkLoadPromise: Promise<void> | null = null let sdkLoadPromise: Promise<void> | null = null
const getBridge = (): MiniProgramBridge | null => { const getBridge = (): MiniProgramBridge | null => {
@@ -108,6 +117,45 @@ const navigateToHostLocationPage = (bridge: MiniProgramBridge, target: WechatOpe
} }
}) })
const requestHostReturnFallback = (bridge: MiniProgramBridge) => {
if (!bridge.postMessage) return false
try {
bridge.postMessage({ data: { type: MINI_PROGRAM_RETURN_MESSAGE } })
return true
} catch (error) {
console.error('请求宿主小程序安全返回失败:', error)
return false
}
}
const navigateBackToHost = (bridge: MiniProgramBridge) => new Promise<WechatMiniProgramReturnResult>((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<WechatMiniProgramBridgeResult> => { const openOnce = async (target: WechatOpenLocationTarget): Promise<WechatMiniProgramBridgeResult> => {
const validation = validateWechatOpenLocationTarget(target) const validation = validateWechatOpenLocationTarget(target)
if (!validation.ok) return { status: 'failed', reason: 'invalid-target' } if (!validation.ok) return { status: 'failed', reason: 'invalid-target' }
@@ -123,3 +171,24 @@ export const openWechatMiniProgramLocation = (target: WechatOpenLocationTarget)
pendingNavigation = openOnce(target).finally(() => { pendingNavigation = null }) pendingNavigation = openOnce(target).finally(() => { pendingNavigation = null })
return pendingNavigation return pendingNavigation
} }
const returnOnce = async (): Promise<WechatMiniProgramReturnResult> => {
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' }
const result = await navigateBackToHost(bridge)
if (result.status === 'returned') return result
// 宿主页可能由分享或冷启动直接打开,页面栈没有上一页时改由宿主执行安全回退。
if (requestHostReturnFallback(bridge)) return { status: 'returned' }
return result
}
export const returnToWechatMiniProgram = () => {
if (pendingReturn) return pendingReturn
pendingReturn = returnOnce().finally(() => { pendingReturn = null })
return pendingReturn
}

View File

@@ -43,28 +43,6 @@ export const isEmbeddedInWechatMiniProgram = () => {
return /miniProgram/i.test(window.navigator?.userAgent || '') 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 = () => ( export const isWechatMiniProgramRuntime = () => (
isNativeWechatMiniProgram() || isEmbeddedInWechatMiniProgram() isNativeWechatMiniProgram() || isEmbeddedInWechatMiniProgram()
) )

View File

@@ -6,6 +6,7 @@ import { flushPromises, mount } from '@vue/test-utils'
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
onLoadHandler: null as null | ((options?: Record<string, unknown>) => Promise<void>), onLoadHandler: null as null | ((options?: Record<string, unknown>) => Promise<void>),
onBackPressHandler: null as null | (() => boolean),
onHideHandler: null as null | (() => void), onHideHandler: null as null | (() => void),
onUnloadHandler: null as null | (() => void), onUnloadHandler: null as null | (() => void),
enterExplainDetail: vi.fn(), enterExplainDetail: vi.fn(),
@@ -17,7 +18,9 @@ const mocks = vi.hoisted(() => ({
close: vi.fn(), close: vi.fn(),
seekToPercent: vi.fn(), seekToPercent: vi.fn(),
setPlaybackRate: vi.fn(), setPlaybackRate: vi.fn(),
toggleMute: vi.fn() toggleMute: vi.fn(),
embeddedInWechatMiniProgram: false,
returnToWechatMiniProgram: vi.fn(() => Promise.resolve({ status: 'failed' as const, reason: 'bridge-unavailable' as const }))
})) }))
const audioState = vi.hoisted(() => ({ const audioState = vi.hoisted(() => ({
@@ -36,6 +39,9 @@ vi.mock('@dcloudio/uni-app', () => ({
onLoad: (handler: (options?: Record<string, unknown>) => Promise<void>) => { onLoad: (handler: (options?: Record<string, unknown>) => Promise<void>) => {
mocks.onLoadHandler = handler mocks.onLoadHandler = handler
}, },
onBackPress: (handler: () => boolean) => {
mocks.onBackPressHandler = handler
},
onHide: (handler: () => void) => { onHide: (handler: () => void) => {
mocks.onHideHandler = handler mocks.onHideHandler = handler
}, },
@@ -74,7 +80,11 @@ vi.mock('@/composables/useGlobalAudioPlayer', () => ({
})) }))
vi.mock('@/utils/hostEnvironment', () => ({ vi.mock('@/utils/hostEnvironment', () => ({
isEmbeddedInWechatMiniProgram: () => false isEmbeddedInWechatMiniProgram: () => mocks.embeddedInWechatMiniProgram
}))
vi.mock('@/services/WechatMiniProgramBridgeService', () => ({
returnToWechatMiniProgram: () => mocks.returnToWechatMiniProgram()
})) }))
import ExhibitDetail from '@/pages/exhibit/detail.vue' import ExhibitDetail from '@/pages/exhibit/detail.vue'
@@ -106,6 +116,7 @@ const exhibit = {
describe('讲解详情音频优先布局', () => { describe('讲解详情音频优先布局', () => {
beforeEach(() => { beforeEach(() => {
mocks.onLoadHandler = null mocks.onLoadHandler = null
mocks.onBackPressHandler = null
mocks.onHideHandler = null mocks.onHideHandler = null
mocks.onUnloadHandler = null mocks.onUnloadHandler = null
audioState.currentAudio.value = null audioState.currentAudio.value = null
@@ -131,6 +142,9 @@ describe('讲解详情音频优先布局', () => {
playInfo: { title: exhibit.name } playInfo: { title: exhibit.name }
}) })
mocks.play.mockResolvedValue(undefined) mocks.play.mockResolvedValue(undefined)
mocks.embeddedInWechatMiniProgram = false
mocks.returnToWechatMiniProgram.mockReset()
mocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'failed', reason: 'bridge-unavailable' })
vi.stubGlobal('uni', { vi.stubGlobal('uni', {
showToast: vi.fn(), showToast: vi.fn(),
navigateBack: vi.fn(), navigateBack: vi.fn(),
@@ -375,6 +389,28 @@ describe('讲解详情音频优先布局', () => {
expect(uni.redirectTo).not.toHaveBeenCalled() expect(uni.redirectTo).not.toHaveBeenCalled()
}) })
it('小程序直达讲解详情时返回宿主,不进入 H5 讲解对象列表', async () => {
mocks.embeddedInWechatMiniProgram = true
mocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({
id: exhibit.id,
targetType: 'STOP',
targetId: 'stop-1',
hallId: 'hall-1',
hallName: '恐龙厅'
})
wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
await flushPromises()
expect(mocks.returnToWechatMiniProgram).toHaveBeenCalledTimes(1)
expect(uni.navigateBack).not.toHaveBeenCalled()
expect(uni.redirectTo).not.toHaveBeenCalled()
})
it('离开详情页时立即关闭当前讲解播放器', async () => { it('离开详情页时立即关闭当前讲解播放器', async () => {
audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl } audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl }
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' } audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
@@ -407,9 +443,6 @@ describe('讲解详情音频优先布局', () => {
}) })
await flushPromises() await flushPromises()
vi.mocked(uni.navigateBack).mockImplementationOnce((options: any) => {
options.fail?.()
})
wrapper.getComponent(GuidePageFrameStub).vm.$emit('back') wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
await flushPromises() await flushPromises()
@@ -418,6 +451,6 @@ describe('讲解详情音频优先布局', () => {
expect(url.split('?')[0]).toBe('/pages/explain/guide-stop-list') expect(url.split('?')[0]).toBe('/pages/explain/guide-stop-list')
expect(params.get('hallId')).toBe('hall-1') expect(params.get('hallId')).toBe('hall-1')
expect(params.get('hallName')).toBe('恐龙厅') expect(params.get('hallName')).toBe('恐龙厅')
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 })) expect(uni.navigateBack).not.toHaveBeenCalled()
}) })
}) })

View File

@@ -7,15 +7,35 @@ import ExplainGuideStopListPage from '@/pages/explain/guide-stop-list.vue'
import { explainUseCase } from '@/usecases/explainUseCase' import { explainUseCase } from '@/usecases/explainUseCase'
const testState = vi.hoisted(() => ({ const testState = vi.hoisted(() => ({
onLoadHandler: null as null | ((options?: Record<string, string>) => void) onLoadHandler: null as null | ((options?: Record<string, string>) => void),
onBackPressHandler: null as null | (() => boolean)
}))
const hostEnvironmentMocks = vi.hoisted(() => ({
embeddedInWechatMiniProgram: false
}))
const miniProgramReturnMocks = vi.hoisted(() => ({
returnToWechatMiniProgram: vi.fn(() => Promise.resolve({ status: 'failed' as const, reason: 'bridge-unavailable' as const }))
})) }))
vi.mock('@dcloudio/uni-app', () => ({ vi.mock('@dcloudio/uni-app', () => ({
onLoad: (handler: (options?: Record<string, string>) => void) => { onLoad: (handler: (options?: Record<string, string>) => void) => {
testState.onLoadHandler = handler testState.onLoadHandler = handler
},
onBackPress: (handler: () => boolean) => {
testState.onBackPressHandler = handler
} }
})) }))
vi.mock('@/utils/hostEnvironment', () => ({
isEmbeddedInWechatMiniProgram: () => hostEnvironmentMocks.embeddedInWechatMiniProgram
}))
vi.mock('@/services/WechatMiniProgramBridgeService', () => ({
returnToWechatMiniProgram: () => miniProgramReturnMocks.returnToWechatMiniProgram()
}))
vi.mock('@/usecases/explainUseCase', () => ({ vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: { explainUseCase: {
listGuideStopsPageByHall: vi.fn().mockResolvedValue({ listGuideStopsPageByHall: vi.fn().mockResolvedValue({
@@ -53,6 +73,10 @@ const mountGuideStopList = () => mount(ExplainGuideStopListPage, {
beforeEach(() => { beforeEach(() => {
testState.onLoadHandler = null testState.onLoadHandler = null
testState.onBackPressHandler = null
hostEnvironmentMocks.embeddedInWechatMiniProgram = false
miniProgramReturnMocks.returnToWechatMiniProgram.mockReset()
miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'failed', reason: 'bridge-unavailable' })
vi.stubGlobal('uni', { vi.stubGlobal('uni', {
navigateBack: vi.fn(), navigateBack: vi.fn(),
reLaunch: vi.fn(), reLaunch: vi.fn(),
@@ -155,6 +179,19 @@ describe('讲解对象列表返回', () => {
wrapper.unmount() wrapper.unmount()
}) })
it('小程序直达展厅时返回宿主,不重启 H5 展厅列表', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
const wrapper = mountGuideStopList()
await wrapper.get('[data-testid="list-back"]').trigger('click')
await flushPromises()
expect(miniProgramReturnMocks.returnToWechatMiniProgram).toHaveBeenCalledTimes(1)
expect(uni.reLaunch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('页面栈返回失败时回退至独立展厅列表', async () => { it('页面栈返回失败时回退至独立展厅列表', async () => {
vi.stubGlobal('getCurrentPages', vi.fn(() => [ vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/explain/list', options: {} }, { route: 'pages/explain/list', options: {} },

View File

@@ -6,8 +6,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ExplainListPage from '@/pages/explain/list.vue' import ExplainListPage from '@/pages/explain/list.vue'
const hostEnvironmentMocks = vi.hoisted(() => ({ const hostEnvironmentMocks = vi.hoisted(() => ({
embeddedInWechatMiniProgram: false, embeddedInWechatMiniProgram: false
navigateBackToWechatMiniProgram: vi.fn(() => false) }))
const miniProgramReturnMocks = vi.hoisted(() => ({
returnToWechatMiniProgram: vi.fn(() => Promise.resolve({ status: 'failed' as const, reason: 'bridge-unavailable' as const }))
})) }))
const testState = vi.hoisted(() => ({ const testState = vi.hoisted(() => ({
@@ -25,8 +28,11 @@ vi.mock('@dcloudio/uni-app', () => ({
})) }))
vi.mock('@/utils/hostEnvironment', () => ({ vi.mock('@/utils/hostEnvironment', () => ({
isEmbeddedInWechatMiniProgram: () => hostEnvironmentMocks.embeddedInWechatMiniProgram, isEmbeddedInWechatMiniProgram: () => hostEnvironmentMocks.embeddedInWechatMiniProgram
navigateBackToWechatMiniProgram: () => hostEnvironmentMocks.navigateBackToWechatMiniProgram() }))
vi.mock('@/services/WechatMiniProgramBridgeService', () => ({
returnToWechatMiniProgram: () => miniProgramReturnMocks.returnToWechatMiniProgram()
})) }))
vi.mock('@/usecases/explainUseCase', () => ({ vi.mock('@/usecases/explainUseCase', () => ({
@@ -62,8 +68,8 @@ const mountExplainList = () => mount(ExplainListPage, {
beforeEach(() => { beforeEach(() => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = false hostEnvironmentMocks.embeddedInWechatMiniProgram = false
hostEnvironmentMocks.navigateBackToWechatMiniProgram.mockReset() miniProgramReturnMocks.returnToWechatMiniProgram.mockReset()
hostEnvironmentMocks.navigateBackToWechatMiniProgram.mockReturnValue(false) miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'failed', reason: 'bridge-unavailable' })
testState.onLoadHandler = null testState.onLoadHandler = null
testState.onBackPressHandler = null testState.onBackPressHandler = null
window.location.hash = '#/pages/explain/list' window.location.hash = '#/pages/explain/list'
@@ -100,7 +106,7 @@ describe('讲解展厅列表返回', () => {
it('小程序中的首页讲解 Tab 进入时仍优先回到项目首页', async () => { it('小程序中的首页讲解 Tab 进入时仍优先回到项目首页', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true hostEnvironmentMocks.embeddedInWechatMiniProgram = true
hostEnvironmentMocks.navigateBackToWechatMiniProgram.mockReturnValue(true) miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
vi.stubGlobal('getCurrentPages', vi.fn(() => [ vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/index/index', options: { tab: 'explain' } }, { route: 'pages/index/index', options: { tab: 'explain' } },
{ route: 'pages/explain/list', options: {} } { route: 'pages/explain/list', options: {} }
@@ -110,7 +116,7 @@ describe('讲解展厅列表返回', () => {
await wrapper.get('[data-testid="explain-list-back"]').trigger('click') await wrapper.get('[data-testid="explain-list-back"]').trigger('click')
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 })) expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
expect(hostEnvironmentMocks.navigateBackToWechatMiniProgram).not.toHaveBeenCalled() expect(miniProgramReturnMocks.returnToWechatMiniProgram).not.toHaveBeenCalled()
wrapper.unmount() wrapper.unmount()
}) })
@@ -128,23 +134,24 @@ describe('讲解展厅列表返回', () => {
it('小程序直达独立入口时返回小程序,而不是重启项目首页', async () => { it('小程序直达独立入口时返回小程序,而不是重启项目首页', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true hostEnvironmentMocks.embeddedInWechatMiniProgram = true
hostEnvironmentMocks.navigateBackToWechatMiniProgram.mockReturnValue(true) miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program&weapp=1' window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program&weapp=1'
window.history.replaceState({}, '', window.location.href) window.history.replaceState({}, '', window.location.href)
const wrapper = mountExplainList() const wrapper = mountExplainList()
expect(wrapper.getComponent(ExplainHallSelectStub).props('forceInternalHeader')).toBe(false) expect(wrapper.getComponent(ExplainHallSelectStub).props('forceInternalHeader')).toBe(false)
await wrapper.get('[data-testid="explain-list-back"]').trigger('click') await wrapper.get('[data-testid="explain-list-back"]').trigger('click')
await flushPromises()
expect(uni.navigateBack).not.toHaveBeenCalled() expect(uni.navigateBack).not.toHaveBeenCalled()
expect(hostEnvironmentMocks.navigateBackToWechatMiniProgram).toHaveBeenCalledTimes(1) expect(miniProgramReturnMocks.returnToWechatMiniProgram).toHaveBeenCalledTimes(1)
expect(uni.reLaunch).not.toHaveBeenCalled() expect(uni.reLaunch).not.toHaveBeenCalled()
wrapper.unmount() wrapper.unmount()
}) })
it('微信嵌入态从非讲解首页进入时返回小程序', async () => { it('微信嵌入态从非讲解首页进入时返回小程序', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true hostEnvironmentMocks.embeddedInWechatMiniProgram = true
hostEnvironmentMocks.navigateBackToWechatMiniProgram.mockReturnValue(true) miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program' window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program'
window.history.replaceState({}, '', window.location.href) window.history.replaceState({}, '', window.location.href)
vi.stubGlobal('getCurrentPages', vi.fn(() => [ vi.stubGlobal('getCurrentPages', vi.fn(() => [
@@ -154,9 +161,10 @@ describe('讲解展厅列表返回', () => {
const wrapper = mountExplainList() const wrapper = mountExplainList()
await wrapper.get('[data-testid="explain-list-back"]').trigger('click') await wrapper.get('[data-testid="explain-list-back"]').trigger('click')
await flushPromises()
expect(uni.navigateBack).not.toHaveBeenCalled() expect(uni.navigateBack).not.toHaveBeenCalled()
expect(hostEnvironmentMocks.navigateBackToWechatMiniProgram).toHaveBeenCalledTimes(1) expect(miniProgramReturnMocks.returnToWechatMiniProgram).toHaveBeenCalledTimes(1)
expect(uni.reLaunch).not.toHaveBeenCalled() expect(uni.reLaunch).not.toHaveBeenCalled()
wrapper.unmount() wrapper.unmount()
}) })
@@ -216,7 +224,7 @@ describe('讲解展厅列表返回', () => {
it('小程序直达入口的浏览器返回会委托给小程序宿主', async () => { it('小程序直达入口的浏览器返回会委托给小程序宿主', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true hostEnvironmentMocks.embeddedInWechatMiniProgram = true
hostEnvironmentMocks.navigateBackToWechatMiniProgram.mockReturnValue(true) miniProgramReturnMocks.returnToWechatMiniProgram.mockResolvedValue({ status: 'returned' })
window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program&weapp=1' window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program&weapp=1'
window.history.replaceState({}, '', window.location.href) window.history.replaceState({}, '', window.location.href)
const wrapper = mountExplainList() const wrapper = mountExplainList()
@@ -230,7 +238,7 @@ describe('讲解展厅列表返回', () => {
expect(uni.navigateBack).not.toHaveBeenCalled() expect(uni.navigateBack).not.toHaveBeenCalled()
expect(pushState).toHaveBeenCalledTimes(1) expect(pushState).toHaveBeenCalledTimes(1)
expect(hostEnvironmentMocks.navigateBackToWechatMiniProgram).toHaveBeenCalledTimes(1) expect(miniProgramReturnMocks.returnToWechatMiniProgram).toHaveBeenCalledTimes(1)
expect(uni.reLaunch).not.toHaveBeenCalled() expect(uni.reLaunch).not.toHaveBeenCalled()
wrapper.unmount() wrapper.unmount()
}) })

View File

@@ -4,6 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const target = { latitude: 22.604321, longitude: 114.058765, name: '停车场 A&B', address: '龙华区 #1' } const target = { latitude: 22.604321, longitude: 114.058765, name: '停车场 A&B', address: '龙华区 #1' }
const setEmbeddedRuntime = () => Object.assign(window, { __wxjs_environment: 'miniprogram' }) const setEmbeddedRuntime = () => Object.assign(window, { __wxjs_environment: 'miniprogram' })
const addWechatSdkMarker = () => {
const script = document.createElement('script')
script.dataset.wechatJsSdk = 'true'
document.head.appendChild(script)
}
const loadService = async () => { const loadService = async () => {
vi.resetModules() vi.resetModules()
@@ -33,6 +38,7 @@ describe('微信小程序 web-view 桥接', () => {
it('等待延迟注入的 bridge 后导航到编码后的宿主页', async () => { it('等待延迟注入的 bridge 后导航到编码后的宿主页', async () => {
setEmbeddedRuntime() setEmbeddedRuntime()
addWechatSdkMarker()
const { openWechatMiniProgramLocation } = await loadService() const { openWechatMiniProgramLocation } = await loadService()
const result = openWechatMiniProgramLocation(target) const result = openWechatMiniProgramLocation(target)
await vi.advanceTimersByTimeAsync(100) await vi.advanceTimersByTimeAsync(100)
@@ -84,4 +90,45 @@ describe('微信小程序 web-view 桥接', () => {
await expect(first).resolves.toEqual({ status: 'opened' }) await expect(first).resolves.toEqual({ status: 'opened' })
expect(navigateTo).toHaveBeenCalledTimes(1) expect(navigateTo).toHaveBeenCalledTimes(1)
}) })
it('等待延迟 bridge 后返回宿主小程序', async () => {
setEmbeddedRuntime()
addWechatSdkMarker()
const { returnToWechatMiniProgram } = await loadService()
const result = returnToWechatMiniProgram()
await vi.advanceTimersByTimeAsync(100)
const navigateBack = vi.fn(({ success }) => success())
Object.assign(window, {
wx: {
miniProgram: {
getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }),
navigateBack
}
}
})
await vi.advanceTimersByTimeAsync(100)
await expect(result).resolves.toEqual({ status: 'returned' })
expect(navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
})
it('原生返回失败时委托宿主页执行安全回退', async () => {
setEmbeddedRuntime()
const { MINI_PROGRAM_RETURN_MESSAGE, returnToWechatMiniProgram } = await loadService()
const postMessage = vi.fn()
Object.assign(window, {
wx: {
miniProgram: {
getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }),
navigateBack: ({ fail }: { fail?: (error: { errMsg?: string }) => void }) => fail?.({ errMsg: 'navigateBack:fail page stack is empty' }),
postMessage
}
}
})
await expect(returnToWechatMiniProgram()).resolves.toEqual({ status: 'returned' })
expect(postMessage).toHaveBeenCalledWith({
data: { type: MINI_PROGRAM_RETURN_MESSAGE }
})
})
}) })

View File

@@ -1,7 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { import {
isEmbeddedInWechatMiniProgram, isEmbeddedInWechatMiniProgram
navigateBackToWechatMiniProgram
} from '@/utils/hostEnvironment' } from '@/utils/hostEnvironment'
const createWindow = (overrides: Record<string, unknown> = {}) => ({ const createWindow = (overrides: Record<string, unknown> = {}) => ({
@@ -50,24 +49,3 @@ describe('微信小程序宿主环境识别', () => {
expect(isEmbeddedInWechatMiniProgram()).toBe(false) 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()
})
})