From 298e7d7606b487773276784f10708c1d78ab1a21 Mon Sep 17 00:00:00 2001 From: cxk <119064883@qq.com> Date: Mon, 20 Jul 2026 08:09:58 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=A6=96=E9=A1=B5=E7=82=B9?= =?UTF-8?q?=E4=BD=8D=E8=AF=A6=E6=83=85=E7=9B=AE=E6=A0=87=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/facility/detail.vue | 7 +- src/pages/index/index.vue | 205 ++++++++++++-------------- src/usecases/poiDetailUseCase.ts | 159 ++++++++++++++++++++ src/view-models/guideViewModels.ts | 5 +- tests/unit/IndexPoiSearchFlow.spec.ts | 145 ++++++++++++++++-- 5 files changed, 388 insertions(+), 133 deletions(-) create mode 100644 src/usecases/poiDetailUseCase.ts diff --git a/src/pages/facility/detail.vue b/src/pages/facility/detail.vue index 3439203..050997a 100644 --- a/src/pages/facility/detail.vue +++ b/src/pages/facility/detail.vue @@ -248,7 +248,7 @@ const resolveFacilityPoi = async () => { if (poiById) return poiById // 搜索结果必须按稳定 ID 解析,避免同名点位被定位到错误楼层。 - if (searchContext.value.source === 'search') return null + if (searchContext.value.source === 'search' || searchContext.value.source === 'guide-poi') return null const poiByHall = await resolvePoiFromHall() if (poiByHall) return poiByHall @@ -273,7 +273,10 @@ const resolveFacilityPoi = async () => { } const resolveRenderPoi = async () => { - const resolveByExactId = searchContext.value.source === 'search' && Boolean(facility.value.id) + const resolveByExactId = ( + searchContext.value.source === 'search' + || searchContext.value.source === 'guide-poi' + ) && Boolean(facility.value.id) const normalizedTarget = normalizePoiName(facility.value.name) if (!resolveByExactId && !normalizedTarget) return null diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 12fa43f..4edd229 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -99,11 +99,11 @@ - - 查看展厅 + + {{ selectedGuidePoiDetailActionText }} @@ -316,6 +316,11 @@ import { import { explainUseCase } from '@/usecases/explainUseCase' +import { + poiDetailUseCase, + type PoiDetailSource, + type PoiDetailTarget +} from '@/usecases/poiDetailUseCase' import type { GuideRenderPoi } from '@/domain/guideModel' @@ -341,7 +346,6 @@ import type { GuideRouteResult, GuideRouteTarget, MuseumFloor, - MuseumHall, MuseumPoi } from '@/domain/museum' import { getFloorSortLevel } from '@/domain/guideFloor' @@ -397,8 +401,19 @@ const loadingFloorId = ref('') const renderedFloorId = ref('') const failedFloorId = ref('') const selectedGuidePoi = ref(null) -const selectedGuidePoiExplainHall = ref(null) -let selectedGuidePoiExplainHallRequestId = 0 +const selectedGuidePoiDetailTarget = ref(null) +let poiDetailResolutionRequestId = 0 + +type PoiDetailUiContext = Readonly<{ + source: PoiDetailSource + categoryModeActive: boolean + visiblePoiIds: string[] | null +}> + +let pendingPoiDetailUiContext: Readonly<{ + requestId: number + value: PoiDetailUiContext +}> | null = null // 状态 const currentTab = ref(initialTopTab()) @@ -726,9 +741,18 @@ const isGuidePoiHallLike = (poi: GuideRenderPoi | null) => Boolean( const selectedGuidePoiIsHall = computed(() => isGuidePoiHallLike(selectedGuidePoi.value)) const selectedGuidePoiCollapsedSubtitle = computed(() => ( - selectedGuidePoiExplainHall.value ? '点按展开查看展厅' : selectedGuidePoiSubtitle.value + selectedGuidePoiDetailTarget.value?.detailType === 'hall' + ? '点按展开查看展厅' + : selectedGuidePoiSubtitle.value )) +const selectedGuidePoiDetailActionText = computed(() => { + const detailType = selectedGuidePoiDetailTarget.value?.detailType + if (detailType === 'hall') return '查看展厅' + if (detailType === 'exhibit') return '查看展品' + return '查看详情' +}) + const routeSimulationStepText = computed(() => { if (!activeRoutePreview.value) return '位置预览 · 查看位置关系' const hasConnector = activeRoutePreview.value.connectorPoints.length > 0 @@ -1127,110 +1151,63 @@ const togglePoiCardCollapsed = () => { isPoiCardCollapsed.value = !isPoiCardCollapsed.value } -const normalizeHallMatchText = (value?: string) => (value || '') - .trim() - .replace(/[\s()()【】[\]_-]/g, '') - .replace(/^展厅\d+/, '') - .toLowerCase() - -const resolvePoiExplainHall = async (poi: GuideRenderPoi): Promise => { - const halls = await explainUseCase.listHalls() - const matchedByPoiId = halls.find((hall) => ( - hall.poiId === poi.id - || hall.location?.poiId === poi.id - )) - if (matchedByPoiId) return matchedByPoiId - - const hallId = poi.hallId - if (hallId) { - const matchedById = halls.find((hall) => hall.id === hallId) - if (matchedById) return matchedById - } - - const matchedByEntrance = halls.find((hall) => ( - (poi.entrances || []).some((entrance) => ( - hall.poiId === entrance.sourcePlaceId - || hall.poiId === entrance.id - )) - )) - if (matchedByEntrance) return matchedByEntrance - - const poiHallName = normalizeHallMatchText(poi.hallName || poi.name) - return halls.find((hall) => { - const hallName = normalizeHallMatchText(hall.name) - return hallName && poiHallName && hallName === poiHallName - }) || null -} - watch(selectedGuidePoi, (poi) => { - const requestId = ++selectedGuidePoiExplainHallRequestId - selectedGuidePoiExplainHall.value = null - if (!poi || !isGuidePoiHallLike(poi)) return + const requestId = ++poiDetailResolutionRequestId + selectedGuidePoiDetailTarget.value = null + if (!poi) return - void resolvePoiExplainHall(poi) - .then((hall) => { + void poiDetailUseCase.resolve(poi) + .then((target) => { if ( - requestId !== selectedGuidePoiExplainHallRequestId + requestId !== poiDetailResolutionRequestId || selectedGuidePoi.value?.id !== poi.id ) return - selectedGuidePoiExplainHall.value = hall + selectedGuidePoiDetailTarget.value = target }) .catch((error) => { - if (requestId !== selectedGuidePoiExplainHallRequestId) return - console.warn('点位讲解展厅关联解析失败:', error) + if (requestId !== poiDetailResolutionRequestId) return + console.warn('点位详情目标解析失败:', error) }) }, { flush: 'sync' }) -const navigateToPoiDetail = ({ - url, - floorId, - failureMessage -}: { - url: string - floorId: string - failureMessage: string -}) => { - const returnContext = guideModelState.beginPoiDetailReturn(floorId) +const navigateToPoiDetailTarget = ( + target: PoiDetailTarget, + uiContext: PoiDetailUiContext +) => { + const returnContext = guideModelState.beginPoiDetailReturn(target.floorId) + pendingPoiDetailUiContext = { + requestId: returnContext.requestId, + value: uiContext + } uni.navigateTo({ - url, + url: target.url, success: () => { guideModelState.confirmPoiDetailReturn(returnContext.requestId) }, fail: () => { guideModelState.cancelPoiDetailReturn(returnContext.requestId) + if (pendingPoiDetailUiContext?.requestId === returnContext.requestId) { + pendingPoiDetailUiContext = null + } uni.showToast({ - title: failureMessage, + title: target.failureMessage, icon: 'none' }) } }) } -const navigateFromSelectedPoiToDetail = (url: string) => { - const poi = selectedGuidePoi.value - if (!poi) return - - // Detail pages do not own the mounted renderer. The homepage consumes this once on return. - navigateToPoiDetail({ - url, - floorId: poi.floorId, - failureMessage: '讲解详情打开失败,请重试' +const handleViewSelectedPoiDetail = () => { + const target = selectedGuidePoiDetailTarget.value + if (!target) return + navigateToPoiDetailTarget(target, { + source: 'map', + categoryModeActive: false, + visiblePoiIds: null }) } -const navigateToHallExplainObjects = (hallId: string, hallName = '讲解') => { - navigateFromSelectedPoiToDetail( - explainGuideStopListUrl(hallId, hallName) - ) -} - -const handleViewSelectedHall = () => { - const hall = selectedGuidePoiExplainHall.value - if (!hall) return - navigateToHallExplainObjects(hall.id, hall.name) -} - const loadRouteTargets = async (keyword = '') => { routeTargetsLoading.value = true routeTargetsError.value = '' @@ -1579,10 +1556,20 @@ onShow(async () => { await nextTick() const detailReturnContext = guideModelState.consumePoiDetailReturn() if (detailReturnContext) { + const uiContext = pendingPoiDetailUiContext?.requestId === detailReturnContext.requestId + ? pendingPoiDetailUiContext.value + : null + pendingPoiDetailUiContext = null await resetGuideModelToFloorBaseline({ rendererReason: 'poi-detail-close', floorId: detailReturnContext.floorId }) + if (uiContext?.source === 'search') { + homeCategoryModeActive.value = uiContext.categoryModeActive + searchVisiblePoiIds.value = uiContext.visiblePoiIds + ? [...uiContext.visiblePoiIds] + : null + } await homeSearchPanelRef.value?.restoreResultListScroll?.() return } @@ -1658,38 +1645,28 @@ const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => { } } -const createSearchDetailUrl = (poi: MuseumPoi, context: PoiSearchContext) => { - const resultCount = homeCategoryModeActive.value - ? context.floorResultCount - : context.resultCount - const params = new URLSearchParams({ - source: 'search', - searchOrigin: context.origin, - searchKeyword: context.keyword, - searchCategoryId: context.categoryId, - searchFloorId: context.floorId, - searchFloorLabel: context.floorLabel, - resultCount: String(Math.max(1, resultCount)), - floorResultCount: String(context.floorResultCount), - visiblePoiIds: context.visiblePoiIds.join(','), - listScrollTop: String(context.listScrollTop), - id: poi.id, - target: poi.name, - floorId: poi.floorId, - floorLabel: poi.floorLabel - }) - - return `/pages/facility/detail?${params.toString()}` -} - const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => { - // The homepage keeps its renderer mounted; detail close is consumed by onShow once. + const requestId = ++poiDetailResolutionRequestId + const categoryModeActive = homeCategoryModeActive.value + const visiblePoiIds = categoryModeActive + ? [...context.visiblePoiIds] + : searchVisiblePoiIds.value + ? [...searchVisiblePoiIds.value] + : null searchTargetFocusRequest.value = null - navigateToPoiDetail({ - url: createSearchDetailUrl(poi, context), - floorId: poi.floorId, - failureMessage: '点位详情打开失败,请重试' - }) + try { + const target = await poiDetailUseCase.resolve(poi) + if (requestId !== poiDetailResolutionRequestId) return + navigateToPoiDetailTarget(target, { + source: 'search', + categoryModeActive, + visiblePoiIds + }) + } catch (error) { + if (requestId !== poiDetailResolutionRequestId) return + console.warn('搜索点位详情目标解析失败:', error) + uni.showToast({ title: '点位详情解析失败,请重试', icon: 'none' }) + } } const handleHomeSearchCancel = () => { diff --git a/src/usecases/poiDetailUseCase.ts b/src/usecases/poiDetailUseCase.ts new file mode 100644 index 0000000..cb0d780 --- /dev/null +++ b/src/usecases/poiDetailUseCase.ts @@ -0,0 +1,159 @@ +import type { + MuseumCategory, + MuseumHall, + MuseumPoiEntrance, + MuseumPoiKind +} from '@/domain/museum' +import { explainUseCase } from '@/usecases/explainUseCase' +import { explainGuideStopListUrl } from '@/utils/explainNavigation' + +export type PoiDetailType = 'hall' | 'exhibit' | 'facility' + +export type PoiDetailSource = 'map' | 'search' + +export interface PoiDetailSourcePoi { + id: string + name: string + floorId: string + floorLabel?: string + primaryCategory?: MuseumCategory | string + kind?: MuseumPoiKind + hallId?: string + hallName?: string + exhibitId?: string + sourcePlaceId?: string + entrances?: MuseumPoiEntrance[] +} + +export interface PoiDetailTarget { + poiId: string + floorId: string + detailType: PoiDetailType + detailId: string + hallId?: string + exhibitId?: string + url: string + failureMessage: string +} + +const normalizeMatchText = (value?: string) => (value || '') + .trim() + .replace(/[\s()()【】[\]_-]/g, '') + .replace(/^展厅\d*/, '') + .toLowerCase() + +const categoryIdOf = (poi: PoiDetailSourcePoi) => ( + typeof poi.primaryCategory === 'string' + ? poi.primaryCategory + : poi.primaryCategory?.id || '' +) + +const isHallNameFallbackAllowed = (poi: PoiDetailSourcePoi) => { + const categoryId = categoryIdOf(poi) + return ( + poi.kind === 'hall' + || poi.kind === 'hall_entrance' + || categoryId === 'touring_poi' + || categoryId === 'exhibition_hall' + || categoryId === 'exhibition_hall_entrance' + ) +} + +const appendQuery = (url: string, values: Record) => { + const [path, query = ''] = url.split('?') + const params = new URLSearchParams(query) + Object.entries(values).forEach(([key, value]) => params.set(key, String(value))) + return `${path}?${params.toString()}` +} + +const createCanonicalDetailUrl = (url: string, poi: PoiDetailSourcePoi) => ( + // 详情 URL 只承载稳定目标,搜索筛选等首页状态由调用方单独保存。 + appendQuery(url, { + source: 'guide-poi', + poiId: poi.id, + floorId: poi.floorId + }) +) + +const resolveHallByStableRelationship = ( + poi: PoiDetailSourcePoi, + halls: MuseumHall[] +) => { + if (poi.hallId) { + const hall = halls.find((item) => item.id === poi.hallId) + if (hall) return hall + } + + const stablePoiIds = new Set([ + poi.id, + poi.sourcePlaceId, + ...(poi.entrances || []).flatMap((entrance) => [ + entrance.id, + entrance.sourcePlaceId + ]) + ].filter((id): id is string => Boolean(id))) + + return halls.find((hall) => ( + (hall.poiId ? stablePoiIds.has(hall.poiId) : false) + || (hall.location?.poiId ? stablePoiIds.has(hall.location.poiId) : false) + )) || null +} + +const resolveHallByControlledNameFallback = ( + poi: PoiDetailSourcePoi, + halls: MuseumHall[] +) => { + // 名称只对明确的展厅语义兜底,避免普通空间误入讲解列表。 + if (!isHallNameFallbackAllowed(poi)) return null + const poiHallName = normalizeMatchText(poi.hallName || poi.name) + if (!poiHallName) return null + return halls.find((hall) => normalizeMatchText(hall.name) === poiHallName) || null +} + +export class PoiDetailUseCase { + async resolve(poi: PoiDetailSourcePoi): Promise { + if (poi.exhibitId) { + return { + poiId: poi.id, + floorId: poi.floorId, + detailType: 'exhibit', + detailId: poi.exhibitId, + exhibitId: poi.exhibitId, + url: createCanonicalDetailUrl(`/pages/exhibit/detail?id=${encodeURIComponent(poi.exhibitId)}`, poi), + failureMessage: '展品详情打开失败,请重试' + } + } + + const halls = await explainUseCase.listHalls().catch((error) => { + console.warn('展厅关联数据加载失败,点位详情降级为普通设施:', error) + return [] + }) + const hall = resolveHallByStableRelationship(poi, halls) + || resolveHallByControlledNameFallback(poi, halls) + if (hall) { + return { + poiId: poi.id, + floorId: poi.floorId, + detailType: 'hall', + detailId: hall.id, + hallId: hall.id, + url: createCanonicalDetailUrl(explainGuideStopListUrl(hall.id, hall.name), poi), + failureMessage: '展厅讲解列表打开失败,请重试' + } + } + + return { + poiId: poi.id, + floorId: poi.floorId, + detailType: 'facility', + detailId: poi.id, + url: createCanonicalDetailUrl( + `/pages/facility/detail?id=${encodeURIComponent(poi.id)}&target=${encodeURIComponent(poi.name)}`, + poi + ), + failureMessage: '点位详情打开失败,请重试' + } + } +} + +export const poiDetailUseCase = new PoiDetailUseCase() diff --git a/src/view-models/guideViewModels.ts b/src/view-models/guideViewModels.ts index 5e5bb08..f2fa975 100644 --- a/src/view-models/guideViewModels.ts +++ b/src/view-models/guideViewModels.ts @@ -25,7 +25,7 @@ export interface FacilityDetailViewModel { } export interface FacilityDetailSearchContext { - source: 'search' | 'direct' + source: 'search' | 'guide-poi' | 'direct' searchOrigin: 'home' | 'page' | '' searchKeyword: string searchCategoryId: string @@ -84,6 +84,7 @@ const parseNonNegativeInteger = (value: unknown) => { export const parseFacilityDetailSearchContext = ( options: Record = {} ): FacilityDetailSearchContext => { + const source = decodeQueryText(options.source) const searchOrigin = decodeQueryText(options.searchOrigin) const visiblePoiIds = decodeQueryText(options.visiblePoiIds) .split(',') @@ -91,7 +92,7 @@ export const parseFacilityDetailSearchContext = ( .filter(Boolean) return { - source: decodeQueryText(options.source) === 'search' ? 'search' : 'direct', + source: source === 'search' || source === 'guide-poi' ? source : 'direct', searchOrigin: searchOrigin === 'home' || searchOrigin === 'page' ? searchOrigin : '', searchKeyword: decodeQueryText(options.searchKeyword), searchCategoryId: decodeQueryText(options.searchCategoryId), diff --git a/tests/unit/IndexPoiSearchFlow.spec.ts b/tests/unit/IndexPoiSearchFlow.spec.ts index 1537ad3..f7b505e 100644 --- a/tests/unit/IndexPoiSearchFlow.spec.ts +++ b/tests/unit/IndexPoiSearchFlow.spec.ts @@ -20,6 +20,12 @@ const testState = vi.hoisted(() => ({ searchResetCount: 0, onShowHandler: null as null | (() => Promise | void), halls: [] as Array<{ id: string; poiId?: string; location?: { poiId?: string }; name: string }>, + hallResolutionPromises: [] as Array>>, explainResults: [] as Array<{ type: 'hall' | 'exhibit' hallId?: string @@ -76,7 +82,9 @@ vi.mock('@/usecases/explainUseCase', () => ({ explainUseCase: { loadExplainHalls: async () => [], loadExplainHallSummaries: async () => ({}), - listHalls: async () => testState.halls, + listHalls: async () => ( + testState.hallResolutionPromises.shift() || Promise.resolve(testState.halls) + ), searchExplain: async () => testState.explainResults } })) @@ -211,6 +219,7 @@ beforeEach(() => { testState.searchResetCount = 0 testState.onShowHandler = null testState.halls = [] + testState.hallResolutionPromises = [] testState.explainResults = [] window.history.replaceState({}, '', '/#/pages/index/index?tab=guide') vi.stubGlobal('uni', { @@ -403,15 +412,10 @@ describe('首页搜索与地图闭环', () => { const navigateTo = vi.mocked(uni.navigateTo) const detailUrl = navigateTo.mock.calls[0]?.[0]?.url as string const query = new URLSearchParams(detailUrl.split('?')[1]) - expect(query.get('source')).toBe('search') - expect(query.get('searchOrigin')).toBe('home') - expect(query.get('searchKeyword')).toBe('卫生间') - expect(query.get('searchCategoryId')).toBe('restroom') - expect(query.get('searchFloorId')).toBe('L2') - expect(query.get('resultCount')).toBe('2') - expect(query.get('floorResultCount')).toBe('2') - expect(query.get('visiblePoiIds')).toBe(categoryContext.visiblePoiIds.join(',')) - expect(query.get('listScrollTop')).toBe('126') + expect(query.get('source')).toBe('guide-poi') + expect(query.get('id')).toBe(poi.id) + expect(query.get('floorId')).toBe('L2') + expect(query.has('searchKeyword')).toBe(false) expect(testState.searchMountCount).toBe(1) expect(testState.searchUnmountCount).toBe(0) @@ -422,7 +426,7 @@ describe('首页搜索与地图闭环', () => { expect(testState.searchResetCount).toBe(0) expect(testState.searchRestoreCount).toBeGreaterThan(0) expect(map.props('activeFloor')).toBe('L2') - expect(map.props('visiblePoiIds')).toBeNull() + expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) expect(map.props('targetFocusRequest')).toBeNull() const resetCount = testState.searchResetCount @@ -560,7 +564,74 @@ describe('首页搜索与地图闭环', () => { expect(wrapper.find('.guide-poi-card').exists()).toBe(false) }) - it('展厅类型空间没有讲解展厅关联时不显示展厅与讲解按钮', async () => { + it('同一个展厅从地图和搜索进入时使用相同详情路由和 hallId', async () => { + const hallPoi = { + ...poi, + kind: 'hall' as const, + hallId: 'hall-l2', + primaryCategory: { id: 'exhibition_hall', label: '展厅' } + } + testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }] + const wrapper = await mountIndex() + const map = wrapper.getComponent(GuideMapShellStub) + const search = wrapper.getComponent(PoiSearchPanelStub) + + map.vm.$emit('poi-click', { + ...hallPoi, + primaryCategory: 'exhibition_hall', + primaryCategoryZh: '展厅' + }) + await flushPromises() + await wrapper.find('.poi-action.primary').trigger('tap') + await flushPromises() + const mapUrl = vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.url as string + vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.fail?.({ errMsg: 'navigateTo:fail test cleanup' }) + + search.vm.$emit('result-tap', hallPoi, categoryContext) + await flushPromises() + const searchUrl = vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.url as string + const mapRoute = mapUrl.split('?')[0] + const searchRoute = searchUrl.split('?')[0] + const mapQuery = new URLSearchParams(mapUrl.split('?')[1]) + const searchQuery = new URLSearchParams(searchUrl.split('?')[1]) + + expect(mapRoute).toBe('/pages/explain/guide-stop-list') + expect(searchRoute).toBe(mapRoute) + expect(mapQuery.get('hallId')).toBe('hall-l2') + expect(searchQuery.get('hallId')).toBe(mapQuery.get('hallId')) + expect(searchUrl).toBe(mapUrl) + }) + + it('同一个普通设施从地图和搜索进入时都解析为设施详情', async () => { + const wrapper = await mountIndex() + const map = wrapper.getComponent(GuideMapShellStub) + const search = wrapper.getComponent(PoiSearchPanelStub) + + map.vm.$emit('poi-click', { + ...poi, + primaryCategory: 'restroom', + primaryCategoryZh: '卫生间' + }) + await flushPromises() + await wrapper.find('.poi-card-collapsed').trigger('tap') + await wrapper.vm.$nextTick() + await wrapper.find('.poi-action.primary').trigger('tap') + await flushPromises() + const mapUrl = vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.url as string + vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.fail?.({ errMsg: 'navigateTo:fail test cleanup' }) + + search.vm.$emit('result-tap', poi, categoryContext) + await flushPromises() + const searchUrl = vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]?.url as string + + expect(mapUrl.split('?')[0]).toBe('/pages/facility/detail') + expect(searchUrl.split('?')[0]).toBe('/pages/facility/detail') + expect(new URLSearchParams(mapUrl.split('?')[1]).get('id')).toBe(poi.id) + expect(new URLSearchParams(searchUrl.split('?')[1]).get('id')).toBe(poi.id) + expect(searchUrl).toBe(mapUrl) + }) + + it('展厅类型空间没有讲解展厅关联时只进入普通点位详情,不显示虚假讲解入口', async () => { testState.halls = [{ id: 'hall-biology', poiId: 'another-poi', name: '生物厅' }] const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) @@ -577,9 +648,53 @@ describe('首页搜索与地图闭环', () => { await flushPromises() expect(wrapper.find('.guide-poi-card').exists()).toBe(true) - expect(wrapper.find('.poi-card-actions').exists()).toBe(false) + expect(wrapper.find('.poi-card-actions').exists()).toBe(true) expect(wrapper.text()).not.toContain('查看展厅') expect(wrapper.text()).not.toContain('相关讲解') + expect(wrapper.text()).toContain('查看详情') + await wrapper.find('.poi-action.primary').trigger('tap') + await flushPromises() + expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/facility/detail?') + }) + + it('快速切换点位时忽略上一个点位较晚返回的展厅关联结果', async () => { + let resolveFirst!: (halls: typeof testState.halls) => void + let resolveSecond!: (halls: typeof testState.halls) => void + testState.hallResolutionPromises = [ + new Promise((resolve) => { resolveFirst = resolve }), + new Promise((resolve) => { resolveSecond = resolve }) + ] + const wrapper = await mountIndex() + const map = wrapper.getComponent(GuideMapShellStub) + const firstPoi = { + ...poi, + id: 'poi-hall-first', + kind: 'hall' as const, + hallId: 'hall-first', + primaryCategory: 'exhibition_hall', + primaryCategoryZh: '展厅' + } + const secondPoi = { + ...poi, + id: 'poi-hall-second', + kind: 'hall' as const, + hallId: 'hall-second', + primaryCategory: 'exhibition_hall', + primaryCategoryZh: '展厅' + } + + map.vm.$emit('poi-click', firstPoi) + map.vm.$emit('poi-click', secondPoi) + resolveSecond([{ id: 'hall-second', poiId: secondPoi.id, name: '第二展厅' }]) + await flushPromises() + resolveFirst([{ id: 'hall-first', poiId: firstPoi.id, name: '第一展厅' }]) + await flushPromises() + + expect(wrapper.text()).toContain(secondPoi.name) + await wrapper.find('.poi-action.primary').trigger('tap') + await flushPromises() + const url = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url as string + expect(new URLSearchParams(url.split('?')[1]).get('hallId')).toBe('hall-second') }) it('展厅编号名称规范化后仍能关联真实讲解展厅', async () => { @@ -746,7 +861,7 @@ describe('首页搜索与地图闭环', () => { expect(testState.resetToViewBaselineCalls).toHaveLength(0) }) - it('单结果分类写入 resultCount=1,主动取消恢复默认标记并清除目标', async () => { + it('单结果分类使用统一详情目标,主动取消恢复默认标记并清除目标', async () => { const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) const map = wrapper.getComponent(GuideMapShellStub) @@ -762,7 +877,7 @@ describe('首页搜索与地图闭环', () => { await flushPromises() const detailUrl = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url as string - expect(new URLSearchParams(detailUrl.split('?')[1]).get('resultCount')).toBe('1') + expect(new URLSearchParams(detailUrl.split('?')[1]).get('source')).toBe('guide-poi') expect(map.props('targetFocusRequest')).toBeNull() search.vm.$emit('cancel')