统一首页点位详情目标解析

This commit is contained in:
cxk
2026-07-20 08:09:58 +08:00
parent 153be754a9
commit 298e7d7606
5 changed files with 388 additions and 133 deletions

View File

@@ -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

View File

@@ -99,11 +99,11 @@
</view>
</view>
<view
v-if="selectedGuidePoiExplainHall"
v-if="selectedGuidePoiDetailTarget"
class="poi-card-actions"
>
<view class="poi-action primary" @tap="handleViewSelectedHall">
<text class="poi-action-text">查看展厅</text>
<view class="poi-action primary" @tap="handleViewSelectedPoiDetail">
<text class="poi-action-text">{{ selectedGuidePoiDetailActionText }}</text>
</view>
</view>
</template>
@@ -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<GuideRenderPoi | null>(null)
const selectedGuidePoiExplainHall = ref<MuseumHall | null>(null)
let selectedGuidePoiExplainHallRequestId = 0
const selectedGuidePoiDetailTarget = ref<PoiDetailTarget | null>(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<GuideTopTab>(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<MuseumHall | null> => {
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 = () => {

View File

@@ -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<string, string | number>) => {
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<PoiDetailTarget> {
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()

View File

@@ -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<string, unknown> = {}
): 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),