Compare commits
5 Commits
e2bf84aec6
...
3cc0336806
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cc0336806 | ||
|
|
298e7d7606 | ||
|
|
153be754a9 | ||
|
|
b58f838624 | ||
|
|
5331d6e0af |
@@ -65,6 +65,11 @@ import type {
|
|||||||
GuideRouteFloorSegment,
|
GuideRouteFloorSegment,
|
||||||
GuideRouteResult
|
GuideRouteResult
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
|
import {
|
||||||
|
getPoiDisplayPolicy,
|
||||||
|
isPoiVisibilityTierAtLeast,
|
||||||
|
type PoiVisibilityTier
|
||||||
|
} from '@/domain/poiDisplay'
|
||||||
import {
|
import {
|
||||||
compareFloorsTopToBottom,
|
compareFloorsTopToBottom,
|
||||||
getFloorSortLevel
|
getFloorSortLevel
|
||||||
@@ -341,7 +346,6 @@ interface FloorOption {
|
|||||||
|
|
||||||
type RenderPoi = GuideRenderPoi
|
type RenderPoi = GuideRenderPoi
|
||||||
type PoiDisplayMode = 'core' | 'balanced' | 'detail'
|
type PoiDisplayMode = 'core' | 'balanced' | 'detail'
|
||||||
type PoiVisibilityTier = 'tight' | 'balanced' | 'full'
|
|
||||||
|
|
||||||
interface PoiSpriteUserData {
|
interface PoiSpriteUserData {
|
||||||
poi?: RenderPoi
|
poi?: RenderPoi
|
||||||
@@ -607,8 +611,6 @@ const poiHitTargetScaleMultiplier = 4.8
|
|||||||
const poiHitTargetCoreScaleMultiplier = 5.4
|
const poiHitTargetCoreScaleMultiplier = 5.4
|
||||||
const poiScreenHitRadiusPx = 64
|
const poiScreenHitRadiusPx = 64
|
||||||
const poiScreenHitRadiusCorePx = 76
|
const poiScreenHitRadiusCorePx = 76
|
||||||
const poiAmbientLabelHallSpacingPx = 92
|
|
||||||
const poiAmbientLabelServiceSpacingPx = 72
|
|
||||||
const modelLoadRetryDelaysMs = [300, 900]
|
const modelLoadRetryDelaysMs = [300, 900]
|
||||||
const adjacentPreloadDelayMs = 900
|
const adjacentPreloadDelayMs = 900
|
||||||
const manualAutoSwitchPauseMs = 1500
|
const manualAutoSwitchPauseMs = 1500
|
||||||
@@ -844,29 +846,6 @@ const syncControlInteractionOptions = (event?: PointerEvent) => {
|
|||||||
controls.touches.TWO = THREE.TOUCH.DOLLY_ROTATE
|
controls.touches.TWO = THREE.TOUCH.DOLLY_ROTATE
|
||||||
}
|
}
|
||||||
|
|
||||||
const corePoiCategories = new Set([
|
|
||||||
'poi',
|
|
||||||
'touring_poi',
|
|
||||||
'exhibition_hall',
|
|
||||||
'exhibition_hall_entrance',
|
|
||||||
'basic_service_facility',
|
|
||||||
'transport_circulation',
|
|
||||||
'accessibility_special_service'
|
|
||||||
])
|
|
||||||
|
|
||||||
const poiCategoryPriority: Record<string, number> = {
|
|
||||||
target_preview: 100,
|
|
||||||
exhibition_hall: 96,
|
|
||||||
exhibition_hall_entrance: 92,
|
|
||||||
touring_poi: 90,
|
|
||||||
poi: 86,
|
|
||||||
accessibility_special_service: 88,
|
|
||||||
basic_service_facility: 82,
|
|
||||||
business_poi: 80,
|
|
||||||
transport_circulation: 76,
|
|
||||||
operation_experience: 46
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHallPoi = (poi: RenderPoi) => (
|
const isHallPoi = (poi: RenderPoi) => (
|
||||||
poi.kind === 'hall'
|
poi.kind === 'hall'
|
||||||
|| poi.primaryCategory === 'exhibition_hall'
|
|| poi.primaryCategory === 'exhibition_hall'
|
||||||
@@ -881,6 +860,13 @@ const isServiceFacilityPoi = (poi: RenderPoi) => (
|
|||||||
|
|
||||||
const isTransportPoi = (poi: RenderPoi) => poi.primaryCategory === 'transport_circulation'
|
const isTransportPoi = (poi: RenderPoi) => poi.primaryCategory === 'transport_circulation'
|
||||||
|
|
||||||
|
const getPoiPolicy = (poi: RenderPoi) => (
|
||||||
|
poi.displayPolicy || getPoiDisplayPolicy({
|
||||||
|
primaryCategory: poi.primaryCategory,
|
||||||
|
kind: poi.kind
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const poiGlyphMap: Record<string, string> = {
|
const poiGlyphMap: Record<string, string> = {
|
||||||
poi: '点',
|
poi: '点',
|
||||||
accessible_restroom: '无',
|
accessible_restroom: '无',
|
||||||
@@ -1263,15 +1249,11 @@ const getPoiGlyph = (poi: RenderPoi) => (
|
|||||||
|
|
||||||
const shouldShowPoiInCurrentMode = (poi: RenderPoi) => {
|
const shouldShowPoiInCurrentMode = (poi: RenderPoi) => {
|
||||||
const mode = getPoiDisplayMode()
|
const mode = getPoiDisplayMode()
|
||||||
|
const policy = getPoiPolicy(poi)
|
||||||
|
|
||||||
if (mode === 'core') {
|
if (!policy.markerVisible) return false
|
||||||
return corePoiCategories.has(poi.primaryCategory)
|
if (mode === 'core') return policy.allowOverview
|
||||||
}
|
if (mode === 'balanced') return policy.allowMulti
|
||||||
|
|
||||||
if (mode === 'balanced') {
|
|
||||||
return corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'touring_poi'
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1314,22 +1296,9 @@ const shouldShowPoiAtDistance = (poi: RenderPoi, tier: PoiVisibilityTier) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeView.value === 'floor' && isHallPoi(poi)) {
|
const policy = getPoiPolicy(poi)
|
||||||
return true
|
return policy.markerVisible
|
||||||
}
|
&& isPoiVisibilityTierAtLeast(tier, policy.minMarkerTier)
|
||||||
|
|
||||||
if (tier === 'tight') {
|
|
||||||
return corePoiCategories.has(poi.primaryCategory) && !isTransportPoi(poi)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tier === 'balanced') {
|
|
||||||
return !isTransportPoi(poi) && (
|
|
||||||
corePoiCategories.has(poi.primaryCategory)
|
|
||||||
|| poi.primaryCategory === 'touring_poi'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPoiVisibilityLimit = (tier: PoiVisibilityTier) => {
|
const getPoiVisibilityLimit = (tier: PoiVisibilityTier) => {
|
||||||
@@ -1345,7 +1314,7 @@ const getPoiScreenSpacing = (tier: PoiVisibilityTier) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getPoiPriority = (poi: RenderPoi) => {
|
const getPoiPriority = (poi: RenderPoi) => {
|
||||||
const categoryPriority = poiCategoryPriority[poi.primaryCategory] || 20
|
const categoryPriority = getPoiPolicy(poi).priority
|
||||||
const selectedBoost = poi.id === activeFocusPoiId.value ? 1000 : 0
|
const selectedBoost = poi.id === activeFocusPoiId.value ? 1000 : 0
|
||||||
const hallBoost = activeView.value === 'floor' && isHallPoi(poi) ? 40 : 0
|
const hallBoost = activeView.value === 'floor' && isHallPoi(poi) ? 40 : 0
|
||||||
const transportPenalty = isTransportPoi(poi) ? 18 : 0
|
const transportPenalty = isTransportPoi(poi) ? 18 : 0
|
||||||
@@ -1364,8 +1333,7 @@ const getPoiLabelDensityTier = (): PoiVisibilityTier => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldCreateAmbientPoiLabel = (poi: RenderPoi) => (
|
const shouldCreateAmbientPoiLabel = (poi: RenderPoi) => (
|
||||||
isHallPoi(poi)
|
getPoiPolicy(poi).labelVisible
|
||||||
|| isServiceFacilityPoi(poi)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const shouldShowAmbientPoiLabel = (
|
const shouldShowAmbientPoiLabel = (
|
||||||
@@ -1375,11 +1343,9 @@ const shouldShowAmbientPoiLabel = (
|
|||||||
) => {
|
) => {
|
||||||
if (!markerVisible || activeView.value !== 'floor') return false
|
if (!markerVisible || activeView.value !== 'floor') return false
|
||||||
if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') return false
|
if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') return false
|
||||||
if (isHallPoi(poi)) return true
|
const policy = getPoiPolicy(poi)
|
||||||
if (isTransportPoi(poi)) return false
|
return policy.labelVisible
|
||||||
if (isServiceFacilityPoi(poi)) return densityTier === 'full'
|
&& isPoiVisibilityTierAtLeast(densityTier, policy.minLabelTier)
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPoiAmbientLabelPriority = (poi: RenderPoi) => {
|
const getPoiAmbientLabelPriority = (poi: RenderPoi) => {
|
||||||
@@ -1490,17 +1456,15 @@ const updateAmbientPoiLabels = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const spacing = isHallPoi(poi) ? poiAmbientLabelHallSpacingPx : poiAmbientLabelServiceSpacingPx
|
const spacing = getPoiPolicy(poi).labelCollisionSpacing
|
||||||
const bounds = updatePoiDomLabelPosition(labelHandle)
|
const bounds = updatePoiDomLabelPosition(labelHandle)
|
||||||
if (!bounds) {
|
if (!bounds) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const overlaps = acceptedBounds.some((acceptedBound) => domLabelBoundsOverlap(bounds, acceptedBound, spacing))
|
const overlaps = acceptedBounds.some((acceptedBound) => (
|
||||||
|
domLabelBoundsOverlap(bounds, acceptedBound, spacing)
|
||||||
|
))
|
||||||
setPoiDomLabelVisible(labelHandle, !overlaps)
|
setPoiDomLabelVisible(labelHandle, !overlaps)
|
||||||
if (!overlaps) {
|
if (!overlaps) acceptedBounds.push(bounds)
|
||||||
acceptedBounds.push(bounds)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4802,7 +4766,7 @@ const findLoadedPoi = (poiId: string) => (
|
|||||||
const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => {
|
const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => {
|
||||||
const sprite = new THREE.Sprite(createPoiMaterial(poi))
|
const sprite = new THREE.Sprite(createPoiMaterial(poi))
|
||||||
const hitTarget = new THREE.Sprite(createPoiHitTargetMaterial())
|
const hitTarget = new THREE.Sprite(createPoiHitTargetMaterial())
|
||||||
const isCorePoi = corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'target_preview'
|
const isCorePoi = getPoiPolicy(poi).allowOverview || poi.primaryCategory === 'target_preview'
|
||||||
const hitTargetScale = markerSize * (isCorePoi ? poiHitTargetCoreScaleMultiplier : poiHitTargetScaleMultiplier)
|
const hitTargetScale = markerSize * (isCorePoi ? poiHitTargetCoreScaleMultiplier : poiHitTargetScaleMultiplier)
|
||||||
|
|
||||||
sprite.userData.baseScale = markerSize
|
sprite.userData.baseScale = markerSize
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
MuseumPoiEntrance,
|
MuseumPoiEntrance,
|
||||||
MuseumPoiKind
|
MuseumPoiKind
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
|
import type { PoiDisplayPolicy } from '@/domain/poiDisplay'
|
||||||
|
|
||||||
export interface GuideRenderPoi {
|
export interface GuideRenderPoi {
|
||||||
id: string
|
id: string
|
||||||
@@ -19,6 +20,7 @@ export interface GuideRenderPoi {
|
|||||||
sourcePlaceId?: string
|
sourcePlaceId?: string
|
||||||
sourceSpaceId?: string
|
sourceSpaceId?: string
|
||||||
entrances?: MuseumPoiEntrance[]
|
entrances?: MuseumPoiEntrance[]
|
||||||
|
displayPolicy?: PoiDisplayPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuideModelFloorAsset {
|
export interface GuideModelFloorAsset {
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export interface MuseumPoi {
|
|||||||
sourcePlaceId?: string
|
sourcePlaceId?: string
|
||||||
sourceSpaceId?: string
|
sourceSpaceId?: string
|
||||||
entrances?: MuseumPoiEntrance[]
|
entrances?: MuseumPoiEntrance[]
|
||||||
|
displayPolicy?: import('@/domain/poiDisplay').PoiDisplayPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GuideLocationResolutionStatus = 'exact' | 'hallFallback' | 'candidate' | 'unavailable'
|
export type GuideLocationResolutionStatus = 'exact' | 'hallFallback' | 'candidate' | 'unavailable'
|
||||||
|
|||||||
125
src/domain/poiDisplay.ts
Normal file
125
src/domain/poiDisplay.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { MuseumPoiKind } from '@/domain/museum'
|
||||||
|
|
||||||
|
export type PoiVisibilityTier = 'tight' | 'balanced' | 'full'
|
||||||
|
|
||||||
|
export interface PoiDisplayPolicy {
|
||||||
|
markerVisible: boolean
|
||||||
|
labelVisible: boolean
|
||||||
|
minMarkerTier: PoiVisibilityTier
|
||||||
|
minLabelTier: PoiVisibilityTier
|
||||||
|
allowOverview: boolean
|
||||||
|
allowMulti: boolean
|
||||||
|
priority: number
|
||||||
|
labelCollisionSpacing: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoiDisplayPolicyInput {
|
||||||
|
primaryCategory?: string
|
||||||
|
kind?: MuseumPoiKind
|
||||||
|
}
|
||||||
|
|
||||||
|
const tierRank: Record<PoiVisibilityTier, number> = {
|
||||||
|
tight: 0,
|
||||||
|
balanced: 1,
|
||||||
|
full: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isPoiVisibilityTierAtLeast = (
|
||||||
|
tier: PoiVisibilityTier,
|
||||||
|
minimum: PoiVisibilityTier
|
||||||
|
) => tierRank[tier] >= tierRank[minimum]
|
||||||
|
|
||||||
|
export const getPoiDisplayPolicy = ({
|
||||||
|
primaryCategory = '',
|
||||||
|
kind
|
||||||
|
}: PoiDisplayPolicyInput): PoiDisplayPolicy => {
|
||||||
|
const category = primaryCategory.trim().toLowerCase()
|
||||||
|
const isTarget = category === 'target_preview'
|
||||||
|
const isHall = kind === 'hall'
|
||||||
|
|| category === 'exhibition_hall'
|
||||||
|
|| category === 'exhibition_hall_entrance'
|
||||||
|
|| category === 'touring_poi'
|
||||||
|
const isSpace = kind === 'space' || category.startsWith('space_')
|
||||||
|
const isService = category === 'basic_service_facility'
|
||||||
|
|| category === 'accessibility_special_service'
|
||||||
|
|| category === 'business_poi'
|
||||||
|
const isTransport = category === 'transport_circulation'
|
||||||
|
|
||||||
|
if (isTarget) {
|
||||||
|
return {
|
||||||
|
markerVisible: true,
|
||||||
|
labelVisible: true,
|
||||||
|
minMarkerTier: 'tight',
|
||||||
|
minLabelTier: 'tight',
|
||||||
|
allowOverview: true,
|
||||||
|
allowMulti: true,
|
||||||
|
priority: 1000,
|
||||||
|
labelCollisionSpacing: 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHall) {
|
||||||
|
return {
|
||||||
|
markerVisible: true,
|
||||||
|
labelVisible: true,
|
||||||
|
minMarkerTier: 'tight',
|
||||||
|
// 原始 floor 视图中展厅 marker 可见即显示标签,不受更近一档缩放限制。
|
||||||
|
minLabelTier: 'tight',
|
||||||
|
allowOverview: true,
|
||||||
|
allowMulti: true,
|
||||||
|
priority: 120,
|
||||||
|
labelCollisionSpacing: 64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSpace) {
|
||||||
|
return {
|
||||||
|
markerVisible: true,
|
||||||
|
labelVisible: false,
|
||||||
|
minMarkerTier: 'tight',
|
||||||
|
// 普通空间保留 marker,但不参与展厅/设施环境标签层。
|
||||||
|
minLabelTier: 'full',
|
||||||
|
allowOverview: true,
|
||||||
|
allowMulti: true,
|
||||||
|
priority: 50,
|
||||||
|
labelCollisionSpacing: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isService) {
|
||||||
|
return {
|
||||||
|
markerVisible: true,
|
||||||
|
labelVisible: true,
|
||||||
|
minMarkerTier: 'tight',
|
||||||
|
minLabelTier: 'full',
|
||||||
|
allowOverview: true,
|
||||||
|
allowMulti: true,
|
||||||
|
priority: 80,
|
||||||
|
labelCollisionSpacing: 48
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTransport) {
|
||||||
|
return {
|
||||||
|
markerVisible: true,
|
||||||
|
labelVisible: false,
|
||||||
|
minMarkerTier: 'balanced',
|
||||||
|
minLabelTier: 'full',
|
||||||
|
allowOverview: true,
|
||||||
|
allowMulti: true,
|
||||||
|
priority: 40,
|
||||||
|
labelCollisionSpacing: 40
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
markerVisible: true,
|
||||||
|
labelVisible: false,
|
||||||
|
minMarkerTier: 'tight',
|
||||||
|
minLabelTier: 'full',
|
||||||
|
allowOverview: true,
|
||||||
|
allowMulti: true,
|
||||||
|
priority: 20,
|
||||||
|
labelCollisionSpacing: 32
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ type ExplainListHistoryState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let explainListHistoryPushed = false
|
let explainListHistoryPushed = false
|
||||||
|
let isConsumingExplainListHistory = false
|
||||||
let isReturningToGuideHome = false
|
let isReturningToGuideHome = false
|
||||||
|
|
||||||
type PageStackEntry = {
|
type PageStackEntry = {
|
||||||
@@ -96,6 +97,14 @@ const guideHomeUrl = () => (
|
|||||||
shouldUseHostNavigation.value ? guideTopTabUrl('guide') : GUIDE_HOME_URL
|
shouldUseHostNavigation.value ? guideTopTabUrl('guide') : GUIDE_HOME_URL
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isExplainListHistoryState = (state: unknown): state is ExplainListHistoryState => (
|
||||||
|
Boolean(
|
||||||
|
state
|
||||||
|
&& typeof state === 'object'
|
||||||
|
&& (state as ExplainListHistoryState).museumGuidePage === 'explain-list'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const showGuideHomeReturnError = () => {
|
const showGuideHomeReturnError = () => {
|
||||||
isReturningToGuideHome = false
|
isReturningToGuideHome = false
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
@@ -111,8 +120,22 @@ const reLaunchGuideHome = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const consumeExplainListHistory = () => {
|
||||||
|
if (
|
||||||
|
typeof window === 'undefined'
|
||||||
|
|| !explainListHistoryPushed
|
||||||
|
|| !isExplainListHistoryState(window.history.state)
|
||||||
|
) return false
|
||||||
|
|
||||||
|
// 根栈压入了同 URL 标记,先消费它,避免回首页后再次回流到讲解列表。
|
||||||
|
isConsumingExplainListHistory = true
|
||||||
|
window.history.back()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const returnToGuideHome = () => {
|
const returnToGuideHome = () => {
|
||||||
if (isReturningToGuideHome) return
|
if (isReturningToGuideHome || isConsumingExplainListHistory) return
|
||||||
|
if (consumeExplainListHistory()) return
|
||||||
|
|
||||||
isReturningToGuideHome = true
|
isReturningToGuideHome = true
|
||||||
const pages = getPageStack()
|
const pages = getPageStack()
|
||||||
@@ -138,14 +161,6 @@ const returnToGuideHome = () => {
|
|||||||
reLaunchGuideHome()
|
reLaunchGuideHome()
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExplainListHistoryState = (state: unknown): state is ExplainListHistoryState => (
|
|
||||||
Boolean(
|
|
||||||
state
|
|
||||||
&& typeof state === 'object'
|
|
||||||
&& (state as ExplainListHistoryState).museumGuidePage === 'explain-list'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const pushExplainListHistory = () => {
|
const pushExplainListHistory = () => {
|
||||||
if (typeof window === 'undefined' || getPageStack().length > 1) return
|
if (typeof window === 'undefined' || getPageStack().length > 1) return
|
||||||
if (explainListHistoryPushed) return
|
if (explainListHistoryPushed) return
|
||||||
@@ -168,10 +183,12 @@ const handleExplainListHistoryPopState = (event: PopStateEvent) => {
|
|||||||
|
|
||||||
if (isExplainListHistoryState(event.state)) {
|
if (isExplainListHistoryState(event.state)) {
|
||||||
explainListHistoryPushed = true
|
explainListHistoryPushed = true
|
||||||
|
isConsumingExplainListHistory = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
explainListHistoryPushed = false
|
explainListHistoryPushed = false
|
||||||
|
isConsumingExplainListHistory = false
|
||||||
returnToGuideHome()
|
returnToGuideHome()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ const resolveFacilityPoi = async () => {
|
|||||||
if (poiById) return poiById
|
if (poiById) return poiById
|
||||||
|
|
||||||
// 搜索结果必须按稳定 ID 解析,避免同名点位被定位到错误楼层。
|
// 搜索结果必须按稳定 ID 解析,避免同名点位被定位到错误楼层。
|
||||||
if (searchContext.value.source === 'search') return null
|
if (searchContext.value.source === 'search' || searchContext.value.source === 'guide-poi') return null
|
||||||
|
|
||||||
const poiByHall = await resolvePoiFromHall()
|
const poiByHall = await resolvePoiFromHall()
|
||||||
if (poiByHall) return poiByHall
|
if (poiByHall) return poiByHall
|
||||||
@@ -273,7 +273,10 @@ const resolveFacilityPoi = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolveRenderPoi = 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)
|
const normalizedTarget = normalizePoiName(facility.value.name)
|
||||||
if (!resolveByExactId && !normalizedTarget) return null
|
if (!resolveByExactId && !normalizedTarget) return null
|
||||||
|
|
||||||
|
|||||||
@@ -99,14 +99,11 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
v-if="selectedGuidePoiIsHall"
|
v-if="selectedGuidePoiDetailTarget"
|
||||||
class="poi-card-actions"
|
class="poi-card-actions"
|
||||||
>
|
>
|
||||||
<view class="poi-action primary" @tap="handleViewSelectedHall">
|
<view class="poi-action primary" @tap="handleViewSelectedPoiDetail">
|
||||||
<text class="poi-action-text">查看展厅</text>
|
<text class="poi-action-text">{{ selectedGuidePoiDetailActionText }}</text>
|
||||||
</view>
|
|
||||||
<view class="poi-action" @tap="handleSelectedPoiRelated">
|
|
||||||
<text class="poi-action-text">相关讲解</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -290,7 +287,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||||
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
||||||
@@ -319,6 +316,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
explainUseCase
|
explainUseCase
|
||||||
} from '@/usecases/explainUseCase'
|
} from '@/usecases/explainUseCase'
|
||||||
|
import {
|
||||||
|
poiDetailUseCase,
|
||||||
|
type PoiDetailSource,
|
||||||
|
type PoiDetailTarget
|
||||||
|
} from '@/usecases/poiDetailUseCase'
|
||||||
import type {
|
import type {
|
||||||
GuideRenderPoi
|
GuideRenderPoi
|
||||||
} from '@/domain/guideModel'
|
} from '@/domain/guideModel'
|
||||||
@@ -344,7 +346,6 @@ import type {
|
|||||||
GuideRouteResult,
|
GuideRouteResult,
|
||||||
GuideRouteTarget,
|
GuideRouteTarget,
|
||||||
MuseumFloor,
|
MuseumFloor,
|
||||||
MuseumHall,
|
|
||||||
MuseumPoi
|
MuseumPoi
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
import { getFloorSortLevel } from '@/domain/guideFloor'
|
import { getFloorSortLevel } from '@/domain/guideFloor'
|
||||||
@@ -400,6 +401,19 @@ const loadingFloorId = ref('')
|
|||||||
const renderedFloorId = ref('')
|
const renderedFloorId = ref('')
|
||||||
const failedFloorId = ref('')
|
const failedFloorId = ref('')
|
||||||
const selectedGuidePoi = ref<GuideRenderPoi | null>(null)
|
const selectedGuidePoi = ref<GuideRenderPoi | null>(null)
|
||||||
|
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())
|
const currentTab = ref<GuideTopTab>(initialTopTab())
|
||||||
@@ -714,23 +728,31 @@ const selectedGuidePoiSubtitle = computed(() => {
|
|||||||
return `${floorLabel} · ${poi.primaryCategoryZh || '位置预览'}`
|
return `${floorLabel} · ${poi.primaryCategoryZh || '位置预览'}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedGuidePoiIsHall = computed(() => {
|
const isGuidePoiHallLike = (poi: GuideRenderPoi | null) => Boolean(
|
||||||
const poi = selectedGuidePoi.value
|
poi
|
||||||
return Boolean(
|
&& (
|
||||||
poi
|
poi.kind === 'hall'
|
||||||
&& (
|
|| poi.primaryCategory === 'touring_poi'
|
||||||
poi.kind === 'hall'
|
|| poi.primaryCategory === 'exhibition_hall'
|
||||||
|| poi.primaryCategory === 'touring_poi'
|
|| poi.primaryCategory === 'exhibition_hall_entrance'
|
||||||
|| poi.primaryCategory === 'exhibition_hall'
|
|
||||||
|| poi.primaryCategory === 'exhibition_hall_entrance'
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
})
|
)
|
||||||
|
|
||||||
|
const selectedGuidePoiIsHall = computed(() => isGuidePoiHallLike(selectedGuidePoi.value))
|
||||||
|
|
||||||
const selectedGuidePoiCollapsedSubtitle = computed(() => (
|
const selectedGuidePoiCollapsedSubtitle = computed(() => (
|
||||||
selectedGuidePoiIsHall.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(() => {
|
const routeSimulationStepText = computed(() => {
|
||||||
if (!activeRoutePreview.value) return '位置预览 · 查看位置关系'
|
if (!activeRoutePreview.value) return '位置预览 · 查看位置关系'
|
||||||
const hasConnector = activeRoutePreview.value.connectorPoints.length > 0
|
const hasConnector = activeRoutePreview.value.connectorPoints.length > 0
|
||||||
@@ -1129,120 +1151,60 @@ const togglePoiCardCollapsed = () => {
|
|||||||
isPoiCardCollapsed.value = !isPoiCardCollapsed.value
|
isPoiCardCollapsed.value = !isPoiCardCollapsed.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeHallMatchText = (value?: string) => (value || '')
|
watch(selectedGuidePoi, (poi) => {
|
||||||
.trim()
|
const requestId = ++poiDetailResolutionRequestId
|
||||||
.replace(/[\s()()【】[\]_-]/g, '')
|
selectedGuidePoiDetailTarget.value = null
|
||||||
.toLowerCase()
|
if (!poi) return
|
||||||
|
|
||||||
const resolveSelectedPoiHall = async (): Promise<MuseumHall | null> => {
|
void poiDetailUseCase.resolve(poi)
|
||||||
const poi = selectedGuidePoi.value
|
.then((target) => {
|
||||||
if (!poi) return null
|
if (
|
||||||
|
requestId !== poiDetailResolutionRequestId
|
||||||
|
|| selectedGuidePoi.value?.id !== poi.id
|
||||||
|
) return
|
||||||
|
|
||||||
const halls = await explainUseCase.listHalls()
|
selectedGuidePoiDetailTarget.value = target
|
||||||
const matchedByPoiId = halls.find((hall) => (
|
})
|
||||||
hall.poiId === poi.id
|
.catch((error) => {
|
||||||
|| hall.location?.poiId === poi.id
|
if (requestId !== poiDetailResolutionRequestId) return
|
||||||
))
|
console.warn('点位详情目标解析失败:', error)
|
||||||
if (matchedByPoiId) return matchedByPoiId
|
})
|
||||||
|
}, { flush: 'sync' })
|
||||||
|
|
||||||
const hallId = poi.hallId
|
const navigateToPoiDetailTarget = (
|
||||||
if (hallId) {
|
target: PoiDetailTarget,
|
||||||
const matchedById = halls.find((hall) => hall.id === hallId)
|
uiContext: PoiDetailUiContext
|
||||||
if (matchedById) return matchedById
|
) => {
|
||||||
|
const returnContext = guideModelState.beginPoiDetailReturn(target.floorId)
|
||||||
|
pendingPoiDetailUiContext = {
|
||||||
|
requestId: returnContext.requestId,
|
||||||
|
value: uiContext
|
||||||
}
|
}
|
||||||
|
|
||||||
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.includes(poiHallName) || poiHallName.includes(hallName))
|
|
||||||
}) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToPoiDetail = ({
|
|
||||||
url,
|
|
||||||
floorId,
|
|
||||||
failureMessage
|
|
||||||
}: {
|
|
||||||
url: string
|
|
||||||
floorId: string
|
|
||||||
failureMessage: string
|
|
||||||
}) => {
|
|
||||||
const returnContext = guideModelState.beginPoiDetailReturn(floorId)
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url,
|
url: target.url,
|
||||||
success: () => {
|
success: () => {
|
||||||
guideModelState.confirmPoiDetailReturn(returnContext.requestId)
|
guideModelState.confirmPoiDetailReturn(returnContext.requestId)
|
||||||
},
|
},
|
||||||
fail: () => {
|
fail: () => {
|
||||||
guideModelState.cancelPoiDetailReturn(returnContext.requestId)
|
guideModelState.cancelPoiDetailReturn(returnContext.requestId)
|
||||||
|
if (pendingPoiDetailUiContext?.requestId === returnContext.requestId) {
|
||||||
|
pendingPoiDetailUiContext = null
|
||||||
|
}
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: failureMessage,
|
title: target.failureMessage,
|
||||||
icon: 'none'
|
icon: 'none'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateFromSelectedPoiToDetail = (url: string) => {
|
const handleViewSelectedPoiDetail = () => {
|
||||||
const poi = selectedGuidePoi.value
|
const target = selectedGuidePoiDetailTarget.value
|
||||||
if (!poi) return
|
if (!target) return
|
||||||
|
navigateToPoiDetailTarget(target, {
|
||||||
// Detail pages do not own the mounted renderer. The homepage consumes this once on return.
|
source: 'map',
|
||||||
navigateToPoiDetail({
|
categoryModeActive: false,
|
||||||
url,
|
visiblePoiIds: null
|
||||||
floorId: poi.floorId,
|
|
||||||
failureMessage: '讲解详情打开失败,请重试'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToHallExplainObjects = (hallId: string, hallName = '讲解') => {
|
|
||||||
navigateFromSelectedPoiToDetail(
|
|
||||||
explainGuideStopListUrl(hallId, hallName)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleViewSelectedHall = async () => {
|
|
||||||
const hall = await resolveSelectedPoiHall()
|
|
||||||
if (!hall) {
|
|
||||||
uni.showToast({
|
|
||||||
title: '该展厅详情暂未接入',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateToHallExplainObjects(hall.id, hall.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelectedPoiRelated = async () => {
|
|
||||||
if (!selectedGuidePoi.value) return
|
|
||||||
|
|
||||||
const hall = await resolveSelectedPoiHall()
|
|
||||||
if (hall) {
|
|
||||||
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)
|
|
||||||
|
|
||||||
if (firstHall?.hallId) {
|
|
||||||
navigateToHallExplainObjects(firstHall.hallId, selectedGuidePoi.value.hallName || '讲解')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
uni.showToast({
|
|
||||||
title: '该展厅暂无对应讲解',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1594,10 +1556,20 @@ onShow(async () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
const detailReturnContext = guideModelState.consumePoiDetailReturn()
|
const detailReturnContext = guideModelState.consumePoiDetailReturn()
|
||||||
if (detailReturnContext) {
|
if (detailReturnContext) {
|
||||||
|
const uiContext = pendingPoiDetailUiContext?.requestId === detailReturnContext.requestId
|
||||||
|
? pendingPoiDetailUiContext.value
|
||||||
|
: null
|
||||||
|
pendingPoiDetailUiContext = null
|
||||||
await resetGuideModelToFloorBaseline({
|
await resetGuideModelToFloorBaseline({
|
||||||
rendererReason: 'poi-detail-close',
|
rendererReason: 'poi-detail-close',
|
||||||
floorId: detailReturnContext.floorId
|
floorId: detailReturnContext.floorId
|
||||||
})
|
})
|
||||||
|
if (uiContext?.source === 'search') {
|
||||||
|
homeCategoryModeActive.value = uiContext.categoryModeActive
|
||||||
|
searchVisiblePoiIds.value = uiContext.visiblePoiIds
|
||||||
|
? [...uiContext.visiblePoiIds]
|
||||||
|
: null
|
||||||
|
}
|
||||||
await homeSearchPanelRef.value?.restoreResultListScroll?.()
|
await homeSearchPanelRef.value?.restoreResultListScroll?.()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1673,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) => {
|
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
|
searchTargetFocusRequest.value = null
|
||||||
navigateToPoiDetail({
|
try {
|
||||||
url: createSearchDetailUrl(poi, context),
|
const target = await poiDetailUseCase.resolve(poi)
|
||||||
floorId: poi.floorId,
|
if (requestId !== poiDetailResolutionRequestId) return
|
||||||
failureMessage: '点位详情打开失败,请重试'
|
navigateToPoiDetailTarget(target, {
|
||||||
})
|
source: 'search',
|
||||||
|
categoryModeActive,
|
||||||
|
visiblePoiIds
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== poiDetailResolutionRequestId) return
|
||||||
|
console.warn('搜索点位详情目标解析失败:', error)
|
||||||
|
uni.showToast({ title: '点位详情解析失败,请重试', icon: 'none' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHomeSearchCancel = () => {
|
const handleHomeSearchCancel = () => {
|
||||||
@@ -1983,8 +1945,7 @@ const handleExplainBack = () => {
|
|||||||
.poi-card-actions {
|
.poi-card-actions {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.poi-action {
|
.poi-action {
|
||||||
@@ -1993,7 +1954,7 @@ const handleExplainBack = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0 6px;
|
padding: 0 16px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: #f6f7f4;
|
background: #f6f7f4;
|
||||||
border: 1px solid #e5e6de;
|
border: 1px solid #e5e6de;
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
MuseumPoi
|
MuseumPoi
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
|
import {
|
||||||
|
getPoiDisplayPolicy
|
||||||
|
} from '@/domain/poiDisplay'
|
||||||
import {
|
import {
|
||||||
dataSourceConfig
|
dataSourceConfig
|
||||||
} from '@/config/dataSource'
|
} from '@/config/dataSource'
|
||||||
@@ -56,18 +59,25 @@ const getNow = () => (
|
|||||||
: Date.now()
|
: Date.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => ({
|
const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => {
|
||||||
id: poi.id,
|
const kind = poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility'
|
||||||
name: poi.name,
|
return {
|
||||||
floorId: poi.floorId,
|
id: poi.id,
|
||||||
primaryCategory: poi.primaryCategory,
|
name: poi.name,
|
||||||
primaryCategoryZh: poi.primaryCategoryZh,
|
floorId: poi.floorId,
|
||||||
iconType: poi.iconType || poi.primaryCategory,
|
primaryCategory: poi.primaryCategory,
|
||||||
positionGltf: poi.positionGltf,
|
primaryCategoryZh: poi.primaryCategoryZh,
|
||||||
sourceObjectName: poi.sourceObjectName,
|
iconType: poi.iconType || poi.primaryCategory,
|
||||||
kind: poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility',
|
positionGltf: poi.positionGltf,
|
||||||
hallName: poi.primaryCategory === 'touring_poi' ? poi.name : undefined
|
sourceObjectName: poi.sourceObjectName,
|
||||||
})
|
kind,
|
||||||
|
hallName: poi.primaryCategory === 'touring_poi' ? poi.name : undefined,
|
||||||
|
displayPolicy: getPoiDisplayPolicy({
|
||||||
|
primaryCategory: poi.primaryCategory,
|
||||||
|
kind
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
|
const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
|
||||||
id: poi.id,
|
id: poi.id,
|
||||||
@@ -84,7 +94,11 @@ const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
|
|||||||
spaceId: poi.spaceId,
|
spaceId: poi.spaceId,
|
||||||
sourcePlaceId: poi.sourcePlaceId,
|
sourcePlaceId: poi.sourcePlaceId,
|
||||||
sourceSpaceId: poi.sourceSpaceId,
|
sourceSpaceId: poi.sourceSpaceId,
|
||||||
entrances: poi.entrances
|
entrances: poi.entrances,
|
||||||
|
displayPolicy: poi.displayPolicy || getPoiDisplayPolicy({
|
||||||
|
primaryCategory: poi.primaryCategory.id,
|
||||||
|
kind: poi.kind
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const toSgsRenderPoi = (poi: ReturnType<typeof toMuseumPoiFromSgs>) => toGuideRenderPoi(poi)
|
const toSgsRenderPoi = (poi: ReturnType<typeof toMuseumPoiFromSgs>) => toGuideRenderPoi(poi)
|
||||||
|
|||||||
159
src/usecases/poiDetailUseCase.ts
Normal file
159
src/usecases/poiDetailUseCase.ts
Normal 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()
|
||||||
@@ -25,7 +25,7 @@ export interface FacilityDetailViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FacilityDetailSearchContext {
|
export interface FacilityDetailSearchContext {
|
||||||
source: 'search' | 'direct'
|
source: 'search' | 'guide-poi' | 'direct'
|
||||||
searchOrigin: 'home' | 'page' | ''
|
searchOrigin: 'home' | 'page' | ''
|
||||||
searchKeyword: string
|
searchKeyword: string
|
||||||
searchCategoryId: string
|
searchCategoryId: string
|
||||||
@@ -84,6 +84,7 @@ const parseNonNegativeInteger = (value: unknown) => {
|
|||||||
export const parseFacilityDetailSearchContext = (
|
export const parseFacilityDetailSearchContext = (
|
||||||
options: Record<string, unknown> = {}
|
options: Record<string, unknown> = {}
|
||||||
): FacilityDetailSearchContext => {
|
): FacilityDetailSearchContext => {
|
||||||
|
const source = decodeQueryText(options.source)
|
||||||
const searchOrigin = decodeQueryText(options.searchOrigin)
|
const searchOrigin = decodeQueryText(options.searchOrigin)
|
||||||
const visiblePoiIds = decodeQueryText(options.visiblePoiIds)
|
const visiblePoiIds = decodeQueryText(options.visiblePoiIds)
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -91,7 +92,7 @@ export const parseFacilityDetailSearchContext = (
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source: decodeQueryText(options.source) === 'search' ? 'search' : 'direct',
|
source: source === 'search' || source === 'guide-poi' ? source : 'direct',
|
||||||
searchOrigin: searchOrigin === 'home' || searchOrigin === 'page' ? searchOrigin : '',
|
searchOrigin: searchOrigin === 'home' || searchOrigin === 'page' ? searchOrigin : '',
|
||||||
searchKeyword: decodeQueryText(options.searchKeyword),
|
searchKeyword: decodeQueryText(options.searchKeyword),
|
||||||
searchCategoryId: decodeQueryText(options.searchCategoryId),
|
searchCategoryId: decodeQueryText(options.searchCategoryId),
|
||||||
|
|||||||
@@ -234,6 +234,32 @@ describe('讲解展厅列表返回', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('根栈页内返回先消费历史标记,再回到导览首页', async () => {
|
||||||
|
let currentHistoryState: Record<string, unknown> = {}
|
||||||
|
vi.spyOn(window.history, 'state', 'get').mockImplementation(() => currentHistoryState)
|
||||||
|
vi.spyOn(window.history, 'pushState').mockImplementation((state) => {
|
||||||
|
currentHistoryState = (state || {}) as Record<string, unknown>
|
||||||
|
})
|
||||||
|
const wrapper = mountExplainList()
|
||||||
|
const historyBack = vi.spyOn(window.history, 'back').mockImplementation(() => {
|
||||||
|
currentHistoryState = {}
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate', { state: currentHistoryState }))
|
||||||
|
})
|
||||||
|
testState.onLoadHandler?.()
|
||||||
|
|
||||||
|
expect(window.history.state).toMatchObject({ museumGuidePage: 'explain-list' })
|
||||||
|
|
||||||
|
await wrapper.get('[data-testid="explain-list-back"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(historyBack).toHaveBeenCalledTimes(1)
|
||||||
|
expect(uni.reLaunch).toHaveBeenCalledTimes(1)
|
||||||
|
expect(uni.reLaunch).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
url: '/pages/index/index?tab=guide'
|
||||||
|
}))
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('普通 H5 浏览器返回也会回到导览首页,且不调用 history.back', async () => {
|
it('普通 H5 浏览器返回也会回到导览首页,且不调用 history.back', async () => {
|
||||||
const historyBack = vi.spyOn(window.history, 'back')
|
const historyBack = vi.spyOn(window.history, 'back')
|
||||||
const wrapper = mountExplainList()
|
const wrapper = mountExplainList()
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ const testState = vi.hoisted(() => ({
|
|||||||
searchResetCount: 0,
|
searchResetCount: 0,
|
||||||
onShowHandler: null as null | (() => Promise<void> | void),
|
onShowHandler: null as null | (() => Promise<void> | void),
|
||||||
halls: [] as Array<{ id: string; poiId?: string; location?: { poiId?: string }; name: string }>,
|
halls: [] as Array<{ id: string; poiId?: string; location?: { poiId?: string }; name: string }>,
|
||||||
|
hallResolutionPromises: [] as Array<Promise<Array<{
|
||||||
|
id: string
|
||||||
|
poiId?: string
|
||||||
|
location?: { poiId?: string }
|
||||||
|
name: string
|
||||||
|
}>>>,
|
||||||
explainResults: [] as Array<{
|
explainResults: [] as Array<{
|
||||||
type: 'hall' | 'exhibit'
|
type: 'hall' | 'exhibit'
|
||||||
hallId?: string
|
hallId?: string
|
||||||
@@ -76,7 +82,9 @@ vi.mock('@/usecases/explainUseCase', () => ({
|
|||||||
explainUseCase: {
|
explainUseCase: {
|
||||||
loadExplainHalls: async () => [],
|
loadExplainHalls: async () => [],
|
||||||
loadExplainHallSummaries: async () => ({}),
|
loadExplainHallSummaries: async () => ({}),
|
||||||
listHalls: async () => testState.halls,
|
listHalls: async () => (
|
||||||
|
testState.hallResolutionPromises.shift() || Promise.resolve(testState.halls)
|
||||||
|
),
|
||||||
searchExplain: async () => testState.explainResults
|
searchExplain: async () => testState.explainResults
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -211,6 +219,7 @@ beforeEach(() => {
|
|||||||
testState.searchResetCount = 0
|
testState.searchResetCount = 0
|
||||||
testState.onShowHandler = null
|
testState.onShowHandler = null
|
||||||
testState.halls = []
|
testState.halls = []
|
||||||
|
testState.hallResolutionPromises = []
|
||||||
testState.explainResults = []
|
testState.explainResults = []
|
||||||
window.history.replaceState({}, '', '/#/pages/index/index?tab=guide')
|
window.history.replaceState({}, '', '/#/pages/index/index?tab=guide')
|
||||||
vi.stubGlobal('uni', {
|
vi.stubGlobal('uni', {
|
||||||
@@ -403,15 +412,10 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
const navigateTo = vi.mocked(uni.navigateTo)
|
const navigateTo = vi.mocked(uni.navigateTo)
|
||||||
const detailUrl = navigateTo.mock.calls[0]?.[0]?.url as string
|
const detailUrl = navigateTo.mock.calls[0]?.[0]?.url as string
|
||||||
const query = new URLSearchParams(detailUrl.split('?')[1])
|
const query = new URLSearchParams(detailUrl.split('?')[1])
|
||||||
expect(query.get('source')).toBe('search')
|
expect(query.get('source')).toBe('guide-poi')
|
||||||
expect(query.get('searchOrigin')).toBe('home')
|
expect(query.get('id')).toBe(poi.id)
|
||||||
expect(query.get('searchKeyword')).toBe('卫生间')
|
expect(query.get('floorId')).toBe('L2')
|
||||||
expect(query.get('searchCategoryId')).toBe('restroom')
|
expect(query.has('searchKeyword')).toBe(false)
|
||||||
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(testState.searchMountCount).toBe(1)
|
expect(testState.searchMountCount).toBe(1)
|
||||||
expect(testState.searchUnmountCount).toBe(0)
|
expect(testState.searchUnmountCount).toBe(0)
|
||||||
|
|
||||||
@@ -422,7 +426,7 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
expect(testState.searchResetCount).toBe(0)
|
expect(testState.searchResetCount).toBe(0)
|
||||||
expect(testState.searchRestoreCount).toBeGreaterThan(0)
|
expect(testState.searchRestoreCount).toBeGreaterThan(0)
|
||||||
expect(map.props('activeFloor')).toBe('L2')
|
expect(map.props('activeFloor')).toBe('L2')
|
||||||
expect(map.props('visiblePoiIds')).toBeNull()
|
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
|
||||||
expect(map.props('targetFocusRequest')).toBeNull()
|
expect(map.props('targetFocusRequest')).toBeNull()
|
||||||
|
|
||||||
const resetCount = testState.searchResetCount
|
const resetCount = testState.searchResetCount
|
||||||
@@ -528,7 +532,7 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
expect(testState.resetToViewBaselineCalls).toHaveLength(1)
|
expect(testState.resetToViewBaselineCalls).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('模型点选展厅的相关讲解进入该展厅的讲解对象列表,并在返回时复位原楼层', async () => {
|
it('模型点选展厅只显示一个讲解入口,并在返回时复位原楼层', async () => {
|
||||||
testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }]
|
testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }]
|
||||||
const wrapper = await mountIndex()
|
const wrapper = await mountIndex()
|
||||||
const map = wrapper.getComponent(GuideMapShellStub)
|
const map = wrapper.getComponent(GuideMapShellStub)
|
||||||
@@ -541,9 +545,10 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const relatedActions = wrapper.findAll('.poi-action')
|
const actions = wrapper.findAll('.poi-action')
|
||||||
expect(relatedActions).toHaveLength(2)
|
expect(actions).toHaveLength(1)
|
||||||
await relatedActions[1]!.trigger('tap')
|
expect(wrapper.text()).not.toContain('相关讲解')
|
||||||
|
await wrapper.find('.poi-action.primary').trigger('tap')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/explain/guide-stop-list?hallId=hall-l2')
|
expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/explain/guide-stop-list?hallId=hall-l2')
|
||||||
@@ -559,7 +564,165 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
expect(wrapper.find('.guide-poi-card').exists()).toBe(false)
|
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)
|
||||||
|
|
||||||
|
map.vm.$emit('poi-click', {
|
||||||
|
...poi,
|
||||||
|
id: 'space-l2-temporary',
|
||||||
|
name: '生物厅临展空间',
|
||||||
|
kind: 'space',
|
||||||
|
hallId: 'space-l2-temporary',
|
||||||
|
primaryCategory: 'exhibition_hall',
|
||||||
|
primaryCategoryZh: '展厅'
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.guide-poi-card').exists()).toBe(true)
|
||||||
|
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 () => {
|
||||||
|
testState.halls = [{ id: 'hall-biology', name: '生物厅' }]
|
||||||
|
const wrapper = await mountIndex()
|
||||||
|
const map = wrapper.getComponent(GuideMapShellStub)
|
||||||
|
|
||||||
|
map.vm.$emit('poi-click', {
|
||||||
|
...poi,
|
||||||
|
id: 'hall-space-biology',
|
||||||
|
name: '展厅_6生物厅',
|
||||||
|
kind: 'space',
|
||||||
|
hallId: 'space-biology',
|
||||||
|
primaryCategory: 'exhibition_hall',
|
||||||
|
primaryCategoryZh: '展厅'
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const actions = wrapper.findAll('.poi-action')
|
||||||
|
expect(actions).toHaveLength(1)
|
||||||
|
await actions[0]!.trigger('tap')
|
||||||
|
await flushPromises()
|
||||||
|
expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain(
|
||||||
|
'/pages/explain/guide-stop-list?hallId=hall-biology'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('模型点选展厅的唯一讲解入口进入讲解对象列表并保留一次性返回上下文', async () => {
|
||||||
testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }]
|
testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }]
|
||||||
const wrapper = await mountIndex()
|
const wrapper = await mountIndex()
|
||||||
const map = wrapper.getComponent(GuideMapShellStub)
|
const map = wrapper.getComponent(GuideMapShellStub)
|
||||||
@@ -571,7 +734,7 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
primaryCategoryZh: '展厅'
|
primaryCategoryZh: '展厅'
|
||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await wrapper.findAll('.poi-action')[1]!.trigger('tap')
|
await wrapper.find('.poi-action.primary').trigger('tap')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/explain/guide-stop-list?hallId=hall-l2')
|
expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/explain/guide-stop-list?hallId=hall-l2')
|
||||||
@@ -698,7 +861,7 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
expect(testState.resetToViewBaselineCalls).toHaveLength(0)
|
expect(testState.resetToViewBaselineCalls).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('单结果分类写入 resultCount=1,主动取消恢复默认标记并清除目标', async () => {
|
it('单结果分类使用统一详情目标,主动取消恢复默认标记并清除目标', async () => {
|
||||||
const wrapper = await mountIndex()
|
const wrapper = await mountIndex()
|
||||||
const search = wrapper.getComponent(PoiSearchPanelStub)
|
const search = wrapper.getComponent(PoiSearchPanelStub)
|
||||||
const map = wrapper.getComponent(GuideMapShellStub)
|
const map = wrapper.getComponent(GuideMapShellStub)
|
||||||
@@ -714,7 +877,7 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const detailUrl = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url as string
|
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()
|
expect(map.props('targetFocusRequest')).toBeNull()
|
||||||
|
|
||||||
search.vm.$emit('cancel')
|
search.vm.$emit('cancel')
|
||||||
|
|||||||
37
tests/unit/PoiDisplayPolicy.spec.ts
Normal file
37
tests/unit/PoiDisplayPolicy.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
getPoiDisplayPolicy,
|
||||||
|
isPoiVisibilityTierAtLeast
|
||||||
|
} from '@/domain/poiDisplay'
|
||||||
|
|
||||||
|
describe('POI 显示策略', () => {
|
||||||
|
it('空间点位初始总览保留 marker,但不进入环境标签层', () => {
|
||||||
|
const policy = getPoiDisplayPolicy({
|
||||||
|
primaryCategory: 'space_public_area',
|
||||||
|
kind: 'space'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(policy.labelVisible).toBe(false)
|
||||||
|
expect(policy.minMarkerTier).toBe('tight')
|
||||||
|
expect(policy.allowOverview).toBe(true)
|
||||||
|
expect(policy.allowMulti).toBe(true)
|
||||||
|
expect(policy.minLabelTier).toBe('full')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('展厅和服务设施拥有不同优先级与标签密度策略', () => {
|
||||||
|
const hall = getPoiDisplayPolicy({ primaryCategory: 'exhibition_hall', kind: 'hall' })
|
||||||
|
const service = getPoiDisplayPolicy({ primaryCategory: 'business_poi', kind: 'facility' })
|
||||||
|
|
||||||
|
expect(hall.labelVisible).toBe(true)
|
||||||
|
expect(hall.minLabelTier).toBe('tight')
|
||||||
|
expect(hall.priority).toBeGreaterThan(service.priority)
|
||||||
|
expect(service.minLabelTier).toBe('full')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('LOD tier 按 tight 到 full 单调放宽', () => {
|
||||||
|
expect(isPoiVisibilityTierAtLeast('tight', 'tight')).toBe(true)
|
||||||
|
expect(isPoiVisibilityTierAtLeast('balanced', 'tight')).toBe(true)
|
||||||
|
expect(isPoiVisibilityTierAtLeast('balanced', 'full')).toBe(false)
|
||||||
|
expect(isPoiVisibilityTierAtLeast('full', 'balanced')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user