diff --git a/src/components/search/PoiSearchPanel.vue b/src/components/search/PoiSearchPanel.vue index 4e889f4..549027e 100644 --- a/src/components/search/PoiSearchPanel.vue +++ b/src/components/search/PoiSearchPanel.vue @@ -255,12 +255,14 @@ const props = withDefaults(defineProps<{ autofocus?: boolean currentFloorId?: string currentFloorLabel?: string + locationPreviewSelection?: boolean }>(), { initialKeyword: '', variant: 'page', autofocus: false, currentFloorId: '', - currentFloorLabel: '' + currentFloorLabel: '', + locationPreviewSelection: false }) const emit = defineEmits<{ @@ -913,7 +915,7 @@ const handleResultTap = (poi: MuseumPoi) => { // H5 普通 view 的滚动事件并非始终派发,进入详情前直接读取真实位置。 captureResultListScroll() const context = createSearchContext() - if (props.variant === 'home') { + if (props.variant === 'home' || props.locationPreviewSelection) { emit('resultTap', poi, context) return } diff --git a/src/pages/exhibit/detail.vue b/src/pages/exhibit/detail.vue index b77eba3..87214c3 100644 --- a/src/pages/exhibit/detail.vue +++ b/src/pages/exhibit/detail.vue @@ -6,6 +6,15 @@ @back="handleBack" > + + {{ detailState === 'loading' ? '正在加载讲解对象' : detailState === 'missing' ? '缺少讲解对象参数' : '讲解对象加载失败' }} + {{ detailState === 'loading' ? '请稍候' : detailStateMessage }} + + + + + + @@ -183,6 +193,8 @@ const defaultDetail: ExplainDetailPageViewModel = { } const exhibit = ref(defaultDetail) +const detailState = ref<'idle' | 'loading' | 'ready' | 'missing' | 'error'>('idle') +const detailStateMessage = ref('请检查链接后重试。') const globalAudioPlayer = useGlobalAudioPlayer() const activeTopTab = ref('explain') const resolvedHallId = ref('') @@ -467,6 +479,7 @@ const loadExplainDetail = async ( request: NonNullable, 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) diff --git a/src/pages/explain/list.vue b/src/pages/explain/list.vue index b04910e..42f7a99 100644 --- a/src/pages/explain/list.vue +++ b/src/pages/explain/list.vue @@ -14,6 +14,7 @@ :loading="explainLoading" :error="explainError" @hall-click="handleExplainHallClick" + @retry="loadExplainHalls" @back="handleBack" /> diff --git a/src/pages/facility/detail.vue b/src/pages/facility/detail.vue index 3b82bd9..3439203 100644 --- a/src/pages/facility/detail.vue +++ b/src/pages/facility/detail.vue @@ -396,6 +396,13 @@ onLoad(async (options: Record = {}) => { 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) => { diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 09a9da9..2e459c3 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -254,6 +254,7 @@ :loading="explainLoading" :error="explainError" @hall-click="handleExplainHallClick" + @retry="loadExplainHalls" @back="handleExplainBack" /> @@ -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 } diff --git a/src/pages/route/detail.vue b/src/pages/route/detail.vue index 4608cf2..9d33823 100644 --- a/src/pages/route/detail.vue +++ b/src/pages/route/detail.vue @@ -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 | null, fallbac } const applyRouteOptions = async (options: Record = {}) => { + 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 = {}) => { 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 = {}) => { } 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 = {}) => { 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 diff --git a/src/pages/search/index.vue b/src/pages/search/index.vue index f83dd67..6015f4d 100644 --- a/src/pages/search/index.vue +++ b/src/pages/search/index.vue @@ -16,6 +16,8 @@ :initial-keyword="initialKeyword" variant="page" autofocus + :location-preview-selection="isLocationPreviewSelection" + @result-tap="handleLocationPreviewResult" /> @@ -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 @@ -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 diff --git a/src/repositories/SdkGuideRouteRepository.ts b/src/repositories/SdkGuideRouteRepository.ts index 1f2301a..8e15ab5 100644 --- a/src/repositories/SdkGuideRouteRepository.ts +++ b/src/repositories/SdkGuideRouteRepository.ts @@ -4,7 +4,8 @@ import type { GuideRouteTarget } from '@/domain/museum' import { - NAV_ROUTE_READINESS + NAV_ROUTE_READINESS, + applyRouteReadinessGate } from '@/domain/guideReadiness' import { isIndoorNavigableFloor @@ -90,12 +91,17 @@ export class SdkGuideRouteRepository { ) {} async getRouteReadiness(): Promise { + // This is a product gate, not an SDK capability check. SDK diagnostics must + // never enable navigation while the verified graph package is unavailable. + const hardGate = applyRouteReadinessGate(NAV_ROUTE_READINESS) + if (!hardGate.ready) return hardGate + try { const diagnostics = await this.sdkApiProvider.getMapDiagnostics() const floors = (diagnostics.floors || []).filter(isIndoorNavigableFloor) if (!floors.length) { - return createUnavailableReadiness('SDK 地图楼层数据为空') + return applyRouteReadinessGate(createUnavailableReadiness('SDK 地图楼层数据为空')) } // 地图级 diagnostics 的楼层摘要只保证 routePlanningReady/navigablePlaceCount; @@ -131,7 +137,7 @@ export class SdkGuideRouteRepository { navigablePlaceCount: floor.navigablePlaceCount })) }) - return createUnavailableReadiness('无法定位,当前仅支持点位位置预览') + return applyRouteReadinessGate(createUnavailableReadiness('无法定位,当前仅支持点位位置预览')) } // 如果有楼层未就绪,给出警告但仍然可用 @@ -139,23 +145,23 @@ export class SdkGuideRouteRepository { const floorLabels = notReadyFloors .map((f) => f.floorName || f.floorCode || String(f.floorId)) .join('、') - return { + return applyRouteReadinessGate({ ready: true, message: `部分楼层位置关系数据未就绪:${floorLabels},可查看已就绪楼层的位置关系`, requiredData: [] - } + }) } - return { + return applyRouteReadinessGate({ ready: true, message: 'SDK 位置关系数据已接入,可进行位置预览', requiredData: [] - } + }) } catch (error) { console.error('[SDKRouteReadiness] 读取 SDK 路线状态失败', error) - return createUnavailableReadiness( + return applyRouteReadinessGate(createUnavailableReadiness( error instanceof Error ? error.message : 'SDK 位置关系数据加载失败' - ) + )) } } @@ -179,6 +185,8 @@ export class SdkGuideRouteRepository { } async listRouteTargets(): Promise { + const readiness = await this.getRouteReadiness() + if (!readiness.ready) return [] if (this.targetsCache) return this.targetsCache const diagnostics = await this.sdkApiProvider.getMapDiagnostics() diff --git a/src/usecases/guideRouteUseCase.ts b/src/usecases/guideRouteUseCase.ts index 48d86dc..c9f6d65 100644 --- a/src/usecases/guideRouteUseCase.ts +++ b/src/usecases/guideRouteUseCase.ts @@ -13,6 +13,10 @@ import { import { createGuideRouteRepository } from '@/repositories/createGuideRouteRepository' +import { + applyRouteReadinessGate, + NAV_ROUTE_READINESS +} from '@/domain/guideReadiness' const toSearchText = (target: GuideRouteTarget) => [ target.name, @@ -41,13 +45,15 @@ export class GuideRouteUseCase { constructor(private readonly repository: GuideRouteRepository = createGuideRouteRepository()) {} async getRouteReadiness() { - if (this.readinessCache) return this.readinessCache + if (this.readinessCache) return applyRouteReadinessGate(this.readinessCache) - this.readinessCache = await this.repository.getRouteReadiness() - return this.readinessCache + this.readinessCache = applyRouteReadinessGate(await this.repository.getRouteReadiness()) + return applyRouteReadinessGate(this.readinessCache) } async listTargets() { + const readiness = await this.getRouteReadiness() + if (!readiness.ready) return [] if (this.targetsCache) return this.targetsCache this.targetsCache = (await this.repository.listRouteTargets()) @@ -69,11 +75,11 @@ export class GuideRouteUseCase { async planRoute(request: GuideRoutePlanRequest): Promise { try { - const readiness = await this.getRouteReadiness() + const readiness = applyRouteReadinessGate(await this.getRouteReadiness()) if (!readiness.ready) { return { route: null, - error: readiness.message || '当前可查看位置预览和位置关系,暂不提供正式室内导航' + error: readiness.message || NAV_ROUTE_READINESS.message } } diff --git a/src/utils/guideTopTabs.ts b/src/utils/guideTopTabs.ts index 8c9dcd3..25aca21 100644 --- a/src/utils/guideTopTabs.ts +++ b/src/utils/guideTopTabs.ts @@ -56,9 +56,20 @@ export const getLastGuideLocationPreviewUrl = () => { return window.sessionStorage.getItem(GUIDE_LOCATION_PREVIEW_STORAGE_KEY) || '' } +export const clearLastGuideLocationPreviewUrl = () => { + if (typeof window === 'undefined') return + window.sessionStorage.removeItem(GUIDE_LOCATION_PREVIEW_STORAGE_KEY) +} + +export const consumeLastGuideLocationPreviewUrl = () => { + const url = getLastGuideLocationPreviewUrl() + clearLastGuideLocationPreviewUrl() + return url +} + export const navigateToGuideTopTab = (tab: GuideTopTab) => { if (tab === 'guide') { - const lastLocationPreviewUrl = getLastGuideLocationPreviewUrl() + const lastLocationPreviewUrl = consumeLastGuideLocationPreviewUrl() if (lastLocationPreviewUrl) { uni.reLaunch({ url: lastLocationPreviewUrl diff --git a/src/utils/locationPreviewSelection.ts b/src/utils/locationPreviewSelection.ts new file mode 100644 index 0000000..e2e6347 --- /dev/null +++ b/src/utils/locationPreviewSelection.ts @@ -0,0 +1,18 @@ +export const LOCATION_PREVIEW_SELECTION_MODE = 'location-preview' + +export const isLocationPreviewSelectionMode = (value: unknown) => ( + value === LOCATION_PREVIEW_SELECTION_MODE +) + +export const locationPreviewSearchUrl = () => ( + `/pages/search/index?selection=${LOCATION_PREVIEW_SELECTION_MODE}` +) + +export const locationPreviewDetailUrl = (facilityId: string, target: string) => { + const params = new URLSearchParams({ + facilityId, + target, + state: 'preview' + }) + return `/pages/route/detail?${params.toString()}` +} diff --git a/tests/unit/IndexPoiSearchFlow.spec.ts b/tests/unit/IndexPoiSearchFlow.spec.ts index 0448606..483f985 100644 --- a/tests/unit/IndexPoiSearchFlow.spec.ts +++ b/tests/unit/IndexPoiSearchFlow.spec.ts @@ -501,7 +501,7 @@ describe('首页搜索与地图闭环', () => { await viewHallAction.trigger('tap') await flushPromises() - expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/hall/detail?id=hall-l2') + expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/explain/guide-stop-list?hallId=hall-l2') await testState.onShowHandler?.() await flushPromises() expect(testState.resetToViewBaselineCalls).toHaveLength(0) @@ -528,8 +528,8 @@ describe('首页搜索与地图闭环', () => { expect(testState.resetToViewBaselineCalls).toHaveLength(1) }) - it('模型点选展厅的相关讲解可进入展品详情,并在返回时复位原楼层', async () => { - testState.explainResults = [{ type: 'exhibit', exhibitId: 'exhibit-l2' }] + it('模型点选展厅的相关讲解进入该展厅的讲解对象列表,并在返回时复位原楼层', async () => { + testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) @@ -546,7 +546,7 @@ describe('首页搜索与地图闭环', () => { await relatedActions[1]!.trigger('tap') await flushPromises() - expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/exhibit/detail?id=exhibit-l2') + expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/explain/guide-stop-list?hallId=hall-l2') confirmLatestNavigation() await testState.onShowHandler?.() await flushPromises() @@ -559,7 +559,7 @@ describe('首页搜索与地图闭环', () => { expect(wrapper.find('.guide-poi-card').exists()).toBe(false) }) - it('模型点选展厅的相关讲解优先进入展厅详情并保留一次性返回上下文', async () => { + it('模型点选展厅的相关讲解优先进入讲解对象列表并保留一次性返回上下文', async () => { testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) @@ -574,7 +574,7 @@ describe('首页搜索与地图闭环', () => { await wrapper.findAll('.poi-action')[1]!.trigger('tap') await flushPromises() - expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/hall/detail?id=hall-l2') + expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/explain/guide-stop-list?hallId=hall-l2') confirmLatestNavigation() await testState.onShowHandler?.() await flushPromises()