修复搜索与点位定位闭环
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-12 03:07:49 +08:00
parent e006333c0a
commit d8420fc7c2
18 changed files with 2961 additions and 621 deletions

View File

@@ -329,6 +329,7 @@ const props = withDefaults(defineProps<{
initialView?: ViewMode
showControls?: boolean
showPoi?: boolean
visiblePoiIds?: string[] | null
targetFocus?: TargetPoiFocusRequest | null
touchGestureMode?: TouchGestureMode
targetFocusDistanceFactor?: number
@@ -345,6 +346,7 @@ const props = withDefaults(defineProps<{
initialView: 'overview',
showControls: true,
showPoi: false,
visiblePoiIds: null,
targetFocus: null,
touchGestureMode: 'pan',
targetFocusDistanceFactor: 0.36,
@@ -395,7 +397,21 @@ const floors = computed<FloorOption[]>(() => (
label: formatFloorLabel(floor.floorId)
}))
))
const shouldRenderPoiMarkers = computed(() => props.showPoi || props.showControls || Boolean(props.targetFocus))
const shouldRenderPoiMarkers = computed(() => (
props.showPoi
|| props.showControls
|| props.visiblePoiIds !== null
|| Boolean(props.targetFocus)
))
const visiblePoiIdFilter = computed(() => (
props.visiblePoiIds === null
? null
: new Set(props.visiblePoiIds)
))
const isPoiIncludedByVisibleFilter = (poi: RenderPoi) => (
visiblePoiIdFilter.value === null || visiblePoiIdFilter.value.has(poi.id)
)
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
@@ -1480,8 +1496,28 @@ const updatePoiVisibilityByDistance = () => {
.sort((a, b) => (b.poi ? getPoiPriority(b.poi) : 0) - (a.poi ? getPoiPriority(a.poi) : 0))
candidates.forEach(({ sprite, poi, screenPosition }) => {
if (!poi) {
if (!poi || !isPoiIncludedByVisibleFilter(poi)) {
sprite.visible = false
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
const labelSprite = getPoiSpriteUserData(sprite).labelSprite
if (hitTarget) {
hitTarget.visible = false
}
if (labelSprite) {
labelSprite.visible = false
}
return
}
// 分类结果模式直接展示列表对应点位,避免密度策略再次裁剪结果。
if (visiblePoiIdFilter.value !== null) {
sprite.visible = true
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
if (hitTarget) {
hitTarget.visible = true
}
acceptedPositions.push(screenPosition)
visibleCount += 1
return
}
@@ -2182,7 +2218,12 @@ const getPoiHitTargets = () => {
const sprites: THREE.Sprite[] = []
poiGroup?.traverse((child) => {
if (isPoiHitTargetSprite(child)) {
const poi = child.userData.poi as RenderPoi | undefined
if (
isPoiHitTargetSprite(child)
&& child.visible
&& Boolean(poi && isPoiIncludedByVisibleFilter(poi))
) {
sprites.push(child)
}
})
@@ -4513,7 +4554,11 @@ const createPoiMarkerGroup = (
labelSprite.position.copy(getPoiAmbientLabelPosition(displayPosition, markerSize))
labelSprite.visible = false
}
sprite.visible = shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
sprite.visible = isPoiIncludedByVisibleFilter(poi)
&& (
visiblePoiIdFilter.value !== null
|| shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
)
hitTarget.visible = sprite.visible
group.add(sprite)
group.add(hitTarget)
@@ -5294,6 +5339,12 @@ watch(() => props.targetFocus, (request) => {
immediate: true
})
watch(() => props.visiblePoiIds, () => {
refreshPoiVisibilityByDistance()
}, {
deep: true
})
watch(() => [props.routePreview, props.showRoute], () => {
renderRoutePreview()
}, {

View File

@@ -13,6 +13,7 @@
:initial-view="indoorView"
:show-controls="false"
:show-poi="shouldShowIndoorPois"
:visible-poi-ids="visiblePoiIds"
:target-focus="targetFocusRequest"
:touch-gesture-mode="touchGestureMode"
:target-focus-distance-factor="targetFocusDistanceFactor"
@@ -351,6 +352,7 @@ const props = withDefaults(defineProps<{
normalizeFloorId?: (labelOrId: string) => string
dimmed?: boolean
targetFocusRequest?: TargetPoiFocusRequest | null
visiblePoiIds?: string[] | null
touchGestureMode?: TouchGestureMode
targetFocusDistanceFactor?: number
routePreview?: GuideRouteResult | null
@@ -403,6 +405,7 @@ const props = withDefaults(defineProps<{
normalizeFloorId: (labelOrId: string) => labelOrId,
dimmed: false,
targetFocusRequest: null,
visiblePoiIds: null,
touchGestureMode: 'pan',
targetFocusDistanceFactor: 0.36,
routePreview: null,

File diff suppressed because it is too large Load Diff

View File

@@ -15,9 +15,12 @@ import type {
import {
NAV_ROUTE_UNAVAILABLE_MESSAGE
} from '@/domain/guideReadiness'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
isPoiSearchCategorySupported
} from '@/domain/poiCategories'
import type {
SgsFloorDiagnosticsPayload,
SgsGuideStopPayload,
@@ -194,20 +197,22 @@ const toNumber = (value: number | null | undefined, fallback = 0) => (
Number.isFinite(Number(value)) ? Number(value) : fallback
)
const optionalNumber = (value: number | null | undefined) => (
Number.isFinite(Number(value)) ? Number(value) : undefined
)
const optionalNumber = (value: number | null | undefined) => (
value !== null && typeof value !== 'undefined' && Number.isFinite(Number(value))
? Number(value)
: undefined
)
const normalizedText = (value?: string | null) => (value || '').trim()
const normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => {
const x = Number(source.x)
const y = Number(source.y)
const z = Number(source.z)
if (![x, y, z].every(Number.isFinite)) return undefined
return [x, y, z]
}
const normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => {
const x = optionalNumber(source.x)
const y = optionalNumber(source.y)
const z = optionalNumber(source.z)
if (typeof x === 'undefined' || typeof y === 'undefined' || typeof z === 'undefined') return undefined
return [x, y, z]
}
const normalizeStatus = (status?: string): GuideDiagnosticsStatus => {
if (status === 'OK' || status === 'WARN' || status === 'ERROR') return status
@@ -354,7 +359,7 @@ const searchableSpaceText = (space: SgsSpacePayload) => [
const parseBoundaryWktCenter = (
boundaryWkt?: string | null,
fallbackY = 0
fallbackY?: number
): [number, number, number] | undefined => {
const matches = [...(boundaryWkt || '').matchAll(/(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g)]
const points = matches
@@ -377,12 +382,11 @@ const normalizePositionSourceWithFallbackY = (
): [number, number, number] | undefined => {
if (!source) return undefined
const x = Number(source.x)
const rawY = Number(source.y)
const y = Number.isFinite(rawY) ? rawY : Number(fallbackY)
const z = Number(source.z)
const x = optionalNumber(source.x)
const y = optionalNumber(source.y) ?? optionalNumber(fallbackY)
const z = optionalNumber(source.z)
if (![x, y, z].every(Number.isFinite)) return undefined
if (typeof x === 'undefined' || typeof y === 'undefined' || typeof z === 'undefined') return undefined
return [x, y, z]
}
@@ -513,13 +517,16 @@ const kindForSgsPoi = (poi: SgsPoiPayload, category: MuseumCategory) => {
return 'facility'
}
export const toMuseumPoiFromSgs = (
poi: SgsPoiPayload,
floors: SgsSdkFloorSummaryPayload[]
): MuseumPoi => {
const floorId = stringifyId(poi.floorId)
const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === poi.floorCode)
const category = categoryFor(poi)
export const toMuseumPoiFromSgs = (
poi: SgsPoiPayload,
floors: SgsSdkFloorSummaryPayload[]
): MuseumPoi => {
const sourceFloorId = stringifyId(poi.floorId)
const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === sourceFloorId || floor.floorCode === poi.floorCode)
const floorId = stringifyId(matchedFloor?.floorId)
|| sourceFloorId
|| stringifyId(poi.floorCode)
const category = categoryFor(poi)
return {
id: stringifyId(poi.id),
@@ -983,14 +990,19 @@ const addIssue = (
})
}
const missingFieldsForPoi = (poi: MuseumPoi) => {
const fields: string[] = []
if (!poi.id) fields.push('id')
if (!poi.name) fields.push('name')
if (!poi.floorId) fields.push('floorId')
if (!poi.positionGltf) fields.push('position')
return fields
}
const missingFieldsForPoi = (poi: MuseumPoi) => {
const fields: string[] = []
if (!poi.id) fields.push('id')
if (!poi.name) fields.push('name')
if (!poi.floorId) fields.push('floorId')
if (!poi.floorLabel) fields.push('floorLabel')
if (
!Array.isArray(poi.positionGltf)
|| poi.positionGltf.length !== 3
|| poi.positionGltf.some((value) => !Number.isFinite(value))
) fields.push('position')
return fields
}
export const createGuideDataIntegrityReport = (
diagnostics: GuideMapDiagnostics,
@@ -1065,7 +1077,7 @@ export const createGuideDataIntegrityReport = (
}
if (poi.id) poiIds.add(poi.id)
if (poi.floorId && !floorIds.has(poi.floorId)) {
if (poi.floorId && !floorIds.has(poi.floorId)) {
addIssue(issues, {
scope: 'poi',
severity: 'error',
@@ -1074,9 +1086,21 @@ export const createGuideDataIntegrityReport = (
floorLabel: poi.floorLabel,
poiId: poi.id,
fields: ['floorId']
})
}
})
})
}
if (!isPoiSearchCategorySupported(poi)) {
addIssue(issues, {
scope: 'poi',
severity: 'warn',
message: `${poi.name || poi.id} 缺少有效搜索分类`,
floorId: poi.floorId,
floorLabel: poi.floorLabel,
poiId: poi.id,
fields: ['primaryCategory']
})
}
})
const errorCount = issues.filter((issue) => issue.severity === 'error').length
const warningCount = issues.filter((issue) => issue.severity === 'warn').length

288
src/domain/poiCategories.ts Normal file
View File

@@ -0,0 +1,288 @@
import type {
MuseumCategory,
MuseumPoi
} from '@/domain/museum'
export type PoiCategoryId =
| 'exhibition-hall'
| 'cinema'
| 'ticket-office'
| 'service-center'
| 'dining'
| 'souvenir'
| 'nursing-room'
| 'restroom'
| 'elevator'
| 'stairs'
| 'escalator'
export interface PoiCategoryDefinition {
id: PoiCategoryId
label: string
icon: string
order: number
homeVisible: boolean
keywords: readonly string[]
categoryIds: readonly string[]
iconTypes: readonly string[]
}
const definitions: PoiCategoryDefinition[] = [
{
id: 'exhibition-hall',
label: '展厅',
icon: 'exhibition',
order: 1,
homeVisible: true,
keywords: ['展厅', '展览', '展馆', 'exhibition_hall', 'touring_poi', 'hall'],
categoryIds: ['exhibition_hall', 'exhibition_hall_entrance', 'touring_poi'],
iconTypes: ['exhibition_hall', 'hall_entrance', 'exhibition']
},
{
id: 'cinema',
label: '影院',
icon: 'cinema',
order: 2,
homeVisible: false,
keywords: ['影院', '影厅', '剧场', '报告厅', 'cinema', 'theater'],
categoryIds: ['space_theater'],
iconTypes: ['cinema', 'theater']
},
{
id: 'ticket-office',
label: '售票处',
icon: 'ticket',
order: 3,
homeVisible: false,
keywords: ['售票处', '售票', '票务', 'ticket_office'],
categoryIds: [],
iconTypes: ['ticket_office']
},
{
id: 'service-center',
label: '服务中心',
icon: 'service',
order: 4,
homeVisible: false,
keywords: ['服务中心', '服务台', '咨询台', 'service_desk'],
categoryIds: [],
iconTypes: ['service_desk']
},
{
id: 'dining',
label: '餐饮',
icon: 'restaurant',
order: 5,
homeVisible: false,
keywords: ['餐饮', '餐厅', '快餐', '咖啡', 'restaurant', 'dining', 'food', 'cafe'],
categoryIds: [],
iconTypes: ['restaurant', 'dining', 'food', 'cafe']
},
{
id: 'souvenir',
label: '文创',
icon: 'bag',
order: 6,
homeVisible: false,
keywords: ['文创', '纪念品', '商店', 'souvenir', 'gift', 'shop'],
categoryIds: [],
iconTypes: ['souvenir', 'gift', 'shop', 'cultural_creative']
},
{
id: 'nursing-room',
label: '母婴室',
icon: 'nursing',
order: 7,
homeVisible: true,
keywords: ['母婴室', '母婴间', 'mother_baby_room', 'nursing_room'],
categoryIds: [],
iconTypes: ['mother_baby_room', 'nursing_room']
},
{
id: 'restroom',
label: '卫生间',
icon: 'restroom',
order: 8,
homeVisible: true,
keywords: ['卫生间', '洗手间', '厕所', 'toilet', 'accessible_toilet', 'restroom'],
categoryIds: [],
iconTypes: ['toilet', 'accessible_toilet', 'restroom', 'restroom_accessible']
},
{
id: 'elevator',
label: '电梯',
icon: 'elevator',
order: 9,
homeVisible: true,
keywords: ['电梯', 'elevator'],
categoryIds: [],
iconTypes: ['elevator']
},
{
id: 'stairs',
label: '楼梯',
icon: 'stairs',
order: 10,
homeVisible: true,
keywords: ['楼梯', 'stairs', 'stair'],
categoryIds: [],
iconTypes: ['stairs', 'stair']
},
{
id: 'escalator',
label: '扶梯',
icon: 'escalator',
order: 11,
homeVisible: true,
keywords: ['扶梯', 'escalator'],
categoryIds: [],
iconTypes: ['escalator']
}
]
export const POI_CATEGORIES = Object.freeze(
[...definitions].sort((left, right) => left.order - right.order)
)
const homeCategoryOrder: readonly PoiCategoryId[] = [
'exhibition-hall',
'restroom',
'nursing-room',
'elevator',
'stairs',
'escalator'
]
export const HOME_POI_CATEGORIES = Object.freeze(
homeCategoryOrder.map((categoryId) => (
POI_CATEGORIES.find((category) => category.id === categoryId)!
))
)
type PoiCategorySource = Pick<MuseumPoi, 'name' | 'primaryCategory' | 'categories'>
const normalizeValue = (value?: string | null) => (value || '').trim().toLowerCase()
const categoryValues = (categories: MuseumCategory[]) => categories.flatMap((category) => [
category.id,
category.label,
category.iconType || ''
])
export const matchesPoiCategory = (
poi: PoiCategorySource,
definition: PoiCategoryDefinition
) => {
const categories = [poi.primaryCategory, ...(poi.categories || [])]
const normalizedCategoryIds = new Set(categories.map((category) => normalizeValue(category.id)))
const normalizedIconTypes = new Set(categories.map((category) => normalizeValue(category.iconType)))
if (definition.categoryIds.some((categoryId) => normalizedCategoryIds.has(normalizeValue(categoryId)))) {
return true
}
if (definition.iconTypes.some((iconType) => normalizedIconTypes.has(normalizeValue(iconType)))) {
return true
}
const searchableText = [
poi.name,
...categoryValues(categories)
]
.map(normalizeValue)
.filter(Boolean)
.join(' ')
return definition.keywords.some((keyword) => searchableText.includes(normalizeValue(keyword)))
}
export const getPoiCategoryById = (categoryId?: string | null) => (
POI_CATEGORIES.find((category) => category.id === categoryId) || null
)
export const findPoiCategoryByKeyword = (keyword: string) => {
const normalizedKeyword = normalizeValue(keyword)
if (!normalizedKeyword) return null
return POI_CATEGORIES.find((category) => (
normalizeValue(category.label) === normalizedKeyword
|| category.keywords.some((item) => normalizeValue(item) === normalizedKeyword)
)) || null
}
export const resolvePoiCategory = (poi: PoiCategorySource) => (
POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null
)
export const isPoiSearchCategorySupported = (poi: PoiCategorySource) => Boolean(resolvePoiCategory(poi))
export type PoiDataIssueCode =
| 'missing-id'
| 'missing-name'
| 'missing-floor'
| 'missing-position'
| 'invalid-position'
| 'unsupported-category'
export interface PoiDataIssue {
code: PoiDataIssueCode
poiId: string
poiName: string
}
const hasFinitePosition = (position?: [number, number, number]) => (
Array.isArray(position)
&& position.length === 3
&& position.every((value) => Number.isFinite(value))
)
export const getPoiDataIssues = (poi: MuseumPoi): PoiDataIssue[] => {
const issues: PoiDataIssue[] = []
const issueBase = {
poiId: poi.id || '',
poiName: poi.name || ''
}
if (!poi.id?.trim()) issues.push({ code: 'missing-id', ...issueBase })
if (!poi.name?.trim()) issues.push({ code: 'missing-name', ...issueBase })
if (!poi.floorId?.trim() || !poi.floorLabel?.trim()) issues.push({ code: 'missing-floor', ...issueBase })
if (!poi.positionGltf) {
issues.push({ code: 'missing-position', ...issueBase })
} else if (!hasFinitePosition(poi.positionGltf)) {
issues.push({ code: 'invalid-position', ...issueBase })
}
if (!isPoiSearchCategorySupported(poi)) issues.push({ code: 'unsupported-category', ...issueBase })
return issues
}
export interface PoiCollectionInspection {
issues: PoiDataIssue[]
duplicateIds: string[]
}
export const inspectPoiCollection = (pois: MuseumPoi[]): PoiCollectionInspection => {
const seenIds = new Set<string>()
const duplicateIds = new Set<string>()
pois.forEach((poi) => {
if (!poi.id) return
if (seenIds.has(poi.id)) duplicateIds.add(poi.id)
seenIds.add(poi.id)
})
return {
issues: pois.flatMap(getPoiDataIssues),
duplicateIds: Array.from(duplicateIds)
}
}
export const warnPoiCollectionIssues = (source: string, pois: MuseumPoi[]) => {
if (!import.meta.env.DEV) return
const inspection = inspectPoiCollection(pois)
if (!inspection.issues.length && !inspection.duplicateIds.length) return
// 开发期集中输出数据契约问题,避免在页面组件中散落源数据校验。
console.warn(`[POI 数据校验] ${source}`, inspection)
}

29
src/domain/poiSearch.ts Normal file
View File

@@ -0,0 +1,29 @@
import type {
MuseumPoi
} from '@/domain/museum'
import type {
PoiCategoryId
} from '@/domain/poiCategories'
export type PoiSearchOrigin = 'home' | 'page'
export interface PoiSearchContext {
origin: PoiSearchOrigin
keyword: string
categoryId: PoiCategoryId | ''
floorId: string
floorLabel: string
resultCount: number
floorResultCount: number
visiblePoiIds: string[]
listScrollTop: number
}
export interface PoiSearchSelection {
poi: MuseumPoi
context: PoiSearchContext
}
export interface PoiCategoryResultState extends PoiSearchContext {
active: boolean
}

View File

@@ -28,39 +28,46 @@
show-zoom-controls
zoom-controls-top="calc(100vh - 252px)"
@floor-change="handleFloorChange"
@target-focus="handleTargetFocus"
>
<view v-if="isDetailPanelVisible" class="detail-sheet">
<view class="detail-sheet" data-testid="facility-detail-sheet">
<view class="detail-title-row">
<view class="target-dot"></view>
<view class="detail-title-copy">
<text class="detail-title">{{ facility.name }}</text>
<text class="detail-subtitle">{{ facility.location }}</text>
</view>
<view class="detail-close" @tap.stop="handleCloseDetailPanel">
<view
class="detail-close"
data-testid="facility-detail-close"
aria-label="关闭点位详情"
@tap.stop="handleCloseDetailPanel"
>
<text class="detail-close-text">×</text>
</view>
</view>
<view class="tag-row">
<view
v-for="(tag, index) in facility.tags"
:key="tag"
class="detail-tag"
:class="{ active: index === 0 }"
>
<text class="detail-tag-text">{{ tag }}</text>
<view class="detail-meta" data-testid="facility-detail-meta">
<view class="detail-meta-item">
<text class="detail-meta-label">类别</text>
<text class="detail-meta-value">{{ facility.category }}</text>
</view>
<view class="detail-meta-item">
<text class="detail-meta-label">楼层</text>
<text class="detail-meta-value">{{ facility.floor }}</text>
</view>
</view>
<text class="detail-hint">已在对应楼层模型中标出点位</text>
</view>
<view
v-else
class="detail-restore"
@tap.stop="handleOpenDetailPanel"
>
<view class="target-dot small"></view>
<text class="detail-restore-text">点位</text>
<text v-if="facility.description" class="detail-description">
{{ facility.description }}
</text>
<view
v-if="locationErrorMessage"
class="detail-location-error"
data-testid="facility-location-error"
>
<text class="detail-location-error-text">{{ locationErrorMessage }}</text>
</view>
</view>
</GuideMapShell>
</GuidePageFrame>
@@ -78,8 +85,10 @@ import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
parseFacilityDetailSearchContext,
toFacilityDetailViewModel,
toTargetFocusRequestViewModel,
type FacilityDetailSearchContext,
type TargetPoiFocusRequestViewModel,
type FacilityDetailViewModel
} from '@/view-models/guideViewModels'
@@ -106,17 +115,29 @@ const activeFloor = ref('1F')
const requestedTargetFloorId = ref('')
const targetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
const targetFocusRequestId = ref(0)
const isDetailPanelVisible = ref(true)
const locationErrorMessage = ref('')
const resolutionLoadFailed = ref(false)
interface TargetPoiFocusResult {
requestId: number | string
poiId: string
floorId: string
status: 'focused' | 'missing' | 'error'
message?: string
}
const facility = ref<FacilityDetailViewModel>({
id: '',
name: '目标位置',
status: '可预览',
location: '正在定位三维点位',
traffic: guideUseCase.getRouteReadinessSnapshot().message,
tags: ['三维位置', '位置预览']
category: '正在加载',
floor: '正在加载',
description: ''
})
const searchContext = ref<FacilityDetailSearchContext>(
parseFacilityDetailSearchContext()
)
const loadGuideFloors = async () => {
try {
guideFloors.value = await guideUseCase.getFloors()
@@ -136,8 +157,12 @@ const loadGuideFloors = async () => {
}
const focusFacilityPoi = (poi: MuseumPoi) => {
if (!poi.floorId) return
if (!poi.floorId) {
locationErrorMessage.value = '该点位缺少楼层信息,暂时无法在地图中定位。'
return
}
locationErrorMessage.value = ''
activeMode.value = '3d'
activeFloor.value = poi.floorId
targetFocusRequestId.value += 1
@@ -153,8 +178,12 @@ const focusFacilityPoi = (poi: MuseumPoi) => {
}
const focusRenderPoi = (poi: GuideRenderPoi) => {
if (!poi.floorId) return
if (!poi.floorId) {
locationErrorMessage.value = '该点位缺少楼层信息,暂时无法在地图中定位。'
return
}
locationErrorMessage.value = ''
const floorLabel = guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
activeMode.value = '3d'
activeFloor.value = poi.floorId
@@ -177,20 +206,15 @@ const focusRenderPoi = (poi: GuideRenderPoi) => {
const getRenderPoiFloorLabel = (poi: GuideRenderPoi) =>
guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
const applyRenderPoiToFacility = (poi: GuideRenderPoi, routeMessage: string) => {
const applyRenderPoiToFacility = (poi: GuideRenderPoi) => {
const floorLabel = getRenderPoiFloorLabel(poi)
const categoryLabel = poi.primaryCategoryZh || '三维位置'
const categoryLabel = poi.primaryCategoryZh || '未分类'
facility.value = {
id: poi.id,
name: poi.name,
status: '可预览',
location: `${floorLabel} · ${categoryLabel}`,
traffic: routeMessage,
tags: [
floorLabel,
categoryLabel,
'展示点位'
]
category: categoryLabel,
floor: floorLabel || '楼层信息缺失',
description: ''
}
focusRenderPoi(poi)
}
@@ -201,7 +225,15 @@ const normalizePoiName = (value: string) => value
.toLowerCase()
const decodeRouteText = (value: unknown) => (
typeof value === 'string' ? decodeURIComponent(value) : ''
typeof value === 'string'
? (() => {
try {
return decodeURIComponent(value)
} catch {
return value
}
})()
: ''
)
const isPoiOnRequestedFloor = (poi: Pick<MuseumPoi | GuideRenderPoi, 'floorId'>) => (
@@ -222,6 +254,9 @@ const resolveFacilityPoi = async () => {
: null
if (poiById) return poiById
// 搜索结果必须按稳定 ID 解析,避免同名点位被定位到错误楼层。
if (searchContext.value.source === 'search') return null
const poiByHall = await resolvePoiFromHall()
if (poiByHall) return poiByHall
@@ -244,9 +279,10 @@ const resolveFacilityPoi = async () => {
}) || candidatesToMatch[0] || null
}
const resolveRenderPoiByName = async () => {
const resolveRenderPoi = async () => {
const resolveByExactId = searchContext.value.source === 'search' && Boolean(facility.value.id)
const normalizedTarget = normalizePoiName(facility.value.name)
if (!normalizedTarget) return null
if (!resolveByExactId && !normalizedTarget) return null
const modelPackage = await indoorModelSource.loadPackage()
const floorsToSearch = requestedTargetFloorId.value
@@ -255,8 +291,17 @@ const resolveRenderPoiByName = async () => {
for (const floor of floorsToSearch) {
const floorId = floor.floorId
const pois = await indoorModelSource.loadFloorPois(floorId).catch(() => [])
let pois: GuideRenderPoi[] = []
try {
pois = await indoorModelSource.loadFloorPois(floorId)
} catch (error) {
resolutionLoadFailed.value = true
console.warn(`楼层 ${floorId} 的模型点位加载失败:`, error)
continue
}
const matchedPoi = pois.find((poi) => {
if (resolveByExactId) return poi.id === facility.value.id
const poiName = normalizePoiName(poi.name)
const hallName = normalizePoiName(poi.hallName || '')
const sourceObjectName = normalizePoiName(poi.sourceObjectName || '')
@@ -276,8 +321,9 @@ const resolveRenderPoiByName = async () => {
const tryResolveRenderPoiByName = async () => {
try {
return await resolveRenderPoiByName()
return await resolveRenderPoi()
} catch (error) {
resolutionLoadFailed.value = true
console.warn('当前模型源点位解析失败,尝试静态资源兜底:', error)
return null
}
@@ -287,12 +333,15 @@ const tryResolveFacilityPoi = async () => {
try {
return await resolveFacilityPoi()
} catch (error) {
resolutionLoadFailed.value = true
console.warn('内容点位解析失败,继续使用模型点位预览:', error)
return null
}
}
onLoad(async (options: any) => {
onLoad(async (options: Record<string, unknown> = {}) => {
searchContext.value = parseFacilityDetailSearchContext(options)
resolutionLoadFailed.value = false
await loadGuideFloors()
if (options.id) {
@@ -303,40 +352,50 @@ onLoad(async (options: any) => {
facility.value.name = decodeRouteText(options.target)
}
if (options.floorId) {
requestedTargetFloorId.value = normalizeGuideFloorId(decodeRouteText(options.floorId))
const requestedFloorId = decodeRouteText(options.floorId)
|| searchContext.value.searchFloorId
if (requestedFloorId) {
requestedTargetFloorId.value = normalizeGuideFloorId(requestedFloorId)
if (requestedTargetFloorId.value) {
activeFloor.value = requestedTargetFloorId.value
}
}
if (options.floorLabel && requestedTargetFloorId.value) {
const floorLabel = decodeRouteText(options.floorLabel)
facility.value.location = `${floorLabel} · 位置预览`
facility.value.tags = [floorLabel, '三维位置', '位置预览']
const requestedFloorLabel = decodeRouteText(options.floorLabel)
|| searchContext.value.searchFloorLabel
if (requestedFloorLabel) {
facility.value.floor = requestedFloorLabel
}
if (!facility.value.id && !normalizePoiName(facility.value.name)) return
if (!facility.value.id && !options.target) {
locationErrorMessage.value = '未提供可定位的点位信息。'
return
}
try {
const routeReadinessMessage = guideUseCase.getRouteReadinessSnapshot().message
const poi = await tryResolveFacilityPoi()
if (poi) {
facility.value = toFacilityDetailViewModel(
poi,
routeReadinessMessage
)
facility.value = toFacilityDetailViewModel(poi)
focusFacilityPoi(poi)
return
}
const renderPoi = await tryResolveRenderPoiByName()
if (renderPoi) {
applyRenderPoiToFacility(renderPoi, routeReadinessMessage)
applyRenderPoiToFacility(renderPoi)
return
}
facility.value.category = facility.value.category === '正在加载'
? '未分类'
: facility.value.category
facility.value.floor = requestedFloorLabel || '楼层信息缺失'
locationErrorMessage.value = resolutionLoadFailed.value
? '位置数据加载失败,请稍后重试。'
: '该点位缺少可用的位置数据,暂时无法在地图中定位。'
} catch (error) {
console.error('加载设施位置详情失败:', error)
locationErrorMessage.value = '位置数据加载失败,请稍后重试。'
}
})
@@ -344,12 +403,19 @@ const handleFloorChange = (floor: string) => {
activeFloor.value = floor
}
const handleCloseDetailPanel = () => {
isDetailPanelVisible.value = false
const handleTargetFocus = (result: TargetPoiFocusResult) => {
if (result.status === 'focused') {
locationErrorMessage.value = ''
return
}
locationErrorMessage.value = result.status === 'missing'
? '该点位缺少可用的位置数据,暂时无法在地图中定位。'
: '地图定位暂不可用,请稍后重试。'
}
const handleOpenDetailPanel = () => {
isDetailPanelVisible.value = true
const handleCloseDetailPanel = () => {
handlePageBack()
}
const handleTopTabChange = (tab: GuideTopTab) => {
@@ -357,6 +423,13 @@ const handleTopTabChange = (tab: GuideTopTab) => {
}
const handlePageBack = () => {
if (searchContext.value.source === 'search' && searchContext.value.resultCount === 1) {
uni.reLaunch({
url: '/pages/index/index'
})
return
}
uni.navigateBack({
delta: 1,
fail: () => {
@@ -441,91 +514,62 @@ const handlePageBack = () => {
text-overflow: ellipsis;
}
.detail-subtitle {
.detail-meta {
margin-top: 12px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
}
.detail-meta-item {
min-width: 0;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 2px;
box-sizing: border-box;
background: #f7f8f3;
border: 1px solid #e5e6de;
border-radius: 8px;
}
.detail-meta-label {
font-size: 12px;
line-height: 17px;
line-height: 16px;
color: #696962;
}
.detail-meta-value {
min-width: 0;
font-size: 14px;
line-height: 20px;
font-weight: 600;
color: #1f2329;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-row {
margin-top: 12px;
display: flex;
gap: 8px;
}
.detail-tag {
height: 26px;
min-width: 68px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e5e6de;
border-radius: 8px;
}
.detail-tag.active {
background: #151713;
border-color: #151713;
}
.detail-tag-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #1f2329;
}
.detail-tag.active .detail-tag-text {
color: var(--museum-accent);
}
.detail-hint {
.detail-description {
display: block;
margin-top: 10px;
font-size: 12px;
line-height: 17px;
font-weight: 500;
color: #1a7f37;
font-size: 13px;
line-height: 19px;
color: #424754;
}
.detail-restore {
position: absolute;
right: 18px;
bottom: calc(env(safe-area-inset-bottom) + 152px);
height: 40px;
min-width: 74px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.94);
border: 1px solid #dde5df;
.detail-location-error {
margin-top: 10px;
padding: 9px 10px;
background: #fff4e8;
border: 1px solid #f1c99e;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 45;
}
.target-dot.small {
width: 10px;
height: 10px;
margin-top: 0;
border-width: 2px;
box-shadow: none;
}
.detail-restore-text {
.detail-location-error-text {
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #151713;
color: #7a3e05;
}
@media (max-width: 360px) {
@@ -534,9 +578,8 @@ const handlePageBack = () => {
right: 12px;
}
.detail-tag {
min-width: 0;
padding: 0 9px;
.detail-meta {
gap: 8px;
}
}
</style>

View File

@@ -28,6 +28,8 @@
:indoor-view="indoorView"
:layer-mode="indoorLayerMode"
:active-floor="activeGuideFloor"
:visible-poi-ids="searchVisiblePoiIds"
:target-focus-request="searchTargetFocusRequest"
layer-mode-toggle-top="154px"
floor-bottom="193px"
floor-max-height="206px"
@@ -162,17 +164,20 @@
></view>
<view
v-if="showGuideHomeDock"
v-show="showGuideHomeDock"
class="guide-home-dock"
:class="{ expanded: homeSearchExpanded }"
@mousedown.capture="handleHomeDockActivate"
@click.capture="handleHomeDockActivate"
@tap.capture="handleHomeDockActivate"
>
<PoiSearchPanel
ref="homeSearchPanelRef"
variant="home"
:current-floor-id="activeGuideFloor"
:current-floor-label="getGuideFloorLabel(activeGuideFloor)"
@expanded-change="handleHomeSearchExpandedChange"
@category-mode-change="handleHomeCategoryModeChange"
@results-change="handleHomeSearchResultsChange"
@result-tap="handleHomeSearchResultTap"
@cancel="handleHomeSearchCancel"
/>
</view>
@@ -299,8 +304,8 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
import RoutePlannerPanel from '@/components/navigation/RoutePlannerPanel.vue'
@@ -326,12 +331,17 @@ import {
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
normalizeExplainDetailTargetFromGuideStop
} from '@/domain/explainDetailTarget'
import type {
GuideRenderPoi
} from '@/domain/guideModel'
import type {
PoiCategoryResultState,
PoiSearchContext
} from '@/domain/poiSearch'
import {
toTargetFocusRequestViewModel,
type TargetPoiFocusRequestViewModel
} from '@/view-models/guideViewModels'
import {
ARRIVAL_TARGETS_BY_TYPE,
type ArrivalSearchTarget,
@@ -341,7 +351,8 @@ import type {
GuideRouteResult,
GuideRouteTarget,
MuseumFloor,
MuseumHall
MuseumHall,
MuseumPoi
} from '@/domain/museum'
type GuideIndoorView = 'overview' | 'floor' | 'multi'
@@ -424,10 +435,15 @@ const guideMapShellRef = ref<{
showOverview?: () => Promise<void> | void
} | null>(null)
const homeSearchPanelRef = ref<{
expandHomePanel?: () => void
collapseHomePanel?: () => void
resetSearchState?: () => void
restoreResultListScroll?: () => Promise<void>
} | null>(null)
const homeSearchExpanded = ref(false)
const homeCategoryModeActive = ref(false)
const searchVisiblePoiIds = ref<string[] | null>(null)
const searchTargetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
let searchTargetFocusRequestId = 0
let launchOverlayFallbackTimer: ReturnType<typeof setTimeout> | null = null
let launchOverlayFadeTimer: ReturnType<typeof setTimeout> | null = null
@@ -732,7 +748,7 @@ const selectedGuidePoiIsHall = computed(() => {
})
const selectedGuidePoiCollapsedSubtitle = computed(() => (
selectedGuidePoiIsHall.value ? '已定位展厅 · 点按展开操作' : '已定位点位'
selectedGuidePoiIsHall.value ? '点按展开查看展厅' : selectedGuidePoiSubtitle.value
))
const routeSimulationStepText = computed(() => {
@@ -999,7 +1015,7 @@ const handleGuidePoiClick = (poi: GuideRenderPoi) => {
isSimulatingRoute.value = false
activeGuideFloor.value = poi.floorId
renderedFloorId.value = poi.floorId
showIndoorHint('已在当前楼层标出点位', 3000)
showIndoorHint(`${poi.name} · ${getGuideFloorLabel(poi.floorId)}`, 3000)
}
const handleGuideSelectionClear = () => {
@@ -1390,6 +1406,11 @@ const closeHomeSearchDock = () => {
homeSearchExpanded.value = false
}
onShow(async () => {
await nextTick()
await homeSearchPanelRef.value?.restoreResultListScroll?.()
})
const handleMoreOutdoorNav = () => {
if (showArrivalPanel.value && showArrivalPanelCollapsed.value) {
arrivalPanelRef.value?.expandPanel()
@@ -1417,15 +1438,110 @@ const handleHomeSearchExpandedChange = (expanded: boolean) => {
homeSearchExpanded.value = expanded
}
const handleHomeSearchOutsideTap = () => {
closeHomeSearchDock()
const clearHomeSearchMapState = () => {
homeCategoryModeActive.value = false
searchVisiblePoiIds.value = null
searchTargetFocusRequest.value = null
}
const handleHomeDockActivate = () => {
if (homeSearchExpanded.value) return
const handleHomeCategoryModeChange = (active: boolean) => {
homeCategoryModeActive.value = active
if (!active) {
searchVisiblePoiIds.value = null
return
}
homeSearchPanelRef.value?.expandHomePanel?.()
homeSearchExpanded.value = true
closeArrivalPanel()
showRoutePlanner.value = false
isSimulatingRoute.value = false
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
is3DMode.value = true
indoorView.value = 'floor'
}
const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => {
if (!state.active) {
searchVisiblePoiIds.value = null
return
}
searchVisiblePoiIds.value = [...state.visiblePoiIds]
if (state.floorId) {
activeGuideFloor.value = state.floorId
}
}
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) => {
closeArrivalPanel()
showRoutePlanner.value = false
isSimulatingRoute.value = false
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
is3DMode.value = true
indoorView.value = 'floor'
activeGuideFloor.value = poi.floorId
searchTargetFocusRequestId += 1
searchTargetFocusRequest.value = toTargetFocusRequestViewModel({
poiId: poi.id,
name: poi.name,
floorId: poi.floorId,
floorLabel: poi.floorLabel,
primaryCategoryZh: poi.primaryCategory.label,
positionGltf: poi.positionGltf,
sourceObjectName: poi.sourceObjectName,
kind: poi.kind,
hallId: poi.hallId,
hallName: poi.hallName,
entrances: poi.entrances
}, searchTargetFocusRequestId)
// 先把楼层与目标请求交给地图,再进入详情页;返回时列表组件仍保持原状态。
await nextTick()
uni.navigateTo({
url: createSearchDetailUrl(poi, context),
fail: () => {
uni.showToast({
title: '点位详情打开失败,请重试',
icon: 'none'
})
}
})
}
const handleHomeSearchCancel = () => {
clearHomeSearchMapState()
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
homeSearchExpanded.value = false
}
const handleHomeSearchOutsideTap = () => {
closeHomeSearchDock()
}
const guideShellMode = computed(() => (is3DMode.value ? '3d' : '2d'))

View File

@@ -12,6 +12,7 @@
<view class="search-panel-shell">
<PoiSearchPanel
ref="searchPanelRef"
:initial-keyword="initialKeyword"
variant="page"
autofocus
@@ -21,13 +22,16 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
const initialKeyword = ref('')
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const searchPanelRef = ref<{
restoreResultListScroll?: () => Promise<void>
} | null>(null)
onLoad((options: any) => {
const keyword = typeof options.keyword === 'string'
@@ -37,14 +41,14 @@ onLoad((options: any) => {
initialKeyword.value = keyword
})
onShow(async () => {
await nextTick()
await searchPanelRef.value?.restoreResultListScroll?.()
})
const handleBackToMap = () => {
uni.navigateBack({
delta: 1,
fail: () => {
uni.reLaunch({
url: '/pages/index/index?tab=guide'
})
}
uni.reLaunch({
url: '/pages/index/index?tab=guide'
})
}

View File

@@ -15,6 +15,9 @@ import {
isIndoorNavigableFloor,
isPoiOnIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
warnPoiCollectionIssues
} from '@/domain/poiCategories'
import {
formatNavFloorLabel,
isStaticIndoorNavigableFloorId,
@@ -127,6 +130,91 @@ const dedupePoisById = (pois: MuseumPoi[]) => {
})
}
const getPoiSpaceIdentity = (poi: MuseumPoi) => poi.sourceSpaceId || poi.spaceId || ''
const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => {
const hallPoisBySpaceId = new Map(
pois
.map((poi) => [getPoiSpaceIdentity(poi), poi] as const)
.filter(([spaceId]) => Boolean(spaceId))
)
const spacePointsBySpaceId = new Map(
spacePoints
.map((poi) => [getPoiSpaceIdentity(poi), poi] as const)
.filter(([spaceId]) => Boolean(spaceId))
)
const filteredPois = pois.filter((poi) => {
const spaceId = getPoiSpaceIdentity(poi)
const matchingSpacePoint = spaceId ? spacePointsBySpaceId.get(spaceId) : null
if (!matchingSpacePoint) return true
return matchingSpacePoint.primaryCategory.id === 'exhibition_hall'
})
const filteredSpacePoints = spacePoints.filter((poi) => {
const spaceId = getPoiSpaceIdentity(poi)
const matchingHallPoi = spaceId ? hallPoisBySpaceId.get(spaceId) : null
if (!matchingHallPoi) return true
return poi.primaryCategory.id !== 'exhibition_hall'
})
return dedupePoisById([
...filteredPois,
...filteredSpacePoints
])
}
const getMedianPoiY = (pois: MuseumPoi[]) => {
const values = pois
.map((poi) => poi.positionGltf?.[1])
.filter((value): value is number => Number.isFinite(value))
.sort((left, right) => left - right)
if (!values.length) return undefined
const middle = Math.floor(values.length / 2)
return values.length % 2 === 0
? (values[middle - 1] + values[middle]) / 2
: values[middle]
}
type SgsPoiSourceName = 'pois' | 'businessPois' | 'spaces' | 'navigablePlaces'
interface SgsPoiSourceFailure {
floorId: string
source: SgsPoiSourceName
message: string
}
const poiDataLoadErrorMessage = '点位数据加载失败,请检查网络后重试'
const toErrorMessage = (reason: unknown) => (
reason instanceof Error ? reason.message : String(reason || '未知错误')
)
const collectSgsSourceResult = <T>(
result: PromiseSettledResult<T>,
floorId: string,
source: SgsPoiSourceName,
failures: SgsPoiSourceFailure[]
) => {
if (result.status === 'fulfilled') return result.value
failures.push({
floorId,
source,
message: toErrorMessage(result.reason)
})
return null
}
const warnSgsSourceFailures = (scope: string, failures: SgsPoiSourceFailure[]) => {
if (!import.meta.env.DEV || !failures.length) return
// 开发期保留来源与楼层信息,便于区分真实空结果和接口失败。
console.warn(`[SGS 点位数据] ${scope}`, failures)
}
export interface GuideRepository {
getAssetBaseUrl(): string
getFloors(): Promise<MuseumFloor[]>
@@ -171,9 +259,11 @@ export class StaticGuideRepository implements GuideRepository {
if (this.poiCache) return this.poiCache
const pois = await this.provider.loadPoiIndex()
this.poiCache = pois
.map(toMuseumPoi)
const adaptedPois = pois.map(toMuseumPoi)
warnPoiCollectionIssues('static-nav-assets', adaptedPois)
this.poiCache = dedupePoisById(adaptedPois
.filter(isSearchableIndoorPoi)
)
return this.poiCache
}
@@ -299,30 +389,55 @@ export class SgsSdkGuideRepository implements GuideRepository {
const indoorFloors = manifest.floors.filter(isIndoorNavigableFloor)
const indoorFloorIds = new Set(indoorFloors.map((floor) => String(floor.floorId)))
this.floorAliases = buildSgsFloorAliases(indoorFloors)
const failures: SgsPoiSourceFailure[] = []
let successfulCoreSourceCount = 0
const floorDataGroups = await Promise.all(
indoorFloors.map(async (floor) => {
const floorId = String(floor.floorId)
const [pois, businessPois, spaces, navigablePlaces] = await Promise.all([
this.provider.getFloorPois(floorId).catch(() => []),
this.provider.getFloorBusinessPois(floorId).catch(() => []),
this.provider.getFloorSpaces(floorId).catch(() => []),
this.provider.getNavigablePlaces(floorId).catch(() => [])
const [poisResult, businessPoisResult, spacesResult, navigablePlacesResult] = await Promise.allSettled([
this.provider.getFloorPois(floorId),
this.provider.getFloorBusinessPois(floorId),
this.provider.getFloorSpaces(floorId),
this.provider.getNavigablePlaces(floorId)
])
const pois = collectSgsSourceResult(poisResult, floorId, 'pois', failures) || []
const businessPois = collectSgsSourceResult(businessPoisResult, floorId, 'businessPois', failures) || []
const spaces = collectSgsSourceResult(spacesResult, floorId, 'spaces', failures) || []
const navigablePlaces = collectSgsSourceResult(
navigablePlacesResult,
floorId,
'navigablePlaces',
failures
) || []
if (poisResult.status === 'fulfilled') successfulCoreSourceCount += 1
if (spacesResult.status === 'fulfilled') successfulCoreSourceCount += 1
const adaptedPois = [
...pois,
...businessPois
].map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
const fallbackY = getMedianPoiY(adaptedPois)
return {
floorId,
pois: [
...pois,
...businessPois
],
hallPois: toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, floorId)
pois: adaptedPois,
hallPois: toMuseumHallPoisFromSgs(
spaces,
navigablePlaces,
manifest.floors,
floorId,
{ fallbackY }
)
}
})
)
warnSgsSourceFailures('楼层点位聚合', failures)
if (indoorFloors.length && successfulCoreSourceCount === 0) {
throw new Error(poiDataLoadErrorMessage)
}
const ordinaryPois = floorDataGroups
.flatMap((group) => group.pois)
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter((poi) => poi.id && poi.name)
.filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi))
@@ -330,12 +445,18 @@ export class SgsSdkGuideRepository implements GuideRepository {
.flatMap((group) => group.hallPois)
.filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi))
this.poiCache = dedupePoisById([
const adaptedPois = [
...hallPois,
...ordinaryPois
])
]
warnPoiCollectionIssues('sgs-guide-pois-before-dedupe', adaptedPois)
const result = dedupePoisById(adaptedPois)
const hasCoreFailures = failures.some((failure) => (
failure.source === 'pois' || failure.source === 'spaces'
))
if (!hasCoreFailures) this.poiCache = result
return this.poiCache
return result
}
async listSpacePoints() {
@@ -345,48 +466,100 @@ export class SgsSdkGuideRepository implements GuideRepository {
const indoorFloors = manifest.floors.filter(isIndoorNavigableFloor)
const indoorFloorIds = new Set(indoorFloors.map((floor) => String(floor.floorId)))
this.floorAliases = buildSgsFloorAliases(indoorFloors)
const failures: SgsPoiSourceFailure[] = []
let successfulSpaceSourceCount = 0
const floorDataGroups = await Promise.all(
indoorFloors.map(async (floor) => {
const floorId = String(floor.floorId)
const spaces = await this.provider.getFloorSpaces(floorId).catch(() => [])
const [spacesResult, floorPoisResult] = await Promise.allSettled([
this.provider.getFloorSpaces(floorId),
this.provider.getFloorPois(floorId)
])
const spaces = collectSgsSourceResult(spacesResult, floorId, 'spaces', failures) || []
const floorPois = collectSgsSourceResult(floorPoisResult, floorId, 'pois', failures) || []
if (spacesResult.status === 'fulfilled') successfulSpaceSourceCount += 1
const fallbackY = getMedianPoiY(
floorPois.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
)
return {
floorId,
spaces
spaces,
fallbackY
}
})
)
warnSgsSourceFailures('空间点位聚合', failures)
if (indoorFloors.length && successfulSpaceSourceCount === 0) {
throw new Error(poiDataLoadErrorMessage)
}
const spacePoints = floorDataGroups
.flatMap((group) => group.spaces.map((space) => toMuseumSpacePointFromSgs(space, manifest.floors, group.floorId)))
.flatMap((group) => group.spaces.map((space) => toMuseumSpacePointFromSgs(
space,
manifest.floors,
group.floorId,
{ fallbackY: group.fallbackY }
)))
.filter((poi): poi is MuseumPoi => Boolean(poi))
.filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi))
this.spacePointCache = dedupePoisById(spacePoints)
warnPoiCollectionIssues('sgs-space-points-before-dedupe', spacePoints)
const result = dedupePoisById(spacePoints)
const hasSpaceFailures = failures.some((failure) => failure.source === 'spaces')
const floorsWithSpaces = new Set(
floorDataGroups
.filter((group) => group.spaces.length > 0)
.map((group) => group.floorId)
)
const hasPoiHeightFallbackFailures = failures.some((failure) => (
failure.source === 'pois' && floorsWithSpaces.has(failure.floorId)
))
if (!result.length && floorsWithSpaces.size && hasPoiHeightFallbackFailures) {
throw new Error(poiDataLoadErrorMessage)
}
if (!hasSpaceFailures && !hasPoiHeightFallbackFailures) this.spacePointCache = result
return this.spacePointCache
return result
}
async getPoiById(id: string) {
const pois = await this.listPois()
const matchedPoi = pois.find((poi) => poi.id === id)
const pois = await this.searchPois()
return pois.find((poi) => poi.id === id || poi.spaceId === id || poi.sourceSpaceId === id)
|| findPoiByNavIdNameFallback(pois, id)
if (matchedPoi) return matchedPoi
const spacePoints = await this.listSpacePoints()
return spacePoints.find((poi) => poi.id === id || poi.spaceId === id || poi.sourceSpaceId === id)
|| null
}
async searchPois(keyword = '') {
const pois = await this.listPois()
const sourceFailures: unknown[] = []
let pois: MuseumPoi[] = []
let spacePoints: MuseumPoi[] = []
try {
pois = await this.listPois()
} catch (error) {
sourceFailures.push(error)
}
try {
spacePoints = await this.listSpacePoints()
} catch (error) {
sourceFailures.push(error)
}
const searchablePois = mergeSearchablePois(pois, spacePoints)
if (sourceFailures.length && !searchablePois.length) {
throw new Error(poiDataLoadErrorMessage)
}
if (import.meta.env.DEV && sourceFailures.length) {
console.warn('[SGS 点位数据] 搜索使用部分可用数据', sourceFailures.map(toErrorMessage))
}
const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) return pois
if (!normalizedKeyword) return searchablePois
return pois.filter((poi) => toSearchText(poi).includes(normalizedKeyword))
return searchablePois.filter((poi) => toSearchText(poi).includes(normalizedKeyword))
}
async getLocationPreview(poiId: string) {
@@ -425,7 +598,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
async checkDataIntegrity() {
const [floors, pois, diagnostics] = await Promise.all([
this.getFloors(),
this.listPois(),
this.searchPois(),
this.getMapDiagnostics()
])

View File

@@ -140,7 +140,7 @@ export class GuideUseCase {
}
getInitialSearchSpacePoints() {
return this.guide.listSpacePoints()
return this.guide.searchPois()
}
getPoiById(id: string) {

View File

@@ -3,6 +3,9 @@ import type {
GuideLocationPreviewPlan,
MuseumPoi
} from '@/domain/museum'
import {
resolvePoiCategory
} from '@/domain/poiCategories'
export interface FacilityResultViewModel {
id: string
@@ -16,10 +19,22 @@ export interface FacilityResultViewModel {
export interface FacilityDetailViewModel {
id: string
name: string
status: string
location: string
traffic: string
tags: string[]
category: string
floor: string
description: string
}
export interface FacilityDetailSearchContext {
source: 'search' | 'direct'
searchOrigin: 'home' | 'page' | ''
searchKeyword: string
searchCategoryId: string
searchFloorId: string
searchFloorLabel: string
resultCount: number
floorResultCount: number
visiblePoiIds: string[]
listScrollTop: number
}
export interface TargetPoiFocusRequestViewModel extends GuideLocationPreview {
@@ -42,21 +57,53 @@ export const toFacilityResultViewModel = (poi: MuseumPoi): FacilityResultViewMod
})
export const toFacilityDetailViewModel = (
poi: MuseumPoi,
routeUnavailableMessage: string
poi: MuseumPoi
): FacilityDetailViewModel => ({
id: poi.id,
name: poi.name,
status: '可预览',
location: `${poi.floorLabel} · ${poi.primaryCategory.label}`,
traffic: routeUnavailableMessage,
tags: [
poi.floorLabel,
poi.primaryCategory.label,
poi.accessible ? '无障碍相关' : '展示点位'
]
category: resolvePoiCategory(poi)?.label || poi.primaryCategory.label || '未分类',
floor: poi.floorLabel || '楼层信息缺失',
description: ''
})
const decodeQueryText = (value: unknown) => {
if (typeof value !== 'string') return ''
try {
return decodeURIComponent(value)
} catch {
return value
}
}
const parseNonNegativeInteger = (value: unknown) => {
const parsed = Number.parseInt(decodeQueryText(value), 10)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
}
export const parseFacilityDetailSearchContext = (
options: Record<string, unknown> = {}
): FacilityDetailSearchContext => {
const searchOrigin = decodeQueryText(options.searchOrigin)
const visiblePoiIds = decodeQueryText(options.visiblePoiIds)
.split(',')
.map((id) => id.trim())
.filter(Boolean)
return {
source: decodeQueryText(options.source) === 'search' ? 'search' : 'direct',
searchOrigin: searchOrigin === 'home' || searchOrigin === 'page' ? searchOrigin : '',
searchKeyword: decodeQueryText(options.searchKeyword),
searchCategoryId: decodeQueryText(options.searchCategoryId),
searchFloorId: decodeQueryText(options.searchFloorId),
searchFloorLabel: decodeQueryText(options.searchFloorLabel),
resultCount: parseNonNegativeInteger(options.resultCount),
floorResultCount: parseNonNegativeInteger(options.floorResultCount),
visiblePoiIds: Array.from(new Set(visiblePoiIds)),
listScrollTop: parseNonNegativeInteger(options.listScrollTop)
}
}
export const toTargetFocusRequestViewModel = (
targetLocation: GuideLocationPreview,
requestId: number | string

View File

@@ -0,0 +1,319 @@
// @vitest-environment happy-dom
import { defineComponent } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import type { MuseumPoi } from '@/domain/museum'
import {
parseFacilityDetailSearchContext
} from '@/view-models/guideViewModels'
const mocks = vi.hoisted(() => ({
onLoadHandler: null as null | ((options?: Record<string, unknown>) => Promise<void>),
getFloors: vi.fn(),
getPoiById: vi.fn(),
searchPois: vi.fn(),
getHallById: vi.fn(),
loadPackage: vi.fn(),
loadFloorPois: vi.fn()
}))
vi.mock('@dcloudio/uni-app', () => ({
onLoad: (handler: (options?: Record<string, unknown>) => Promise<void>) => {
mocks.onLoadHandler = handler
}
}))
vi.mock('@/usecases/guideUseCase', () => ({
guideUseCase: {
getModelSource: () => ({
loadPackage: mocks.loadPackage,
loadFloorPois: mocks.loadFloorPois
}),
getFloors: mocks.getFloors,
getPoiById: mocks.getPoiById,
searchPois: mocks.searchPois,
normalizeFloorId: (value: string) => value
}
}))
vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: {
getHallById: mocks.getHallById
}
}))
vi.mock('@/utils/guideTopTabs', () => ({
navigateToGuideTopTab: vi.fn()
}))
import FacilityDetail from '@/pages/facility/detail.vue'
const facilityPoi: MuseumPoi = {
id: 'poi-restroom-l2',
name: '二层卫生间',
floorId: 'floor-l2',
floorLabel: '2F',
primaryCategory: {
id: 'restroom',
label: '卫生间'
},
categories: [{ id: 'restroom', label: '卫生间' }],
positionGltf: [12, 3, -8],
accessible: true
}
const cinemaPoi: MuseumPoi = {
...facilityPoi,
id: 'space-cinema-l1',
name: '球幕影院',
floorId: 'floor-l1',
floorLabel: '1F',
primaryCategory: {
id: 'space_theater',
label: '剧场空间',
iconType: 'theater'
},
categories: [{ id: 'space_theater', label: '剧场空间', iconType: 'theater' }],
accessible: false
}
const GuidePageFrameStub = defineComponent({
name: 'GuidePageFrameStub',
emits: ['back', 'tab-change'],
template: '<div data-testid="guide-page-frame"><slot /></div>'
})
const GuideMapShellStub = defineComponent({
name: 'GuideMapShellStub',
props: {
targetFocusRequest: {
type: Object,
default: null
},
activeFloor: {
type: String,
default: ''
}
},
emits: ['floor-change', 'target-focus'],
template: '<div data-testid="guide-map-shell"><slot /></div>'
})
const mountDetail = () => mount(FacilityDetail, {
global: {
stubs: {
GuidePageFrame: GuidePageFrameStub,
GuideMapShell: GuideMapShellStub
}
}
})
const loadDetail = async (options: Record<string, unknown>) => {
expect(mocks.onLoadHandler).toBeTypeOf('function')
await mocks.onLoadHandler?.(options)
await flushPromises()
}
beforeEach(() => {
mocks.onLoadHandler = null
mocks.getFloors.mockResolvedValue([
{ id: 'floor-l1', label: '1F', order: 1 },
{ id: 'floor-l2', label: '2F', order: 2 }
])
mocks.getPoiById.mockResolvedValue(facilityPoi)
mocks.searchPois.mockResolvedValue([])
mocks.getHallById.mockResolvedValue(null)
mocks.loadPackage.mockResolvedValue({
overviewModelUrl: '/overview.glb',
floors: [
{ floorId: 'floor-l1', label: '1F', order: 1, modelUrl: '/l1.glb' },
{ floorId: 'floor-l2', label: '2F', order: 2, modelUrl: '/l2.glb' }
]
})
mocks.loadFloorPois.mockResolvedValue([])
vi.stubGlobal('uni', {
navigateBack: vi.fn(),
reLaunch: vi.fn()
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('点位详情实际渲染与返回闭环', () => {
it('只展示真实详情字段,并向地图发送正确楼层与点位定位请求', async () => {
const wrapper = mountDetail()
await loadDetail({
id: encodeURIComponent(facilityPoi.id),
target: encodeURIComponent(facilityPoi.name),
floorId: facilityPoi.floorId,
floorLabel: facilityPoi.floorLabel
})
const sheet = wrapper.get('[data-testid="facility-detail-sheet"]')
const mapShell = wrapper.getComponent(GuideMapShellStub)
expect(sheet.text()).toContain('二层卫生间')
expect(sheet.text()).toContain('类别卫生间')
expect(sheet.text()).toContain('楼层2F')
expect(sheet.text()).not.toContain('可无障碍到达')
expect(sheet.text()).not.toContain('展示点位')
expect(sheet.text()).not.toContain('已在对应楼层模型中标出点位')
expect(wrapper.find('.detail-restore').exists()).toBe(false)
expect(mapShell.props('activeFloor')).toBe('floor-l2')
expect(mapShell.props('targetFocusRequest')).toMatchObject({
poiId: 'poi-restroom-l2',
floorId: 'floor-l2',
positionGltf: [12, 3, -8]
})
})
it('详情复用统一分类名称,不回退到 SDK 原始空间类别', async () => {
mocks.getPoiById.mockResolvedValue(cinemaPoi)
const wrapper = mountDetail()
await loadDetail({
source: 'search',
resultCount: '2',
id: cinemaPoi.id,
target: cinemaPoi.name,
floorId: cinemaPoi.floorId,
floorLabel: cinemaPoi.floorLabel
})
const sheetText = wrapper.get('[data-testid="facility-detail-sheet"]').text()
expect(sheetText).toContain('类别影院')
expect(sheetText).not.toContain('剧场空间')
})
it('搜索多结果关闭详情后返回原结果列表', async () => {
const wrapper = mountDetail()
await loadDetail({
source: 'search',
resultCount: '4',
id: facilityPoi.id,
target: facilityPoi.name
})
await wrapper.get('[data-testid="facility-detail-close"]').trigger('tap')
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
expect(uni.reLaunch).not.toHaveBeenCalled()
})
it('搜索单结果关闭详情后回到首页', async () => {
const wrapper = mountDetail()
await loadDetail({
source: 'search',
resultCount: '1',
id: facilityPoi.id,
target: facilityPoi.name
})
await wrapper.get('[data-testid="facility-detail-close"]').trigger('tap')
expect(uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/index/index' })
expect(uni.navigateBack).not.toHaveBeenCalled()
})
it('直接进入详情时使用常规返回', async () => {
const wrapper = mountDetail()
await loadDetail({ id: facilityPoi.id, target: facilityPoi.name })
await wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
expect(uni.reLaunch).not.toHaveBeenCalled()
})
it('地图报告点位缺失或定位失败时显示面向用户的错误反馈', async () => {
const wrapper = mountDetail()
await loadDetail({ id: facilityPoi.id, target: facilityPoi.name })
const mapShell = wrapper.getComponent(GuideMapShellStub)
mapShell.vm.$emit('target-focus', {
requestId: 1,
poiId: facilityPoi.id,
floorId: facilityPoi.floorId,
status: 'missing'
})
await flushPromises()
expect(wrapper.get('[data-testid="facility-location-error"]').text())
.toContain('缺少可用的位置数据')
mapShell.vm.$emit('target-focus', {
requestId: 1,
poiId: facilityPoi.id,
floorId: facilityPoi.floorId,
status: 'error'
})
await flushPromises()
expect(wrapper.get('[data-testid="facility-location-error"]').text())
.toContain('地图定位暂不可用')
})
it('位置数据源加载失败时提供明确重试反馈', async () => {
mocks.getPoiById.mockRejectedValueOnce(new Error('network unavailable'))
mocks.loadPackage.mockRejectedValueOnce(new Error('model package unavailable'))
const wrapper = mountDetail()
await loadDetail({
source: 'search',
resultCount: '2',
id: 'poi-network-error',
target: '网络异常点位',
floorId: 'floor-l2',
floorLabel: '2F'
})
expect(wrapper.get('[data-testid="facility-location-error"]').text())
.toContain('位置数据加载失败,请稍后重试')
expect(wrapper.get('[data-testid="facility-detail-meta"]').text()).toContain('楼层2F')
})
it('点位缺少楼层时显示可见兜底且不发送无效定位请求', async () => {
mocks.getPoiById.mockResolvedValueOnce({
...facilityPoi,
id: 'poi-without-floor',
floorId: '',
floorLabel: ''
})
const wrapper = mountDetail()
await loadDetail({ id: 'poi-without-floor', target: '楼层缺失点位' })
expect(wrapper.get('[data-testid="facility-detail-meta"]').text())
.toContain('楼层楼层信息缺失')
expect(wrapper.get('[data-testid="facility-location-error"]').text())
.toContain('缺少楼层信息')
expect(wrapper.getComponent(GuideMapShellStub).props('targetFocusRequest')).toBeNull()
})
it('完整解析并保留搜索关键词、分类、楼层和列表位置上下文', () => {
expect(parseFacilityDetailSearchContext({
source: 'search',
searchOrigin: 'home',
searchKeyword: encodeURIComponent('卫生间'),
searchCategoryId: 'restroom',
searchFloorId: 'floor-l2',
searchFloorLabel: '2F',
resultCount: '4',
floorResultCount: '2',
visiblePoiIds: 'poi-a,poi-b,poi-a',
listScrollTop: '168'
})).toEqual({
source: 'search',
searchOrigin: 'home',
searchKeyword: '卫生间',
searchCategoryId: 'restroom',
searchFloorId: 'floor-l2',
searchFloorLabel: '2F',
resultCount: 4,
floorResultCount: 2,
visiblePoiIds: ['poi-a', 'poi-b'],
listScrollTop: 168
})
})
})

View File

@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
const ThreeMapStub = defineComponent({
name: 'ThreeMap',
props: {
visiblePoiIds: {
type: Array,
default: null
},
targetFocus: {
type: Object,
default: null
}
},
emits: ['floor-change', 'target-focus'],
template: '<div data-testid="three-map"></div>'
})
const modelSource = {
loadPackage: async () => ({ overviewModelUrl: '', floors: [] }),
loadFloorPois: async () => []
}
const mountShell = (visiblePoiIds: string[] | null) => mount(GuideMapShell, {
props: {
mapType: 'indoor',
indoorModelSource: modelSource,
floors: [
{ id: 'L1', label: '1F' },
{ id: 'L2', label: '2F' }
],
activeFloor: 'L1',
visiblePoiIds
},
global: {
stubs: {
ThreeMap: ThreeMapStub
}
}
})
describe('GuideMapShell 点位过滤契约', () => {
it.each([
['不过滤时透传 null', null],
['无结果时透传空列表', []],
['有结果时透传精确 ID', ['poi-a', 'poi-b']]
])('%s', (_label, visiblePoiIds) => {
const wrapper = mountShell(visiblePoiIds as string[] | null)
expect(wrapper.getComponent(ThreeMapStub).props('visiblePoiIds')).toEqual(visiblePoiIds)
})
it('原样转发跨楼层与目标聚焦结果', async () => {
const wrapper = mountShell(['poi-l2'])
const renderer = wrapper.getComponent(ThreeMapStub)
const focusResult = {
requestId: 3,
poiId: 'poi-l2',
floorId: 'L2',
status: 'focused' as const
}
renderer.vm.$emit('floor-change', 'L2')
renderer.vm.$emit('target-focus', focusResult)
await wrapper.vm.$nextTick()
expect(wrapper.emitted('floorChange')).toEqual([['L2']])
expect(wrapper.emitted('targetFocus')).toEqual([[focusResult]])
})
})

View File

@@ -0,0 +1,166 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
SgsSdkApiProvider,
SgsSdkManifestPayload
} from '@/data/providers/sgsSdkApiProvider'
import { SgsSdkGuideRepository } from '@/repositories/GuideRepository'
import { toMuseumPoiFromSgs } from '@/data/adapters/sgsSdkGuideAdapter'
const manifest: SgsSdkManifestPayload = {
mapId: 'museum',
mapName: '深圳自然博物馆',
floors: [
{
floorId: '101',
floorCode: 'L1',
floorName: '1F',
sortOrder: 1
}
]
}
const floorPoi = {
id: 'poi-toilet',
name: '一层卫生间',
type: 'toilet',
typeName: '卫生间',
floorId: '101',
position: {
x: 4,
y: 12,
z: 8
}
}
const theaterSpace = {
id: 'space-theater',
name: '球幕影院',
type: 'theater',
floorId: '101',
boundaryWkt: 'POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))'
}
const createProvider = (overrides: Partial<SgsSdkApiProvider> = {}) => ({
getManifest: vi.fn().mockResolvedValue(manifest),
getFloorPois: vi.fn().mockResolvedValue([floorPoi]),
getFloorBusinessPois: vi.fn().mockResolvedValue([]),
getFloorSpaces: vi.fn().mockResolvedValue([theaterSpace]),
getNavigablePlaces: vi.fn().mockResolvedValue([]),
...overrides
}) as unknown as SgsSdkApiProvider
afterEach(() => {
vi.restoreAllMocks()
})
describe('SgsSdkGuideRepository 点位搜索', () => {
it('源数据仅有 floorCode 时回填 manifest 的稳定 floorId', () => {
const poi = toMuseumPoiFromSgs({
...floorPoi,
id: 'poi-floor-code-only',
floorId: undefined,
floorCode: 'L1'
}, manifest.floors)
expect(poi.floorId).toBe('101')
expect(poi.floorLabel).toBe('1F')
})
it('SDK null 坐标不会被误转换为模型原点', () => {
const poi = toMuseumPoiFromSgs({
...floorPoi,
id: 'poi-with-null-position',
position: {
x: null,
y: null,
z: null
}
}, manifest.floors)
expect(poi.positionGltf).toBeUndefined()
})
it('合并普通点位与空间点位,并保留影院真实分类和楼层高度', async () => {
const repository = new SgsSdkGuideRepository(createProvider())
const results = await repository.searchPois()
const theaterResults = results.filter((poi) => poi.name === '球幕影院')
expect(theaterResults).toHaveLength(1)
expect(theaterResults[0]).toMatchObject({
id: 'space-space-theater',
floorId: '101',
floorLabel: '1F',
primaryCategory: {
id: 'space_theater'
},
positionGltf: [5, 12, 5]
})
expect(results.some((poi) => poi.id === 'poi-toilet')).toBe(true)
})
it('部分来源失败时继续返回可用点位,并在后续搜索重试失败来源', async () => {
const getFloorSpaces = vi.fn()
.mockRejectedValueOnce(new Error('spaces offline'))
.mockRejectedValueOnce(new Error('spaces offline'))
.mockResolvedValue([theaterSpace])
const provider = createProvider({ getFloorSpaces })
const repository = new SgsSdkGuideRepository(provider)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const firstResults = await repository.searchPois()
expect(firstResults.map((poi) => poi.id)).toEqual(['poi-toilet'])
const secondResults = await repository.searchPois()
expect(secondResults.some((poi) => poi.id === 'space-space-theater')).toBe(true)
expect(getFloorSpaces).toHaveBeenCalledTimes(4)
})
it('补充来源失败不会阻止核心点位结果缓存', async () => {
const getFloorPois = vi.fn().mockResolvedValue([floorPoi])
const getFloorSpaces = vi.fn().mockResolvedValue([theaterSpace])
const getFloorBusinessPois = vi.fn().mockRejectedValue(new Error('business offline'))
const provider = createProvider({
getFloorPois,
getFloorSpaces,
getFloorBusinessPois
})
const repository = new SgsSdkGuideRepository(provider)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await repository.listPois()
await repository.listPois()
expect(getFloorPois).toHaveBeenCalledTimes(1)
expect(getFloorSpaces).toHaveBeenCalledTimes(1)
expect(getFloorBusinessPois).toHaveBeenCalledTimes(1)
})
it('楼层高度来源短暂失败时不缓存缺失的空间点位', async () => {
const getFloorPois = vi.fn()
.mockRejectedValueOnce(new Error('pois offline'))
.mockRejectedValueOnce(new Error('pois offline'))
.mockResolvedValue([floorPoi])
const provider = createProvider({ getFloorPois })
const repository = new SgsSdkGuideRepository(provider)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await expect(repository.searchPois()).rejects.toThrow('点位数据加载失败,请检查网络后重试')
const recoveredResults = await repository.searchPois()
expect(recoveredResults.find((poi) => poi.id === 'space-space-theater')?.positionGltf)
.toEqual([5, 12, 5])
expect(getFloorPois).toHaveBeenCalledTimes(4)
})
it('所有核心楼层与空间请求失败时返回明确的可重试错误', async () => {
const provider = createProvider({
getFloorPois: vi.fn().mockRejectedValue(new Error('pois offline')),
getFloorSpaces: vi.fn().mockRejectedValue(new Error('spaces offline'))
})
const repository = new SgsSdkGuideRepository(provider)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await expect(repository.searchPois()).rejects.toThrow('点位数据加载失败,请检查网络后重试')
})
})

View File

@@ -0,0 +1,283 @@
// @vitest-environment happy-dom
import { defineComponent, onMounted, onUnmounted } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import IndexPage from '@/pages/index/index.vue'
const testState = vi.hoisted(() => ({
searchMountCount: 0,
searchUnmountCount: 0,
searchRestoreCount: 0,
onShowHandler: null as null | (() => Promise<void> | void)
}))
vi.mock('@dcloudio/uni-app', () => ({
onLoad: vi.fn(),
onShow: (handler: () => Promise<void> | void) => {
testState.onShowHandler = handler
}
}))
const floors = [
{ id: 'L1', label: '1F', order: 1 },
{ id: 'L2', label: '2F', order: 2 }
]
const modelSource = {
loadPackage: async () => ({ overviewModelUrl: '', floors: [] }),
loadFloorPois: async () => []
}
vi.mock('@/usecases/guideUseCase', () => ({
guideUseCase: {
getAssetBaseUrl: () => '/static/nav-assets',
getModelSource: () => modelSource,
normalizeFloorId: (value: string) => (
value === '1F' ? 'L1' : value === '2F' ? 'L2' : value
),
getFloors: async () => floors
}
}))
vi.mock('@/usecases/guideRouteUseCase', () => ({
guideRouteUseCase: {
listTargets: async () => [],
searchTargets: async () => [],
getRouteReadiness: async () => ({ ready: false, message: '' }),
planRoute: async () => null
}
}))
vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: {
loadExplainHalls: async () => [],
loadExplainHallSummaries: async () => ({}),
listHalls: async () => [],
searchExplain: async () => []
}
}))
const GuidePageFrameStub = defineComponent({
name: 'GuidePageFrame',
template: '<main><slot /></main>'
})
const GuideMapShellStub = defineComponent({
name: 'GuideMapShell',
props: {
activeFloor: { type: String, default: '' },
indoorView: { type: String, default: '' },
visiblePoiIds: { type: Array, default: null },
targetFocusRequest: { type: Object, default: null }
},
emits: ['floor-change', 'poi-click', 'selection-clear'],
template: '<section data-testid="guide-map"><slot name="overlay" /><slot /></section>'
})
const PoiSearchPanelStub = defineComponent({
name: 'PoiSearchPanel',
props: {
currentFloorId: { type: String, default: '' },
currentFloorLabel: { type: String, default: '' }
},
emits: [
'expanded-change',
'category-mode-change',
'results-change',
'result-tap',
'cancel'
],
setup() {
onMounted(() => {
testState.searchMountCount += 1
})
onUnmounted(() => {
testState.searchUnmountCount += 1
})
return {}
},
methods: {
collapseHomePanel() {},
resetSearchState() {},
restoreResultListScroll() {
testState.searchRestoreCount += 1
}
},
template: '<div data-testid="poi-search"></div>'
})
const stubs = {
GuidePageFrame: GuidePageFrameStub,
GuideMapShell: GuideMapShellStub,
PoiSearchPanel: PoiSearchPanelStub,
RoutePlannerPanel: true,
ArrivalPanel: true,
ExplainHallSelect: true
}
const poi = {
id: 'poi-l2-restroom',
name: '二层卫生间',
floorId: 'L2',
floorLabel: '2F',
primaryCategory: { id: 'restroom', label: '卫生间' },
categories: [{ id: 'restroom', label: '卫生间' }],
positionGltf: [12, 3, 24] as [number, number, number],
accessible: true
}
const categoryContext = {
origin: 'home' as const,
keyword: '卫生间',
categoryId: 'restroom' as const,
floorId: 'L2',
floorLabel: '2F',
resultCount: 5,
floorResultCount: 2,
visiblePoiIds: ['poi-l2-restroom', 'poi-l2-restroom-2'],
listScrollTop: 126
}
const mountIndex = async () => {
const wrapper = mount(IndexPage, {
global: { stubs }
})
await flushPromises()
return wrapper
}
beforeEach(() => {
testState.searchMountCount = 0
testState.searchUnmountCount = 0
testState.searchRestoreCount = 0
testState.onShowHandler = null
window.history.replaceState({}, '', '/#/pages/index/index?tab=guide')
vi.stubGlobal('uni', {
navigateTo: vi.fn(),
navigateBack: vi.fn(),
reLaunch: vi.fn(),
showToast: vi.fn(),
hideKeyboard: vi.fn()
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('首页搜索与地图闭环', () => {
it('向搜索面板传入当前楼层,并将分类结果 ID 精确传给地图', async () => {
const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub)
expect(search.props('currentFloorId')).toBe('L1')
expect(search.props('currentFloorLabel')).toBe('1F')
search.vm.$emit('category-mode-change', true)
search.vm.$emit('results-change', {
...categoryContext,
active: true
})
await wrapper.vm.$nextTick()
expect(map.props('activeFloor')).toBe('L2')
expect(map.props('indoorView')).toBe('floor')
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
})
it('跨楼层结果使用既有 target-focus并携带返回上下文打开详情', async () => {
const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub)
search.vm.$emit('category-mode-change', true)
search.vm.$emit('results-change', {
...categoryContext,
active: true
})
search.vm.$emit('result-tap', poi, categoryContext)
await flushPromises()
expect(map.props('activeFloor')).toBe('L2')
expect(map.props('targetFocusRequest')).toMatchObject({
poiId: poi.id,
floorId: 'L2',
floorLabel: '2F',
positionGltf: poi.positionGltf
})
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(testState.searchMountCount).toBe(1)
expect(testState.searchUnmountCount).toBe(0)
})
it('页面重新显示时要求搜索面板恢复原列表位置', async () => {
await mountIndex()
await testState.onShowHandler?.()
await flushPromises()
expect(testState.searchRestoreCount).toBe(1)
})
it('地图点位卡隐藏首页 dock 时仍保留搜索组件,关闭后可继续使用原列表', async () => {
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', {
id: poi.id,
name: poi.name,
floorId: poi.floorId,
primaryCategory: 'restroom',
primaryCategoryZh: '卫生间',
iconType: 'restroom',
positionGltf: poi.positionGltf
})
await wrapper.vm.$nextTick()
expect(wrapper.get('[data-testid="poi-search"]').exists()).toBe(true)
expect(testState.searchUnmountCount).toBe(0)
map.vm.$emit('selection-clear')
await wrapper.vm.$nextTick()
expect(wrapper.getComponent(PoiSearchPanelStub).exists()).toBe(true)
expect(testState.searchMountCount).toBe(1)
})
it('单结果分类写入 resultCount=1主动取消恢复默认标记并清除目标', async () => {
const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub)
const singleContext = {
...categoryContext,
floorResultCount: 1,
visiblePoiIds: [poi.id]
}
search.vm.$emit('category-mode-change', true)
search.vm.$emit('results-change', { ...singleContext, active: true })
search.vm.$emit('result-tap', poi, singleContext)
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(map.props('targetFocusRequest')).not.toBeNull()
search.vm.$emit('cancel')
await wrapper.vm.$nextTick()
expect(map.props('visiblePoiIds')).toBeNull()
expect(map.props('targetFocusRequest')).toBeNull()
})
})

View File

@@ -0,0 +1,357 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue'
import type { MuseumFloor, MuseumPoi } from '@/domain/museum'
const guideMocks = vi.hoisted(() => ({
getFloors: vi.fn(),
getInitialSearchSpacePoints: vi.fn(),
searchPois: vi.fn()
}))
vi.mock('@/usecases/guideUseCase', () => ({
guideUseCase: guideMocks
}))
const floors: MuseumFloor[] = [
{ id: 'floor-1', label: '1F', order: 1 },
{ id: 'floor-2', label: '2F', order: 2 }
]
const createPoi = (
id: string,
name: string,
floorId: string,
floorLabel: string,
iconType: string,
positionGltf?: [number, number, number]
): MuseumPoi => ({
id,
name,
floorId,
floorLabel,
primaryCategory: {
id: iconType,
label: name.includes('卫生间') ? '卫生间' : name.includes('母婴') ? '母婴室' : '展厅',
iconType
},
categories: [],
positionGltf,
accessible: true
})
const poiPool: MuseumPoi[] = [
createPoi('hall-1', '地球厅', 'floor-1', '1F', 'exhibition_hall', [1, 0, 1]),
createPoi('restroom-1', '卫生间 A', 'floor-1', '1F', 'toilet', [2, 0, 2]),
createPoi('restroom-missing-position', '卫生间 B', 'floor-1', '1F', 'toilet'),
createPoi('restroom-2', '卫生间 C', 'floor-2', '2F', 'toilet', [3, 0, 3]),
createPoi('nursing-2', '母婴室', 'floor-2', '2F', 'mother_baby_room', [4, 0, 4])
]
const flushMountedSearch = async () => {
await flushPromises()
await flushPromises()
}
const createDeferred = <T>() => {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
beforeEach(() => {
guideMocks.getFloors.mockResolvedValue(floors)
guideMocks.getInitialSearchSpacePoints.mockResolvedValue(poiPool)
guideMocks.searchPois.mockImplementation(async (keyword = '') => (
keyword
? poiPool.filter((poi) => poi.name.includes(keyword))
: poiPool
))
vi.stubGlobal('uni', {
hideKeyboard: vi.fn(),
navigateTo: vi.fn(),
showToast: vi.fn()
})
})
afterEach(() => {
document.documentElement.className = ''
document.body.className = ''
vi.unstubAllGlobals()
})
describe('POI 搜索面板实际渲染与状态闭环', () => {
it('首页显示六类统一入口,搜索页显示完整十一类入口', async () => {
const homeWrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
const homeCategories = homeWrapper.findAll('.home-category-chip')
expect(homeCategories).toHaveLength(6)
expect(homeCategories.map((item) => item.text())).toEqual([
'展厅', '卫生间', '母婴室', '电梯', '楼梯', '扶梯'
])
homeCategories.forEach((item) => {
expect(item.find('.line-icon').exists()).toBe(true)
expect(item.find('.home-category-label').exists()).toBe(true)
})
homeWrapper.unmount()
const pageWrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
expect(pageWrapper.findAll('.category-item')).toHaveLength(11)
expect(pageWrapper.text()).toContain('影院')
expect(pageWrapper.text()).toContain('服务中心')
expect(pageWrapper.text()).toContain('文创')
expect(pageWrapper.text()).not.toContain('服务设施')
pageWrapper.unmount()
})
it('首页分类只展示当前楼层结果,并上报可定位点位的精确 marker ID', async () => {
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.get('[data-testid="poi-home-category-results"]').text()).toContain('当前楼层 1F · 2 个点位')
expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true)
const missingPositionRow = wrapper.get('[data-testid="poi-result-restroom-missing-position"]')
expect(missingPositionRow.classes()).toContain('disabled')
expect(missingPositionRow.text()).toContain('暂无地图坐标')
const resultStates = wrapper.emitted('results-change') || []
expect(resultStates.at(-1)?.[0]).toMatchObject({
active: true,
categoryId: 'restroom',
floorId: 'floor-1',
floorLabel: '1F',
resultCount: 2,
floorResultCount: 2,
visiblePoiIds: ['restroom-1']
})
await missingPositionRow.trigger('tap')
expect(wrapper.emitted('resultTap')).toBeUndefined()
expect(uni.showToast).toHaveBeenCalledWith(expect.objectContaining({
title: '该点位缺少地图坐标,暂无法定位'
}))
await wrapper.get('[data-testid="poi-result-restroom-1"]').trigger('tap')
expect(wrapper.emitted('resultTap')?.at(-1)?.[1]).toMatchObject({
categoryId: 'restroom',
resultCount: 2,
visiblePoiIds: ['restroom-1']
})
wrapper.unmount()
})
it('首页分类跟随当前地图楼层更新结果与 marker ID并对无结果给出反馈', async () => {
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
await wrapper.setProps({ currentFloorId: 'floor-2', currentFloorLabel: '2F' })
await flushMountedSearch()
expect(wrapper.get('[data-testid="poi-home-category-results"]').text()).toContain('当前楼层 2F · 1 个点位')
expect(wrapper.find('[data-testid="poi-result-restroom-2"]').exists()).toBe(true)
expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({
active: true,
floorId: 'floor-2',
visiblePoiIds: ['restroom-2']
})
await wrapper.get('[data-testid="poi-category-cancel"]').trigger('tap')
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-nursing-room"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-result-nursing-2"]').exists()).toBe(true)
await wrapper.setProps({ currentFloorId: 'floor-1', currentFloorLabel: '1F' })
await flushMountedSearch()
expect(wrapper.get('[data-testid="poi-empty"]').text()).toContain('当前楼层暂无匹配点位')
expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({
active: true,
visiblePoiIds: []
})
wrapper.unmount()
})
it('独立搜索页进入详情时传递关键词、分类、楼层、结果数和列表位置', async () => {
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
const floor2Tab = wrapper.findAll('.floor-tab').find((item) => item.text() === '2F')
expect(floor2Tab).toBeDefined()
await floor2Tab!.trigger('tap')
await flushMountedSearch()
wrapper.get('[data-testid="poi-result-list"]').element.scrollTop = 128
await wrapper.get('[data-testid="poi-result-restroom-2"]').trigger('tap')
expect(uni.navigateTo).toHaveBeenCalledTimes(1)
const url = vi.mocked(uni.navigateTo).mock.calls[0][0].url
const query = new URLSearchParams(url.split('?')[1])
expect(query.get('source')).toBe('search')
expect(query.get('searchOrigin')).toBe('page')
expect(query.get('searchKeyword')).toBe('卫生间')
expect(query.get('searchCategoryId')).toBe('restroom')
expect(query.get('searchFloorId')).toBe('floor-2')
expect(query.get('resultCount')).toBe('3')
expect(query.get('floorResultCount')).toBe('1')
expect(query.get('visiblePoiIds')).toBe('restroom-2')
expect(query.get('listScrollTop')).toBe('128')
wrapper.unmount()
})
it('页面重新显示时恢复原结果列表位置', async () => {
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
const resultList = wrapper.get('[data-testid="poi-result-list"]')
resultList.element.scrollTop = 128
await wrapper.get('[data-testid="poi-result-restroom-1"]').trigger('tap')
resultList.element.scrollTop = 0
await (wrapper.vm as unknown as {
restoreResultListScroll: () => Promise<void>
}).restoreResultListScroll()
expect(resultList.element.scrollTop).toBe(128)
wrapper.unmount()
})
it('部分数据不会固化,分类点击会重新读取并恢复结果', async () => {
guideMocks.getInitialSearchSpacePoints
.mockResolvedValueOnce([poiPool[0]])
.mockResolvedValueOnce(poiPool)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(guideMocks.getInitialSearchSpacePoints).toHaveBeenCalledTimes(2)
expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true)
wrapper.unmount()
})
it('旧的部分结果晚到时不会覆盖最新完整缓存', async () => {
const oldRequest = createDeferred<MuseumPoi[]>()
const latestRequest = createDeferred<MuseumPoi[]>()
guideMocks.getInitialSearchSpacePoints
.mockImplementationOnce(() => oldRequest.promise)
.mockImplementationOnce(() => latestRequest.promise)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
expect(guideMocks.getInitialSearchSpacePoints).toHaveBeenCalledTimes(1)
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushPromises()
expect(guideMocks.getInitialSearchSpacePoints).toHaveBeenCalledTimes(2)
latestRequest.resolve(poiPool)
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true)
oldRequest.resolve([poiPool[0]])
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-cancel"]').trigger('tap')
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true)
wrapper.unmount()
})
it('服务中心只展示真实服务台,不把普通服务空间误归类', async () => {
const serviceSpace = createPoi(
'space-service-lounge',
'贵宾休息室',
'floor-1',
'1F',
'service_space',
[5, 0, 5]
)
const serviceDesk = createPoi(
'poi-service-desk',
'咨询服务台',
'floor-1',
'1F',
'service_desk',
[6, 0, 6]
)
guideMocks.getInitialSearchSpacePoints.mockResolvedValue([serviceSpace, serviceDesk])
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-service-center"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-result-poi-service-desk"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="poi-result-space-service-lounge"]').exists()).toBe(false)
wrapper.unmount()
})
it('弱网失败显示可重试状态,恢复后重新渲染结果', async () => {
guideMocks.getInitialSearchSpacePoints
.mockRejectedValueOnce(new Error('network down'))
.mockResolvedValueOnce(poiPool)
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
expect(wrapper.get('[data-testid="poi-empty"]').text()).toContain('点位加载失败')
expect(wrapper.find('[data-testid="poi-retry"]').exists()).toBe(true)
await wrapper.get('[data-testid="poi-retry"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-retry"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="poi-result-hall-1"]').exists()).toBe(true)
wrapper.unmount()
})
})

View File

@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
const testState = vi.hoisted(() => ({
restoreCount: 0,
onShowHandler: null as null | (() => Promise<void> | void)
}))
vi.mock('@dcloudio/uni-app', () => ({
onLoad: vi.fn(),
onShow: (handler: () => Promise<void> | void) => {
testState.onShowHandler = handler
}
}))
vi.mock('@/utils/hostEnvironment', () => ({
isEmbeddedInWechatMiniProgram: () => false
}))
import SearchPage from '@/pages/search/index.vue'
const PoiSearchPanelStub = defineComponent({
name: 'PoiSearchPanel',
methods: {
restoreResultListScroll() {
testState.restoreCount += 1
}
},
template: '<div data-testid="poi-search-panel"></div>'
})
beforeEach(() => {
testState.restoreCount = 0
testState.onShowHandler = null
vi.stubGlobal('uni', {
reLaunch: vi.fn()
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('独立搜索页取消闭环', () => {
it('主动返回地图时直接回到馆内首页', async () => {
const wrapper = mount(SearchPage, {
global: {
stubs: {
PoiSearchPanel: PoiSearchPanelStub
}
}
})
await testState.onShowHandler?.()
await flushPromises()
expect(testState.restoreCount).toBe(1)
await wrapper.get('.map-link').trigger('tap')
expect(uni.reLaunch).toHaveBeenCalledWith({
url: '/pages/index/index?tab=guide'
})
})
})