@@ -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()
|
||||
}, {
|
||||
|
||||
@@ -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
@@ -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
288
src/domain/poiCategories.ts
Normal 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
29
src/domain/poiSearch.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
])
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ export class GuideUseCase {
|
||||
}
|
||||
|
||||
getInitialSearchSpacePoints() {
|
||||
return this.guide.listSpacePoints()
|
||||
return this.guide.searchPois()
|
||||
}
|
||||
|
||||
getPoiById(id: string) {
|
||||
|
||||
@@ -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
|
||||
|
||||
319
tests/unit/FacilityDetail.spec.ts
Normal file
319
tests/unit/FacilityDetail.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
74
tests/unit/GuideMapShell.spec.ts
Normal file
74
tests/unit/GuideMapShell.spec.ts
Normal 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]])
|
||||
})
|
||||
})
|
||||
166
tests/unit/GuideRepository.spec.ts
Normal file
166
tests/unit/GuideRepository.spec.ts
Normal 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('点位数据加载失败,请检查网络后重试')
|
||||
})
|
||||
})
|
||||
283
tests/unit/IndexPoiSearchFlow.spec.ts
Normal file
283
tests/unit/IndexPoiSearchFlow.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
357
tests/unit/PoiSearchPanel.spec.ts
Normal file
357
tests/unit/PoiSearchPanel.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
67
tests/unit/SearchPage.spec.ts
Normal file
67
tests/unit/SearchPage.spec.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user