修复H5导览与讲解状态闭环
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-19 17:07:40 +08:00
parent 97788a3e9f
commit 3cba97b786
12 changed files with 147 additions and 44 deletions

View File

@@ -6,6 +6,15 @@
@back="handleBack"
>
<view class="explain-detail-page">
<view v-if="detailState !== 'ready'" class="detail-page-state" :class="`is-${detailState}`" data-testid="exhibit-detail-state">
<text class="detail-page-state-title">{{ detailState === 'loading' ? '正在加载讲解对象' : detailState === 'missing' ? '缺少讲解对象参数' : '讲解对象加载失败' }}</text>
<text class="detail-page-state-desc">{{ detailState === 'loading' ? '请稍候' : detailStateMessage }}</text>
<view v-if="detailState !== 'loading'" class="detail-page-state-actions">
<button v-if="detailState === 'error'" class="detail-page-state-button" @tap="retryDetail">重新加载</button>
<button class="detail-page-state-button secondary" @tap="handleBack">返回讲解列表</button>
</view>
</view>
<template v-else>
<view class="immersive-hero" :class="{ 'has-real-image': heroImage }">
<image
v-if="heroImage"
@@ -129,6 +138,7 @@
<text class="location-text">查看位置</text>
</view>
</view>
</template>
</view>
</GuidePageFrame>
</template>
@@ -183,6 +193,8 @@ const defaultDetail: ExplainDetailPageViewModel = {
}
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
const detailState = ref<'idle' | 'loading' | 'ready' | 'missing' | 'error'>('idle')
const detailStateMessage = ref('请检查链接后重试。')
const globalAudioPlayer = useGlobalAudioPlayer()
const activeTopTab = ref<GuideTopTab>('explain')
const resolvedHallId = ref('')
@@ -467,6 +479,7 @@ const loadExplainDetail = async (
request: NonNullable<typeof detailEntryRequest.value>,
lang: AudioLanguage
) => {
detailState.value = 'loading'
const exhibitData = await explainUseCase.enterExplainDetail({
...request,
lang
@@ -487,6 +500,7 @@ const loadExplainDetail = async (
}
void loadFullDetailTextForLanguage(request, resolvedLanguage, exhibit.value)
detailState.value = 'ready'
}
onLoad(async (options: any = {}) => {
@@ -497,7 +511,10 @@ onLoad(async (options: any = {}) => {
}
const exhibitId = Array.isArray(options.id) ? options.id[0] : options.id
if (!exhibitId) return
if (!exhibitId) {
detailState.value = 'missing'
return
}
const rawTargetType = Array.isArray(options.targetType) ? options.targetType[0] : options.targetType
const targetType: AudioPlayTargetType | undefined = rawTargetType === 'ITEM'
@@ -527,13 +544,21 @@ onLoad(async (options: any = {}) => {
await loadExplainDetail(detailEntryRequest.value, lang)
} catch (error) {
console.error('讲解详情加载失败:', error)
uni.showToast({
title: '讲解详情加载失败,请稍后重试',
icon: 'none'
})
detailState.value = 'error'
detailStateMessage.value = error instanceof Error ? error.message : '请检查网络后重试。'
}
})
const retryDetail = async () => {
if (!detailEntryRequest.value || detailState.value === 'loading') return
try {
await loadExplainDetail(detailEntryRequest.value, selectedAudioLanguage.value)
} catch (error) {
detailState.value = 'error'
detailStateMessage.value = error instanceof Error ? error.message : '请检查网络后重试。'
}
}
// iOS H5 may hide a uni page before its route is fully unloaded.
onHide(closeDetailAudioOnExit)
onUnload(closeDetailAudioOnExit)

View File

@@ -14,6 +14,7 @@
:loading="explainLoading"
:error="explainError"
@hall-click="handleExplainHallClick"
@retry="loadExplainHalls"
@back="handleBack"
/>
</view>

View File

@@ -396,6 +396,13 @@ onLoad(async (options: Record<string, unknown> = {}) => {
const handleFloorChange = (floor: string) => {
activeFloor.value = floor
const currentFloorId = normalizeGuideFloorId(floor)
if (requestedDetailFloorId.value && currentFloorId !== requestedDetailFloorId.value) {
// The selected target stays pinned to its real floor; do not leave a focus
// marker on a different committed renderer floor.
targetFocusRequest.value = null
locationErrorMessage.value = `目标位于 ${facility.value.floor},当前浏览 ${floor}`
}
}
const handleTargetFocus = (result: TargetPoiFocusResult) => {

View File

@@ -254,6 +254,7 @@
:loading="explainLoading"
:error="explainError"
@hall-click="handleExplainHallClick"
@retry="loadExplainHalls"
@back="handleExplainBack"
/>
</view>
@@ -1202,9 +1203,9 @@ const navigateFromSelectedPoiToDetail = (url: string) => {
})
}
const navigateToHallDetail = (hallId: string) => {
const navigateToHallExplainObjects = (hallId: string, hallName = '讲解') => {
navigateFromSelectedPoiToDetail(
`/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
explainGuideStopListUrl(hallId, hallName)
)
}
@@ -1218,7 +1219,7 @@ const handleViewSelectedHall = async () => {
return
}
navigateToHallDetail(hall.id)
navigateToHallExplainObjects(hall.id, hall.name)
}
const handleSelectedPoiRelated = async () => {
@@ -1226,24 +1227,16 @@ const handleSelectedPoiRelated = async () => {
const hall = await resolveSelectedPoiHall()
if (hall) {
navigateToHallDetail(hall.id)
navigateToHallExplainObjects(hall.id, hall.name)
return
}
const keyword = selectedGuidePoi.value.hallName || selectedGuidePoi.value.name
const results = await explainUseCase.searchExplain(keyword)
const firstHall = results.find((item) => item.type === 'hall' && item.hallId)
const firstExhibit = results.find((item) => item.type === 'exhibit' && item.exhibitId)
if (firstHall?.hallId) {
navigateToHallDetail(firstHall.hallId)
return
}
if (firstExhibit?.exhibitId) {
navigateFromSelectedPoiToDetail(
`/pages/exhibit/detail?id=${encodeURIComponent(firstExhibit.exhibitId)}&tab=explain`
)
navigateToHallExplainObjects(firstHall.hallId, selectedGuidePoi.value.hallName || '讲解')
return
}

View File

@@ -207,9 +207,13 @@ import {
} from '@/usecases/guideRouteUseCase'
import {
navigateToGuideTopTab,
clearLastGuideLocationPreviewUrl,
saveLastGuideLocationPreviewUrl,
type GuideTopTab
} from '@/utils/guideTopTabs'
import {
locationPreviewSearchUrl
} from '@/utils/locationPreviewSelection'
type RouteViewState = 'preview' | 'location-error' | 'outdoor-preview'
type GuideMode = '2d' | '3d'
@@ -503,8 +507,10 @@ const requestTargetFocus = (target: Partial<RouteTargetLocation> | null, fallbac
}
const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
const requestSequence = ++routeOptionsSequence
await loadGuideFloors()
await refreshRouteReadinessState()
if (requestSequence !== routeOptionsSequence) return false
const facilityId = normalizeStringOption(options.facilityId)
const target = normalizeStringOption(options.target)
@@ -512,6 +518,7 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
const startLocation = createStartLocationFromOptions(options)
if (!facilityId && !target) {
clearLastGuideLocationPreviewUrl()
hasRouteTarget.value = false
route.value = initialLocationPreviewPlan()
routeViewState.value = 'preview'
@@ -524,6 +531,7 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
}
const matchedPoi = facilityId ? await guideUseCase.getPoiById(facilityId) : null
if (requestSequence !== routeOptionsSequence) return false
if (!matchedPoi) {
hasRouteTarget.value = false
@@ -560,6 +568,8 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
return true
}
let routeOptionsSequence = 0
onLoad((options: any) => {
setLocationPreviewTitle()
void applyRouteOptions(options)
@@ -585,7 +595,6 @@ const syncH5RouteOptions = () => {
}
onMounted(() => {
syncH5RouteOptions()
window.addEventListener('hashchange', syncH5RouteOptions)
})
@@ -596,10 +605,10 @@ onUnmounted(() => {
const handleSelectTarget = () => {
uni.redirectTo({
url: '/pages/search/index',
url: locationPreviewSearchUrl(),
fail: () => {
uni.navigateTo({
url: '/pages/search/index'
url: locationPreviewSearchUrl()
})
}
})
@@ -874,6 +883,7 @@ const handleTopTabChange = (tab: GuideTopTab) => {
}
const handlePageBack = () => {
clearLastGuideLocationPreviewUrl()
if (isManualLocationPanelOpen.value) {
handleManualLocationCancel()
return

View File

@@ -16,6 +16,8 @@
:initial-keyword="initialKeyword"
variant="page"
autofocus
:location-preview-selection="isLocationPreviewSelection"
@result-tap="handleLocationPreviewResult"
/>
</view>
</view>
@@ -26,8 +28,15 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
import {
isLocationPreviewSelectionMode,
locationPreviewDetailUrl
} from '@/utils/locationPreviewSelection'
import type { MuseumPoi } from '@/domain/museum'
import type { PoiSearchContext } from '@/domain/poiSearch'
const initialKeyword = ref('')
const isLocationPreviewSelection = ref(false)
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const searchPanelRef = ref<{
restoreResultListScroll?: () => Promise<void>
@@ -39,6 +48,7 @@ onLoad((options: any) => {
: ''
initialKeyword.value = keyword
isLocationPreviewSelection.value = isLocationPreviewSelectionMode(options.selection)
})
onShow(async () => {
@@ -47,11 +57,23 @@ onShow(async () => {
})
const handleBackToMap = () => {
if (isLocationPreviewSelection.value) {
uni.redirectTo({ url: '/pages/route/detail' })
return
}
uni.reLaunch({
url: '/pages/index/index?tab=guide'
})
}
const handleLocationPreviewResult = (poi: MuseumPoi, _context: PoiSearchContext) => {
if (!isLocationPreviewSelection.value) return
uni.redirectTo({
url: locationPreviewDetailUrl(poi.id, poi.name),
fail: () => uni.navigateTo({ url: locationPreviewDetailUrl(poi.id, poi.name) })
})
}
// #ifdef H5
let removeSearchViewportResizeListener: (() => void) | null = null