整改点位搜索数据加载与分类逻辑

This commit is contained in:
cxk
2026-07-20 21:30:55 +08:00
parent 65ba9720f8
commit 7ae539accc
14 changed files with 2352 additions and 812 deletions

View File

@@ -44,13 +44,18 @@
</view>
</view>
<view v-if="variant === 'home' && !showSearchContent && !isHomeCategoryMode" class="home-category-strip">
<view
v-if="variant === 'home' && !showSearchContent && !isHomeCategoryMode"
class="home-category-strip"
aria-label="快捷查找"
>
<view
v-for="item in homeCategories"
:key="item.id"
class="home-category-chip"
:class="{ active: activeCategoryId === item.id }"
:class="{ active: activeCategoryId === item.id, disabled: isCategoryDisabled(item) }"
:data-testid="`poi-category-${item.id}`"
:aria-disabled="isCategoryDisabled(item)"
@tap="handleFacilityShortcut(item)"
>
<view class="home-category-icon">
@@ -129,14 +134,15 @@
<view class="home-collapse-bar"></view>
</view>
<view class="category-scroll">
<view class="category-scroll" aria-label="快捷查找">
<view class="category-grid" :class="{ 'home-shortcut-grid': variant === 'home' }">
<view
v-for="item in searchCategoryColumns"
:key="item.id"
class="category-item"
:class="{ active: activeCategoryId === item.id }"
:class="{ active: activeCategoryId === item.id, disabled: isCategoryDisabled(item) }"
:data-testid="`poi-category-${item.id}`"
:aria-disabled="isCategoryDisabled(item)"
@tap="handleFacilityShortcut(item)"
>
<view class="category-icon-shell">
@@ -220,8 +226,7 @@ import type {
} from '@/domain/museum'
import {
compareFloorsTopToBottom,
isIndoorNavigableFloor,
isPoiOnIndoorNavigableFloor
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
guideUseCase
@@ -232,18 +237,14 @@ import {
import {
HOME_POI_CATEGORIES,
POI_CATEGORIES,
findPoiCategoryByKeyword,
getPoiCategoryIconHref,
getPoiCategoryById,
getPoiDataIssues,
inspectPoiCollection,
isPoiSearchCategorySupported,
matchesPoiCategory,
resolvePoiCategory,
type PoiCategoryDefinition,
type PoiCategoryId
} from '@/domain/poiCategories'
import type {
GuidePoiSearchViewState,
PoiCategoryResultState,
PoiSearchContext
} from '@/domain/poiSearch'
@@ -273,12 +274,6 @@ const emit = defineEmits<{
cancel: []
}>()
type PreparedPoiResults = {
visiblePois: MuseumPoi[]
duplicateCount: number
excludedCount: number
}
const homeCategories = HOME_POI_CATEGORIES
const searchCategories = POI_CATEGORIES
const searchCategoryColumns = computed(() => (
@@ -299,6 +294,7 @@ const homeContentTapBlockedUntil = ref(0)
const floors = ref<MuseumFloor[]>([])
const activeFloor = ref('')
const pois = ref<MuseumPoi[]>([])
const searchViewState = ref<GuidePoiSearchViewState | null>(null)
const isLoading = ref(false)
const searchError = ref('')
const floorLoadError = ref('')
@@ -306,7 +302,6 @@ const excludedPoiCount = ref(0)
const duplicatePoiCount = ref(0)
const resultListScrollTop = ref(0)
const resultListRef = ref<HTMLElement | { $el?: HTMLElement } | null>(null)
let cachedPoiPool: PreparedPoiResults | null = null
let searchRequestSeq = 0
const collapseDragThreshold = 48
@@ -324,6 +319,9 @@ const isHomeCategoryMode = computed(() => (
const isHomeCollapsed = computed(() => props.variant === 'home' && !showSearchContent.value)
const activeCategory = computed(() => getPoiCategoryById(activeCategoryId.value))
const loadError = computed(() => searchError.value || floorLoadError.value)
const categoryStatesById = computed(() => new Map(
(searchViewState.value?.categories || []).map((item) => [item.definition.id, item] as const)
))
const displayFloors = computed(() => [...floors.value]
.filter(isIndoorNavigableFloor)
@@ -349,24 +347,13 @@ const emptyDesc = computed(() => {
: '可切换楼层或更换关键词'
})
const isVisiblePoi = (poi: MuseumPoi) => isPoiSearchCategorySupported(poi)
&& isPoiOnIndoorNavigableFloor(poi)
const isPoiLocatable = (poi: MuseumPoi) => !getPoiDataIssues(poi).some((issue) => (
issue.code === 'missing-id'
|| issue.code === 'missing-floor'
|| issue.code === 'missing-position'
|| issue.code === 'invalid-position'
))
const unavailablePoiCount = computed(() => pois.value.filter((poi) => !isPoiLocatable(poi)).length)
const isPoiLocatable = (poi: MuseumPoi) => Boolean(poi.positionGltf)
// 数据质量统计保留给诊断日志,普通游客只看到可恢复的加载状态。
const dataWarning = computed(() => {
const diagnostic = {
excluded: excludedPoiCount.value,
duplicates: duplicatePoiCount.value,
unavailable: unavailablePoiCount.value,
floorLoadError: floorLoadError.value
}
if (import.meta.env.DEV && Object.values(diagnostic).some(Boolean)) {
@@ -375,26 +362,11 @@ const dataWarning = computed(() => {
return ''
})
const dedupePois = (items: MuseumPoi[]) => {
const seenIds = new Set<string>()
return items.filter((poi) => {
if (!poi.id?.trim() || seenIds.has(poi.id)) return false
seenIds.add(poi.id)
return true
})
}
const preparePoiResults = (items: MuseumPoi[]): PreparedPoiResults => {
const inspection = inspectPoiCollection(items)
const missingIdCount = items.filter((poi) => !poi.id?.trim()).length
const uniquePois = dedupePois(items)
const visiblePois = uniquePois.filter(isVisiblePoi)
return {
visiblePois,
duplicateCount: inspection.duplicateIds.length,
excludedCount: missingIdCount + uniquePois.length - visiblePois.length
}
const isCategoryDisabled = (category: PoiCategoryDefinition) => {
const state = categoryStatesById.value.get(category.id)
return isLoading.value
|| !state
|| state.disabled
}
const activeFloorOption = computed(() => (
@@ -402,19 +374,15 @@ const activeFloorOption = computed(() => (
))
const createSearchContext = (): PoiSearchContext => {
const visiblePoiIds = floorResults.value
.filter(isPoiLocatable)
.map((poi) => poi.id)
const resultCount = isHomeCategoryMode.value
? floorResults.value.length
: pois.value.length
const visiblePoiIds = searchViewState.value?.visiblePoiIds || []
const resultCount = pois.value.length
return {
origin: props.variant,
keyword: searchKeyword.value,
categoryId: activeCategoryId.value,
floorId: activeFloorOption.value?.id || props.currentFloorId || '',
floorLabel: activeFloor.value || props.currentFloorLabel || '',
floorId: searchViewState.value?.floorId || activeFloorOption.value?.id || props.currentFloorId || '',
floorLabel: searchViewState.value?.floorLabel || activeFloor.value || props.currentFloorLabel || '',
resultCount,
floorResultCount: floorResults.value.length,
visiblePoiIds,
@@ -424,7 +392,8 @@ const createSearchContext = (): PoiSearchContext => {
const emitResultsState = () => {
const context = createSearchContext()
const active = isHomeCategoryMode.value
const active = props.variant === 'home'
&& (searchViewState.value?.mode === 'category' || searchViewState.value?.mode === 'keyword')
emit('results-change', {
...context,
visiblePoiIds: active ? context.visiblePoiIds : [],
@@ -432,6 +401,20 @@ const emitResultsState = () => {
})
}
const emitPendingHomeSearchResults = (floor?: Pick<MuseumFloor, 'id' | 'label'>) => {
if (props.variant !== 'home') return
const context = createSearchContext()
emit('results-change', {
...context,
floorId: floor?.id || context.floorId,
floorLabel: floor?.label || context.floorLabel,
visiblePoiIds: [],
active: true,
pending: true
})
}
const setHomeCategoryMode = (active: boolean) => {
const nextValue = props.variant === 'home' && active
if (homeCategoryMode.value === nextValue) return
@@ -459,29 +442,6 @@ const syncActiveFloor = () => {
|| displayFloors.value[0].label
}
const syncActiveFloorToResults = (items: MuseumPoi[]) => {
if (!displayFloors.value.length) {
activeFloor.value = ''
return
}
if (props.variant === 'home' && (props.currentFloorId || props.currentFloorLabel)) {
syncActiveFloor()
return
}
const resultFloorLabels = new Set(items.map((poi) => poi.floorLabel).filter(Boolean))
if (activeFloor.value && resultFloorLabels.has(activeFloor.value)) return
const firstFloorWithResult = displayFloors.value.find((floor) => resultFloorLabels.has(floor.label))
if (firstFloorWithResult) {
activeFloor.value = firstFloorWithResult.label
return
}
syncActiveFloor()
}
const loadFloors = async () => {
floorLoadError.value = ''
try {
@@ -494,26 +454,66 @@ const loadFloors = async () => {
}
}
const loadPoiPool = async () => {
const result = await guideUseCase.getInitialSearchSpacePoints()
return preparePoiResults(result)
const activeFloorId = () => (
activeFloorOption.value?.id
|| searchViewState.value?.floorId
|| props.currentFloorId
|| ''
)
const commitPoiSearchViewState = async (
state: GuidePoiSearchViewState,
requestSeq: number
) => {
if (requestSeq !== searchRequestSeq) return
searchViewState.value = state
pois.value = state.results
activeFloor.value = state.floorLabel
duplicatePoiCount.value = 0
excludedPoiCount.value = 0
await nextTick()
if (requestSeq !== searchRequestSeq) return
emitResultsState()
}
const setPreparedResults = async (prepared: PreparedPoiResults) => {
pois.value = prepared.visiblePois
duplicatePoiCount.value = prepared.duplicateCount
excludedPoiCount.value = prepared.excludedCount
syncActiveFloorToResults(prepared.visiblePois)
await nextTick()
emitResultsState()
const runPoiSearchRequest = async (
loader: () => Promise<GuidePoiSearchViewState>
) => {
const requestSeq = ++searchRequestSeq
isLoading.value = true
searchError.value = ''
try {
const state = await loader()
await commitPoiSearchViewState(state, requestSeq)
return state
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位搜索结果失败:')
return null
} finally {
if (requestSeq === searchRequestSeq) isLoading.value = false
}
}
const handleLoadFailure = async (requestSeq: number, error: unknown, message: string) => {
if (requestSeq !== searchRequestSeq) return
console.error(message, error)
pois.value = []
if (searchViewState.value) {
searchViewState.value = {
...searchViewState.value,
results: [],
visiblePoiIds: []
}
}
searchError.value = '点位加载失败'
await nextTick()
if (requestSeq !== searchRequestSeq) return
if (props.variant === 'home' && (homeCategoryMode.value || Boolean(searchKeyword.value))) {
emitPendingHomeSearchResults()
return
}
emitResultsState()
}
@@ -523,10 +523,9 @@ const loadInitialSpacePoints = async () => {
searchError.value = ''
try {
const prepared = await loadPoiPool()
const state = await guideUseCase.createInitialPoiSearchState(activeFloorId())
if (requestSeq !== searchRequestSeq) return
cachedPoiPool = prepared
await setPreparedResults(prepared)
await commitPoiSearchViewState(state, requestSeq)
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位基础数据失败:')
} finally {
@@ -543,14 +542,19 @@ const loadPois = async (keyword = '') => {
const requestSeq = ++searchRequestSeq
isLoading.value = true
searchError.value = ''
if (props.variant === 'home') {
// Keep a pending query from briefly showing stale list or marker IDs.
pois.value = []
emitPendingHomeSearchResults()
}
try {
const [result, pool] = await Promise.all([
guideUseCase.searchPois(keyword),
loadPoiPool()
])
const state = await guideUseCase.searchPoiKeyword(
searchViewState.value,
keyword,
activeFloorId()
)
if (requestSeq !== searchRequestSeq) return
cachedPoiPool = pool
await setPreparedResults(preparePoiResults(result))
await commitPoiSearchViewState(state, requestSeq)
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位搜索结果失败:')
} finally {
@@ -558,6 +562,24 @@ const loadPois = async (keyword = '') => {
}
}
const refreshCurrentPoiSearchFloor = async (floorLabel: string) => {
const floor = displayFloors.value.find((item) => item.label === floorLabel)
if (!floor) return
activeFloor.value = floor.label
resultListScrollTop.value = 0
const hasExplicitQuery = searchViewState.value?.mode === 'category'
|| searchViewState.value?.mode === 'keyword'
if (props.variant === 'home' && hasExplicitQuery) {
emitPendingHomeSearchResults(floor)
}
await runPoiSearchRequest(() => guideUseCase.changePoiSearchFloor(
searchViewState.value,
floor.id
))
}
const focusSearchInput = async () => {
searchInputFocused.value = false
await nextTick()
@@ -571,7 +593,6 @@ const expandHomePanel = () => {
}
setHomeCategoryMode(false)
homeExpanded.value = true
emitResultsState()
}
const shouldBlockHomeContentTap = () => (
@@ -619,7 +640,6 @@ const collapseHomePanel = () => {
}
const resetSearchState = async () => {
searchRequestSeq += 1
searchKeyword.value = ''
searchDraftKeyword.value = ''
activeCategoryId.value = ''
@@ -628,18 +648,14 @@ const resetSearchState = async () => {
setHomeCategoryMode(false)
homeExpanded.value = false
searchInputFocused.value = false
if (cachedPoiPool) {
pois.value = cachedPoiPool.visiblePois
duplicatePoiCount.value = cachedPoiPool.duplicateCount
excludedPoiCount.value = cachedPoiPool.excludedCount
} else {
if (searchViewState.value?.mode !== 'default') {
pois.value = []
duplicatePoiCount.value = 0
excludedPoiCount.value = 0
emitPendingHomeSearchResults()
}
syncActiveFloor()
await nextTick()
emitResultsState()
await runPoiSearchRequest(() => guideUseCase.clearPoiSearch(
searchViewState.value,
activeFloorId()
))
}
// 从详情页返回时只清理搜索 UI 与分类 marker不参与地图目标焦点的复位。
@@ -739,7 +755,9 @@ const enterFullSearch = () => {
activeCategoryId.value = ''
searchKeyword.value = ''
searchDraftKeyword.value = ''
if (cachedPoiPool) void setPreparedResults(cachedPoiPool)
pois.value = []
emitPendingHomeSearchResults()
void loadInitialSpacePoints()
}
}
@@ -761,7 +779,14 @@ const handleSearchClear = async () => {
searchKeyword.value = ''
activeCategoryId.value = ''
setHomeCategoryMode(false)
await loadInitialSpacePoints()
if (searchViewState.value?.mode !== 'default') {
pois.value = []
emitPendingHomeSearchResults()
}
await runPoiSearchRequest(() => guideUseCase.clearPoiSearch(
searchViewState.value,
activeFloorId()
))
await focusSearchInput()
}
@@ -776,7 +801,7 @@ const searchShortcut = async (
const category = typeof categoryInput === 'string'
? getPoiCategoryById(categoryInput)
: categoryInput
if (!category) return
if (!category || isCategoryDisabled(category)) return
const requestSeq = ++searchRequestSeq
const useHomeResultList = props.variant === 'home'
@@ -793,17 +818,21 @@ const searchShortcut = async (
searchError.value = ''
setHomeCategoryMode(useHomeResultList)
isLoading.value = true
if (useHomeResultList) {
// The category result replaces the current home list, so clear both
// surfaces until its canonical IDs have resolved.
pois.value = []
emitPendingHomeSearchResults()
}
try {
const pool = await loadPoiPool()
const state = await guideUseCase.selectPoiSearchCategory(
searchViewState.value,
category.id,
activeFloorId()
)
if (requestSeq !== searchRequestSeq) return
cachedPoiPool = pool
const prepared: PreparedPoiResults = {
visiblePois: pool.visiblePois.filter((poi) => matchesPoiCategory(poi, category)),
duplicateCount: pool.duplicateCount,
excludedCount: pool.excludedCount
}
await setPreparedResults(prepared)
await commitPoiSearchViewState(state, requestSeq)
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位分类搜索结果失败:')
} finally {
@@ -827,12 +856,6 @@ const handleSearchConfirm = async (event?: any) => {
setHomeCategoryMode(false)
if (props.variant === 'home' && !homeExpanded.value) expandHomePanel()
const matchedCategory = findPoiCategoryByKeyword(searchKeyword.value)
if (matchedCategory) {
await searchShortcut(matchedCategory)
return
}
activeCategoryId.value = ''
resultListScrollTop.value = 0
await loadPois(searchKeyword.value)
@@ -840,8 +863,7 @@ const handleSearchConfirm = async (event?: any) => {
const handleFloorChange = (floor: string) => {
if (activeFloor.value === floor) return
activeFloor.value = floor
resultListScrollTop.value = 0
void refreshCurrentPoiSearchFloor(floor)
}
const handleResultListScroll = (event: any) => {
@@ -946,11 +968,6 @@ const applyInitialKeyword = async (keyword: string) => {
if (props.variant === 'home' && normalizedKeyword) expandHomePanel()
if (props.variant === 'home' && !normalizedKeyword) homeExpanded.value = false
const matchedCategory = findPoiCategoryByKeyword(normalizedKeyword)
if (matchedCategory) {
await searchShortcut(matchedCategory)
return
}
activeCategoryId.value = ''
if (normalizedKeyword) {
await loadPois(normalizedKeyword)
@@ -963,16 +980,14 @@ watch(() => props.initialKeyword, (keyword) => {
void applyInitialKeyword(keyword || '')
})
watch([() => props.currentFloorId, () => props.currentFloorLabel], async () => {
watch([() => props.currentFloorId, () => props.currentFloorLabel], () => {
if (props.variant !== 'home') return
syncActiveFloor()
await nextTick()
emitResultsState()
})
watch(activeFloor, async () => {
await nextTick()
emitResultsState()
const requestedFloor = displayFloors.value.find((floor) => (
floor.id === props.currentFloorId
|| floor.label === props.currentFloorLabel
))
if (!requestedFloor || requestedFloor.id === searchViewState.value?.floorId) return
void refreshCurrentPoiSearchFloor(requestedFloor.label)
})
watch(showSearchContent, (expanded) => {

View File

@@ -9,6 +9,9 @@ import type {
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
normalizePoiSemanticValue
} from '@/domain/poiCategories'
const floorIdPattern = /^L(-?\d+(?:\.\d+)?)$/
@@ -43,33 +46,176 @@ export const isStaticIndoorNavigableFloorId = (floorId: string) => (
})
)
const toCategory = (category: StaticNavPoiCategoryPayload): MuseumCategory => ({
id: category.topCategory,
label: category.topCategoryZh,
iconType: category.iconType
})
const normalizedText = (value?: string | null) => (value || '').trim().toLowerCase()
const staticDestinationCategoryBySemanticType: Readonly<Record<string, string>> = {
exhibition_hall: 'exhibition_hall',
theater: 'space_theater',
restaurant: 'space_restaurant',
cafe: 'space_restaurant',
shop: 'space_shop',
cultural_shop: 'space_shop',
bookstore: 'space_shop',
vending: 'space_shop'
}
const staticFallbackDestinationTypes = new Set(
Object.keys(staticDestinationCategoryBySemanticType)
)
const staticFacilityCategoryBySemanticType: Readonly<Record<string, string>> = {
ticket_office: 'basic_service_facility',
service_desk: 'basic_service_facility',
locker: 'basic_service_facility',
rental_service: 'basic_service_facility',
toilet: 'basic_service_facility',
accessible_toilet: 'accessibility_special_service',
mother_baby_room: 'basic_service_facility',
elevator: 'transport_circulation',
escalator: 'transport_circulation',
stairs: 'transport_circulation'
}
const staticCategoryLabelBySemanticType: Readonly<Record<string, string>> = {
exhibition_hall: '展厅',
theater: '影院',
restaurant: '餐饮',
cafe: '餐饮',
shop: '购物',
cultural_shop: '购物',
bookstore: '购物',
vending: '购物',
ticket_office: '售票处',
service_desk: '服务',
locker: '服务',
rental_service: '服务',
toilet: '洗手间',
accessible_toilet: '洗手间',
mother_baby_room: '母婴',
elevator: '电梯',
escalator: '扶梯',
stairs: '楼梯'
}
const staticNameSemanticType = (name: string) => {
if (/(?:售票|票务|ticket)/.test(name)) return 'ticket_office'
if (/(?:服务台|咨询台|接待|服务中心|service[_ ]?(?:desk|center)|information)/.test(name)) return 'service_desk'
if (/(?:存包|寄存|储物|locker)/.test(name)) return 'locker'
if (/(?:租赁|租借|rental)/.test(name)) return 'rental_service'
if (/(?:茶水间|饮水间|water)/.test(name)) return 'service_space'
if (/(?:影院|影厅|剧场|cinema|theater)/.test(name)) return 'theater'
if (/(?:餐饮|餐厅|餐馆|咖啡|restaurant|dining|food|cafe)/.test(name)) return 'restaurant'
if (/(?:购物|商店|商铺|文创|零售|shop|store|retail)/.test(name)) return 'shop'
return ''
}
/**
* The static navigation package has legacy facility labels where stairways,
* escalators, and nursing rooms inherit a generic elevator/restroom icon.
* Keep the provider source-faithful and repair that visitor-facing semantic at
* the adapter boundary.
*/
export const normalizeStaticPoiIconType = (
poi: Pick<StaticNavPoiPayload, 'name'>,
rawIconType?: string | null
) => {
const name = normalizedText(poi.name)
const iconType = normalizedText(rawIconType)
if (name.includes('扶梯')) return 'escalator'
if (name.includes('楼梯')) return 'stairs'
if (name.includes('母婴')) return 'mother_baby_room'
if (/(?:无障碍|残疾人|残障).*(?:卫生间|洗手间|厕所)/.test(name)) {
return 'accessible_toilet'
}
if (/(?:卫生间|洗手间|厕所)/.test(name)) return 'toilet'
return iconType
}
const getStaticPoiSemanticType = (
poi: StaticNavPoiPayload,
rawIconType?: string | null
) => (
staticNameSemanticType(normalizedText(poi.name))
|| normalizePoiSemanticValue(normalizeStaticPoiIconType(poi, rawIconType))
)
const getStaticPoiCategoryId = (
rawCategoryId: string,
semanticType: string
) => {
const normalizedCategoryId = normalizePoiSemanticValue(rawCategoryId)
const isTouringFallback = normalizedCategoryId === 'touring_poi'
|| staticFallbackDestinationTypes.has(normalizedCategoryId)
if (isTouringFallback) {
return staticDestinationCategoryBySemanticType[semanticType]
|| staticFacilityCategoryBySemanticType[semanticType]
|| rawCategoryId
}
return rawCategoryId
}
const getStaticPoiCategoryLabel = (
rawCategoryId: string,
rawCategoryLabel: string,
semanticType: string
) => {
const normalizedCategoryId = normalizePoiSemanticValue(rawCategoryId)
const isGenericTouringCategory = normalizedCategoryId === 'touring_poi'
return isGenericTouringCategory
? staticCategoryLabelBySemanticType[semanticType] || rawCategoryLabel
: rawCategoryLabel
}
const getStaticPoiKind = (categoryId: string): MuseumPoi['kind'] => {
if (categoryId === 'exhibition_hall') return 'hall'
if (categoryId.startsWith('space_')) return 'space'
return 'facility'
}
const toCategory = (
poi: StaticNavPoiPayload,
category: StaticNavPoiCategoryPayload
): MuseumCategory => {
const semanticType = getStaticPoiSemanticType(poi, category.iconType)
return {
id: getStaticPoiCategoryId(category.topCategory, semanticType),
label: getStaticPoiCategoryLabel(category.topCategory, category.topCategoryZh, semanticType),
iconType: semanticType
}
}
const isPoiAccessible = (poi: StaticNavPoiPayload) => (
poi.primaryCategory === 'accessibility_special_service'
|| poi.categories?.some((category) => category.topCategory === 'accessibility_special_service') === true
)
export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => ({
export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
const categoryFallbackIconType = poi.categories?.[0]?.iconType
const iconType = getStaticPoiSemanticType(poi, poi.iconType || categoryFallbackIconType)
const primaryCategoryId = getStaticPoiCategoryId(poi.primaryCategory, iconType)
const kind = getStaticPoiKind(primaryCategoryId)
return {
id: poi.id,
name: poi.name,
floorId: poi.floorId,
floorLabel: formatNavFloorLabel(poi.floorId),
primaryCategory: {
id: poi.primaryCategory,
label: poi.primaryCategoryZh,
iconType: poi.iconType
id: primaryCategoryId,
label: getStaticPoiCategoryLabel(poi.primaryCategory, poi.primaryCategoryZh, iconType),
iconType
},
categories: (poi.categories || []).map(toCategory),
categories: (poi.categories || []).map((category) => toCategory(poi, category)),
positionGltf: poi.positionGltf,
sourceObjectName: poi.sourceObjectName,
sourceConfidence: poi.sourceConfidence,
navigationReadiness: poi.navigationReadiness,
accessible: isPoiAccessible(poi),
kind: poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility',
hallName: poi.primaryCategory === 'touring_poi' ? poi.name : undefined
})
kind,
hallName: kind === 'hall' ? poi.name : undefined
}
}

View File

@@ -19,7 +19,8 @@ import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
isPoiSearchCategorySupported
isPoiSearchCategorySupported,
normalizePoiSemanticValue
} from '@/domain/poiCategories'
import type {
SgsFloorDiagnosticsPayload,
@@ -91,9 +92,39 @@ const spaceCategoryBySgsType: Record<string, MuseumCategory> = {
iconType: 'service_space'
},
commercial: {
id: 'space_commercial',
label: '商业空间',
iconType: 'commercial'
id: 'space_shop',
label: '购物空间',
iconType: 'shop'
},
restaurant: {
id: 'space_restaurant',
label: '餐饮空间',
iconType: 'restaurant'
},
cafe: {
id: 'space_restaurant',
label: '餐饮空间',
iconType: 'cafe'
},
shop: {
id: 'space_shop',
label: '购物空间',
iconType: 'shop'
},
cultural_shop: {
id: 'space_shop',
label: '购物空间',
iconType: 'cultural_shop'
},
bookstore: {
id: 'space_shop',
label: '购物空间',
iconType: 'bookstore'
},
vending: {
id: 'space_shop',
label: '购物空间',
iconType: 'vending'
},
parking: {
id: 'space_parking',
@@ -113,6 +144,15 @@ const spaceCategoryBySgsType: Record<string, MuseumCategory> = {
}
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
exhibition_hall: hallCategory,
theater: spaceCategoryBySgsType.theater,
commercial: spaceCategoryBySgsType.commercial,
restaurant: spaceCategoryBySgsType.restaurant,
cafe: spaceCategoryBySgsType.cafe,
shop: spaceCategoryBySgsType.shop,
cultural_shop: spaceCategoryBySgsType.cultural_shop,
bookstore: spaceCategoryBySgsType.bookstore,
vending: spaceCategoryBySgsType.vending,
poi: {
id: 'poi',
label: '点位',
@@ -126,7 +166,7 @@ const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean
operation_experience: {
id: 'operation_experience',
label: '导览点位',
iconType: 'poi'
iconType: 'guide'
},
toilet: {
id: 'basic_service_facility',
@@ -156,10 +196,26 @@ const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean
iconType: 'escalator'
},
entrance_exit: {
id: 'transport_circulation',
id: 'navigation_anchor',
label: '出入口',
iconType: 'entrance_exit'
},
hall_entrance: hallEntranceCategory,
entrance_anchor: {
id: 'navigation_anchor',
label: '入口锚点',
iconType: 'entrance_anchor'
},
route_node: {
id: 'navigation_anchor',
label: '路线节点',
iconType: 'route_node'
},
navigable_place: {
id: 'navigation_anchor',
label: '可导航点',
iconType: 'navigable_place'
},
service_desk: {
id: 'basic_service_facility',
label: '服务台',
@@ -205,6 +261,8 @@ const optionalNumber = (value: number | null | undefined) => (
const normalizedText = (value?: string | null) => (value || '').trim()
export const normalizeSgsPoiSemanticType = (value?: string | null) => normalizePoiSemanticValue(value)
const normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => {
const x = optionalNumber(source.x)
const y = optionalNumber(source.y)
@@ -302,6 +360,8 @@ const nonHallNavigablePlaceTypes = new Set([
'toilet',
'accessible_toilet',
'restroom',
'route_node',
'navigable_place',
'卫生间',
'洗手间',
'无障碍卫生间',
@@ -322,15 +382,38 @@ export interface SgsHallPoiDiagnostics {
}
const businessTypeLabels: Record<string, string> = {
shop: '商店',
shop: '购物',
commercial: '购物',
restaurant: '餐饮',
cafe: '咖啡',
vending: '售卖机',
bookstore: '书店',
cultural_shop: '文创商店',
cafe: '餐饮',
vending: '购物',
bookstore: '购物',
cultural_shop: '购物',
photo_spot: '打卡点'
}
const businessTypeIconTypes: Record<string, string> = {
shop: 'shop',
commercial: 'shop',
restaurant: 'restaurant',
cafe: 'cafe',
vending: 'vending',
bookstore: 'bookstore',
cultural_shop: 'cultural_shop',
photo_spot: 'photo_spot'
}
const categoryForBusinessType = (businessType?: string | null): MuseumCategory => {
const normalizedBusinessType = normalizeSgsPoiSemanticType(businessType)
if (!normalizedBusinessType) return businessCategory
return {
...businessCategory,
label: businessTypeLabels[normalizedBusinessType] || businessCategory.label,
iconType: businessTypeIconTypes[normalizedBusinessType] || normalizedBusinessType
}
}
interface SgsHallPoiBuildOptions {
fallbackY?: number
}
@@ -339,11 +422,29 @@ const sgsSpaceCenterConfidence = 'backend-sgs-sdk-space-center'
const sgsSpaceBoundaryCenterConfidence = 'backend-sgs-sdk-space-boundary-center'
const sgsHallEntranceConfidence = 'backend-sgs-sdk-hall-entrance'
const normalizeTypeId = (value?: string | null) => normalizedText(value)
.toLowerCase()
const normalizeTypeId = (value?: string | null) => normalizeSgsPoiSemanticType(value)
.replace(/[^a-z0-9_-]+/g, '_')
.replace(/^_+|_+$/g, '')
const hiddenSgsPoiTypes = new Set([
'entrance_exit',
'hall_entrance',
'entrance_anchor',
'route_node',
'navigable_place',
'operation_experience'
])
/** Raw navigable-place records may enrich a hall, but are never visitor results themselves. */
export const isSgsNavigablePlaceVisitorResult = (_place: SgsNavigablePlacePayload) => false
export const isSgsPoiHiddenFromVisitorSearch = (poi: Pick<
SgsPoiPayload,
'type' | 'typeName'
>) => [poi.type, poi.typeName]
.map(normalizeSgsPoiSemanticType)
.some((type) => hiddenSgsPoiTypes.has(type))
const hasExhibitionKeyword = (value?: string | null) => (
exhibitionHallKeywords.some((keyword) => normalizedText(value).includes(keyword))
)
@@ -412,7 +513,7 @@ const normalizeSpacePosition = (
}
export const isExhibitionHallSpace = (space: SgsSpacePayload) => {
const type = normalizedText(space.type).toLowerCase()
const type = normalizeSgsPoiSemanticType(space.type)
const name = normalizedText(space.name)
const searchableText = searchableSpaceText(space)
@@ -430,7 +531,7 @@ export const isExhibitionHallNavigablePlace = (place: SgsNavigablePlacePayload)
place.typeCode,
place.typeName
]
.map((value) => normalizedText(value).toLowerCase())
.map((value) => normalizeSgsPoiSemanticType(value))
.filter(Boolean)
if (placeTypes.some((type) => nonHallNavigablePlaceTypes.has(type))) return false
@@ -480,23 +581,26 @@ const normalizePlacePosition = (
)
const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => {
const normalizedType = poi.type?.trim().toLowerCase()
const normalizedBusinessType = poi.businessType?.trim().toLowerCase()
const normalizedType = normalizeSgsPoiSemanticType(poi.type)
const normalizedTypeName = normalizeSgsPoiSemanticType(poi.typeName)
const resolvedType = categoryBySgsType[normalizedType]
? normalizedType
: normalizedTypeName || normalizedType
const normalizedBusinessType = normalizeSgsPoiSemanticType(poi.businessType)
if (String(poi.poiGroup || '').toUpperCase() === 'BUSINESS') {
return {
...businessCategory,
label: normalizedBusinessType ? businessTypeLabels[normalizedBusinessType] || poi.typeName || businessCategory.label : poi.typeName || businessCategory.label,
iconType: normalizedBusinessType || businessCategory.iconType
}
const category = categoryForBusinessType(normalizedBusinessType || resolvedType)
return category.iconType === businessCategory.iconType && poi.typeName
? { ...category, label: poi.typeName }
: category
}
if (String(poi.poiGroup || '').toUpperCase() === 'OTHER') {
return categoryBySgsType[normalizedType || ''] || defaultCategory
return categoryBySgsType[resolvedType] || defaultCategory
}
if (normalizedType && categoryBySgsType[normalizedType]) {
return categoryBySgsType[normalizedType]
if (resolvedType && categoryBySgsType[resolvedType]) {
return categoryBySgsType[resolvedType]
}
if (poi.typeName) {
@@ -511,9 +615,13 @@ const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolea
}
const kindForSgsPoi = (poi: SgsPoiPayload, category: MuseumCategory) => {
if (category.id === 'business_poi') return 'facility'
if (category.id === 'operation_experience') return 'guide'
if (isSgsPoiHiddenFromVisitorSearch(poi)) return 'guide'
if (category.id === 'operation_experience' || category.id === 'navigation_anchor') return 'guide'
if (category.id === hallCategory.id) return 'hall'
// Raw POI endpoints can use a space-like semantic type (restaurant, shop,
// theater) without being a source space. Space records are adapted through
// toMuseumSpacePointFromSgs; raw records remain facility POIs.
return 'facility'
}
@@ -527,10 +635,14 @@ export const toMuseumPoiFromSgs = (
|| sourceFloorId
|| stringifyId(poi.floorCode)
const category = categoryFor(poi)
const kind = kindForSgsPoi(poi, category)
const spatialAreaId = stringifyId(poi.spatialAreaId)
const spatialAreaName = normalizedText(poi.spatialAreaName)
const name = poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}`
return {
id: stringifyId(poi.id),
name: poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}`,
name,
floorId,
floorLabel: formatSgsFloorLabel(matchedFloor?.floorCode || poi.floorCode, matchedFloor?.floorName),
primaryCategory: {
@@ -550,7 +662,11 @@ export const toMuseumPoiFromSgs = (
sourceConfidence: 'backend-sgs-sdk',
navigationReadiness: '位置预览',
accessible: category.accessible === true,
kind: kindForSgsPoi(poi, category)
kind,
hallId: kind === 'hall' ? spatialAreaId || stringifyId(poi.id) : undefined,
hallName: spatialAreaName || (kind === 'hall' ? name : undefined),
spaceId: spatialAreaId || undefined,
sourceSpaceId: spatialAreaId || undefined
}
}

View File

@@ -4,17 +4,15 @@ import type {
} from '@/domain/museum'
export type PoiCategoryId =
| 'itinerary'
| 'exhibition-hall'
| 'cinema'
| 'ticket-office'
| 'service-center'
| 'dining'
| 'souvenir'
| 'nursing-room'
| 'shopping'
| 'service-center'
| 'restroom'
| 'nursing-room'
| 'elevator'
| 'stairs'
| 'escalator'
export interface PoiCategoryDefinition {
@@ -31,33 +29,22 @@ export interface PoiCategoryDefinition {
const definitions: PoiCategoryDefinition[] = [
{
id: 'itinerary',
label: '行程',
icon: 'itinerary',
id: 'exhibition-hall',
label: '展厅',
icon: 'exhibition',
order: 1,
homeVisible: true,
keywords: ['行程', '导览', '参观', 'touring_poi'],
categoryIds: ['touring_poi'],
iconTypes: ['touring', 'guide']
},
{
id: 'exhibition-hall',
label: '展览',
icon: 'exhibition',
order: 2,
homeVisible: true,
keywords: ['展厅', '展览', '展馆', 'exhibition_hall', 'hall'],
categoryIds: ['exhibition_hall', 'exhibition_hall_entrance'],
iconTypes: ['exhibition_hall', 'hall_entrance', 'exhibition'],
excludedCategoryIds: ['touring_poi']
categoryIds: ['exhibition_hall'],
iconTypes: ['exhibition_hall', 'exhibition']
},
{
id: 'cinema',
label: '影院',
icon: 'cinema',
order: 3,
order: 2,
homeVisible: true,
keywords: ['影院', '影厅', '剧场', '报告厅', 'cinema', 'theater'],
keywords: ['影院', '影厅', '剧场', '报告厅', 'cinema', 'theater', '影院空间'],
categoryIds: ['space_theater'],
iconTypes: ['cinema', 'theater']
},
@@ -65,47 +52,47 @@ const definitions: PoiCategoryDefinition[] = [
id: 'ticket-office',
label: '售票处',
icon: 'ticket',
order: 6,
order: 3,
homeVisible: true,
keywords: ['售票处', '售票', '票务', 'ticket_office'],
categoryIds: [],
iconTypes: ['ticket_office']
},
{
id: 'service-center',
label: '服务中心',
icon: 'service',
order: 8,
homeVisible: true,
keywords: ['服务中心', '服务台', '咨询台', 'service_desk'],
categoryIds: [],
iconTypes: ['service_desk']
},
{
id: 'dining',
label: '餐',
label: '餐',
icon: 'restaurant',
order: 5,
order: 4,
homeVisible: true,
keywords: ['餐饮', '餐厅', '快餐', '咖啡', 'restaurant', 'dining', 'food', 'cafe'],
categoryIds: [],
iconTypes: ['restaurant', 'dining', 'food', 'cafe']
},
{
id: 'souvenir',
label: '文创',
id: 'shopping',
label: '购物',
icon: 'bag',
order: 11,
homeVisible: false,
keywords: ['文创', '纪念品', '商店', 'souvenir', 'gift', 'shop'],
order: 5,
homeVisible: true,
keywords: ['购物', '商店', '文创', '纪念品', '零售', 'souvenir', 'gift', 'shop', 'shopping', 'store'],
categoryIds: [],
iconTypes: ['souvenir', 'gift', 'shop', 'cultural_creative']
iconTypes: ['souvenir', 'gift', 'shop', 'store', 'retail', 'cultural_shop', 'bookstore', 'vending']
},
{
id: 'service-center',
label: '服务',
icon: 'service',
order: 6,
homeVisible: true,
keywords: ['服务', '服务中心', '服务台', '咨询台', '租赁', '寄存', 'service_desk', 'rental_service', 'locker'],
categoryIds: [],
iconTypes: ['service_desk', 'service_center', 'rental_service', 'locker']
},
{
id: 'nursing-room',
label: '母婴',
label: '母婴',
icon: 'nursing',
order: 7,
order: 8,
homeVisible: true,
keywords: ['母婴室', '母婴间', 'mother_baby_room', 'nursing_room'],
categoryIds: [],
@@ -115,7 +102,7 @@ const definitions: PoiCategoryDefinition[] = [
id: 'restroom',
label: '洗手间',
icon: 'restroom',
order: 4,
order: 7,
homeVisible: true,
keywords: ['卫生间', '洗手间', '厕所', 'toilet', 'accessible_toilet', 'restroom'],
categoryIds: [],
@@ -131,16 +118,6 @@ const definitions: PoiCategoryDefinition[] = [
categoryIds: [],
iconTypes: ['elevator']
},
{
id: 'stairs',
label: '楼梯',
icon: 'stairs',
order: 12,
homeVisible: false,
keywords: ['楼梯', 'stairs', 'stair'],
categoryIds: [],
iconTypes: ['stairs', 'stair']
},
{
id: 'escalator',
label: '扶梯',
@@ -160,11 +137,12 @@ export const POI_CATEGORIES = Object.freeze(
const homeCategoryOrder: readonly PoiCategoryId[] = [
'exhibition-hall',
'cinema',
'restroom',
'dining',
'ticket-office',
'nursing-room',
'dining',
'shopping',
'service-center',
'restroom',
'nursing-room',
'elevator',
'escalator'
]
@@ -176,17 +154,15 @@ export const HOME_POI_CATEGORIES = Object.freeze(
)
const POI_CATEGORY_ICON_SYMBOLS: Readonly<Record<PoiCategoryId, string>> = {
itinerary: 'poi-itinerary',
'exhibition-hall': 'poi-exhibition-hall',
cinema: 'poi-cinema',
'ticket-office': 'poi-ticket-office',
'service-center': 'poi-service-center',
dining: 'poi-dining',
souvenir: 'poi-souvenir',
'nursing-room': 'poi-nursing-room',
shopping: 'poi-souvenir',
'service-center': 'poi-service-center',
restroom: 'poi-restroom',
'nursing-room': 'poi-nursing-room',
elevator: 'poi-elevator',
stairs: 'poi-stairs',
escalator: 'poi-escalator'
}
@@ -194,52 +170,218 @@ export const getPoiCategoryIconHref = (categoryId: PoiCategoryId) => (
`/static/icons/poi/shortcut-icons.svg#${POI_CATEGORY_ICON_SYMBOLS[categoryId]}`
)
type PoiCategorySource = Pick<MuseumPoi, 'name' | 'primaryCategory' | 'categories'>
export type PoiCategorySource = Pick<
MuseumPoi,
| 'id'
| 'name'
| 'floorId'
| 'primaryCategory'
| 'categories'
| 'kind'
| 'hallId'
| 'spaceId'
| 'sourcePlaceId'
| 'sourceSpaceId'
| 'sourceObjectName'
>
const normalizeValue = (value?: string | null) => (value || '').trim().toLowerCase()
const normalizeValue = (value?: string | null) => (value || '')
.trim()
.normalize('NFKC')
.toLowerCase()
.replace(/[\s-]+/g, '_')
.replace(/^_+|_+$/g, '')
const categoryValues = (categories: MuseumCategory[]) => categories.flatMap((category) => [
category.id,
category.label,
category.iconType || ''
])
const poiSemanticAliases: Readonly<Record<string, string>> = {
exhibition: 'exhibition_hall',
exhibition_hall: 'exhibition_hall',
hall: 'exhibition_hall',
: 'exhibition_hall',
: 'exhibition_hall',
: 'exhibition_hall',
cinema: 'theater',
theater: 'theater',
: 'theater',
: 'theater',
: 'theater',
ticket_office: 'ticket_office',
ticketing: 'ticket_office',
: 'ticket_office',
: 'ticket_office',
: 'ticket_office',
service_desk: 'service_desk',
service_center: 'service_desk',
information_desk: 'service_desk',
: 'service_desk',
: 'service_desk',
: 'service_desk',
restaurant: 'restaurant',
dining: 'restaurant',
food: 'restaurant',
: 'restaurant',
: 'restaurant',
: 'restaurant',
: 'restaurant',
cafe: 'cafe',
coffee: 'cafe',
: 'cafe',
commercial: 'shop',
shop: 'shop',
store: 'shop',
retail: 'shop',
: 'shop',
: 'shop',
: 'shop',
: 'shop',
cultural_shop: 'cultural_shop',
cultural_creative: 'cultural_shop',
: 'cultural_shop',
: 'cultural_shop',
bookstore: 'bookstore',
: 'bookstore',
vending: 'vending',
: 'vending',
toilet: 'toilet',
restroom: 'toilet',
: 'toilet',
: 'toilet',
: 'toilet',
accessible_toilet: 'accessible_toilet',
: 'accessible_toilet',
elevator: 'elevator',
lift: 'elevator',
: 'elevator',
escalator: 'escalator',
: 'escalator',
: 'escalator',
stairs: 'stairs',
stair: 'stairs',
: 'stairs',
mother_baby_room: 'mother_baby_room',
nursing_room: 'mother_baby_room',
: 'mother_baby_room',
: 'mother_baby_room',
: 'mother_baby_room',
entrance_exit: 'entrance_exit',
entrance: 'entrance_exit',
exit: 'entrance_exit',
door: 'entrance_exit',
: 'entrance_exit',
: 'entrance_exit',
: 'entrance_exit',
hall_entrance: 'hall_entrance',
entrance_anchor: 'entrance_anchor',
: 'entrance_anchor',
route_node: 'route_node',
navigation_node: 'route_node',
线: 'route_node',
navigable_place: 'navigable_place',
operation_experience: 'operation_experience',
guide_point: 'operation_experience',
: 'operation_experience'
}
/** Normalize source-specific English/Chinese type labels before domain matching. */
export const normalizePoiSemanticValue = (value?: string | null) => {
const normalized = normalizeValue(value)
return poiSemanticAliases[normalized] || normalized
}
const genericSourceCategoryValues = new Set([
'poi',
'basic_service_facility',
'transport_circulation',
'accessibility_special_service',
'business_poi',
'operation_experience',
'navigation_anchor',
'服务设施',
'交通流线',
'无障碍服务',
'运营服务',
'导览点位'
].map(normalizePoiSemanticValue))
/** Source grouping is not a visitor-facing search term. */
export const isGenericPoiSourceCategory = (category: Pick<MuseumCategory, 'id'>) => (
genericSourceCategoryValues.has(normalizePoiSemanticValue(category.id))
)
const categorySearchValues = (categories: MuseumCategory[]) => categories.flatMap((category) => {
// A generic source group such as "基础服务设施" describes the data source,
// not a visitor-facing service destination. Keep all of its labels out of
// free-text category matching so a restroom cannot become a service result.
if (isGenericPoiSourceCategory(category)) return []
return [category.id, category.label, category.iconType || '']
.map(normalizePoiSemanticValue)
.filter(Boolean)
})
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)))
const normalizedCategoryIds = new Set(categories.map((category) => normalizePoiSemanticValue(category.id)))
const normalizedIconTypes = new Set(categories.map((category) => normalizePoiSemanticValue(category.iconType)))
if (definition.excludedCategoryIds?.some((categoryId) => (
normalizedCategoryIds.has(normalizeValue(categoryId))
normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId))
))) {
return false
}
if (definition.categoryIds.some((categoryId) => normalizedCategoryIds.has(normalizeValue(categoryId)))) {
if (definition.categoryIds.some((categoryId) => normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId)))) {
return true
}
if (definition.iconTypes.some((iconType) => normalizedIconTypes.has(normalizeValue(iconType)))) {
if (definition.iconTypes.some((iconType) => normalizedIconTypes.has(normalizePoiSemanticValue(iconType)))) {
return true
}
// A recognized icon/category is a stronger signal than a word embedded in
// the place name. For example, "展厅售票点" is a ticket office, not an
// exhibition hall merely because its name contains "展厅".
const belongsToAnotherExplicitCategory = POI_CATEGORIES.some((candidate) => {
if (candidate.id === definition.id) return false
return candidate.categoryIds.some((categoryId) => (
normalizedCategoryIds.has(normalizePoiSemanticValue(categoryId))
)) || candidate.iconTypes.some((iconType) => (
normalizedIconTypes.has(normalizePoiSemanticValue(iconType))
))
})
if (belongsToAnotherExplicitCategory) return false
const searchableText = [
poi.name,
...categoryValues(categories)
...categorySearchValues(categories)
]
.map(normalizeValue)
.filter(Boolean)
.join(' ')
return definition.keywords.some((keyword) => searchableText.includes(normalizeValue(keyword)))
return definition.keywords.some((keyword) => {
const normalizedKeyword = normalizeValue(keyword)
const semanticKeyword = normalizePoiSemanticValue(keyword)
return searchableText.includes(normalizedKeyword)
|| searchableText.includes(semanticKeyword)
})
}
export const getPoiCategoryById = (categoryId?: string | null) => (
POI_CATEGORIES.find((category) => category.id === categoryId) || null
)
const legacyCategoryAliases: Readonly<Record<string, PoiCategoryId>> = {
souvenir: 'shopping',
exhibition_hall: 'exhibition-hall',
ticket_office: 'ticket-office',
service_center: 'service-center',
nursing_room: 'nursing-room'
}
export const getPoiCategoryById = (categoryId?: string | null) => {
const categoryIdValue = (categoryId || '').trim().toLowerCase()
const resolvedCategoryId = legacyCategoryAliases[normalizeValue(categoryIdValue)] || categoryIdValue
return POI_CATEGORIES.find((category) => category.id === resolvedCategoryId) || null
}
export const findPoiCategoryByKeyword = (keyword: string) => {
const normalizedKeyword = normalizeValue(keyword)
@@ -255,7 +397,89 @@ export const resolvePoiCategory = (poi: PoiCategorySource) => (
POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null
)
export const isPoiSearchCategorySupported = (poi: PoiCategorySource) => Boolean(resolvePoiCategory(poi))
const hiddenVisitorPrimaryTypes = new Set([
'entrance_exit',
'hall_entrance',
'entrance_anchor',
'route_node',
'navigable_place',
'operation_experience'
])
const primaryTypeValues = (poi: PoiCategorySource) => [
poi.primaryCategory.id,
poi.primaryCategory.label,
poi.primaryCategory.iconType || ''
]
.map(normalizePoiSemanticValue)
.filter(Boolean)
/** A visitor result must never be a guide point, door, entrance anchor, or route node. */
export const isVisitorSearchPoi = (poi: PoiCategorySource) => {
if (poi.kind === 'guide' || poi.kind === 'hall_entrance') return false
return !primaryTypeValues(poi).some((value) => hiddenVisitorPrimaryTypes.has(value))
}
/** Default floor browse only contains canonical halls and valid destination spaces. */
export const isDefaultSearchDestinationPoi = (poi: PoiCategorySource) => {
if (!isVisitorSearchPoi(poi)) return false
if (poi.kind) return poi.kind === 'hall' || poi.kind === 'space'
const primaryCategoryId = normalizePoiSemanticValue(poi.primaryCategory.id)
return primaryCategoryId === 'exhibition_hall'
|| primaryCategoryId === 'touring_poi'
|| primaryCategoryId.startsWith('space_')
}
export const isQuickFindPoi = (
poi: PoiCategorySource,
definition: PoiCategoryDefinition
) => isVisitorSearchPoi(poi) && matchesPoiCategory(poi, definition)
export const isFacilitySearchPoi = (poi: PoiCategorySource) => (
isVisitorSearchPoi(poi) && !isDefaultSearchDestinationPoi(poi)
)
/**
* A strong, source-stable identity for source-level deduplication. Linked space
* IDs are deliberately exposed separately because a facility inside a hall is
* not automatically a duplicate of that hall.
*/
export const getPoiStablePlaceIdentity = (poi: PoiCategorySource) => {
const primaryCategoryId = normalizePoiSemanticValue(poi.primaryCategory.id)
const canUseLinkedSpace = poi.kind === 'hall'
|| poi.kind === 'space'
|| primaryCategoryId === 'business_poi'
|| primaryCategoryId.startsWith('space_')
const sourceSpaceId = canUseLinkedSpace
? poi.sourceSpaceId?.trim() || poi.spaceId?.trim() || poi.hallId?.trim()
: ''
if (sourceSpaceId) return `space:${sourceSpaceId}`
const sourcePlaceId = poi.sourcePlaceId?.trim()
if (sourcePlaceId) return `place:${sourcePlaceId}`
const poiId = poi.id?.trim()
if (poiId) return `poi:${poiId}`
const normalizedName = normalizeValue(poi.name)
return normalizedName ? `name:${poi.floorId || ''}:${normalizedName}` : ''
}
export const getPoiLinkedSpaceIdentity = (poi: PoiCategorySource) => {
const primaryCategoryId = normalizePoiSemanticValue(poi.primaryCategory.id)
const canUseLinkedSpace = poi.kind === 'hall'
|| poi.kind === 'space'
|| primaryCategoryId === 'business_poi'
|| primaryCategoryId.startsWith('space_')
const spaceId = canUseLinkedSpace
? poi.sourceSpaceId?.trim() || poi.spaceId?.trim() || poi.hallId?.trim()
: ''
return spaceId ? `space:${spaceId}` : ''
}
/** Existing consumers use this for generic search eligibility, not quick-find eligibility. */
export const isPoiSearchCategorySupported = (poi: PoiCategorySource) => isVisitorSearchPoi(poi)
export type PoiDataIssueCode =
| 'missing-id'

View File

@@ -2,6 +2,7 @@ import type {
MuseumPoi
} from '@/domain/museum'
import type {
PoiCategoryDefinition,
PoiCategoryId
} from '@/domain/poiCategories'
@@ -26,4 +27,29 @@ export interface PoiSearchSelection {
export interface PoiCategoryResultState extends PoiSearchContext {
active: boolean
pending?: boolean
}
export type GuidePoiSearchMode = 'default' | 'category' | 'keyword'
export interface GuidePoiSearchCategoryState {
definition: PoiCategoryDefinition
count: number
disabled: boolean
}
/**
* Ready-to-render search state for exactly one active floor. The results and
* visible IDs deliberately share the same canonical source so map/list parity
* cannot be reimplemented by a component.
*/
export interface GuidePoiSearchViewState {
mode: GuidePoiSearchMode
floorId: string
floorLabel: string
keyword: string
categoryId: PoiCategoryId | ''
results: MuseumPoi[]
visiblePoiIds: string[]
categories: GuidePoiSearchCategoryState[]
}

View File

@@ -1640,7 +1640,7 @@ const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => {
}
searchVisiblePoiIds.value = [...state.visiblePoiIds]
if (state.floorId) {
if (state.floorId && !state.pending) {
activeGuideFloor.value = state.floorId
}
}

View File

@@ -7,6 +7,9 @@ import type {
import type {
MuseumPoi
} from '@/domain/museum'
import {
isVisitorSearchPoi
} from '@/domain/poiCategories'
import {
getPoiDisplayPolicy
} from '@/domain/poiDisplay'
@@ -31,12 +34,12 @@ import {
} from '@/data/providers/sgsSdkApiProvider'
import {
formatNavFloorLabel,
isStaticIndoorNavigableFloorId
isStaticIndoorNavigableFloorId,
toMuseumPoi
} from '@/data/adapters/navAssetsAdapter'
import {
defaultStaticNavAssetsProvider,
type StaticNavAssetsProvider,
type StaticNavPoiPayload
type StaticNavAssetsProvider
} from '@/data/providers/staticNavAssetsProvider'
import {
mapWithConcurrency
@@ -59,26 +62,6 @@ const getNow = () => (
: Date.now()
)
const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => {
const kind = poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility'
return {
id: poi.id,
name: poi.name,
floorId: poi.floorId,
primaryCategory: poi.primaryCategory,
primaryCategoryZh: poi.primaryCategoryZh,
iconType: poi.iconType || poi.primaryCategory,
positionGltf: poi.positionGltf,
sourceObjectName: poi.sourceObjectName,
kind,
hallName: poi.primaryCategory === 'touring_poi' ? poi.name : undefined,
displayPolicy: getPoiDisplayPolicy({
primaryCategory: poi.primaryCategory,
kind
})
}
}
const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
id: poi.id,
name: poi.name,
@@ -104,10 +87,17 @@ const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
const toSgsRenderPoi = (poi: ReturnType<typeof toMuseumPoiFromSgs>) => toGuideRenderPoi(poi)
const getRenderPoiDedupeKeys = (poi: GuideRenderPoi) => {
// A facility can be located inside the same spatial area as other facilities.
// That relationship must not collapse distinct visitor markers (for example,
// a restroom and an elevator in one service zone). Space identity is only a
// canonical-place key for halls, spaces, and business-place representations.
const canDedupeBySpace = poi.kind === 'hall'
|| poi.kind === 'space'
|| poi.primaryCategory === 'business_poi'
const stableKeys = [
poi.id ? `id:${poi.id}` : '',
poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '',
poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '',
canDedupeBySpace && poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '',
canDedupeBySpace && poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '',
poi.sourcePlaceId ? `place:${poi.floorId}:${poi.sourcePlaceId}` : ''
].filter(Boolean)
@@ -303,7 +293,9 @@ export class StaticGuideModelRepository implements GuideModelRepository {
const pois = await this.provider.loadFloorPois(floor.poiDataAsset)
return pois
.map(toRenderPoi)
.map(toMuseumPoi)
.filter(isVisitorSearchPoi)
.map(toGuideRenderPoi)
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
}
}
@@ -472,12 +464,19 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
loadFloorData('guidePois', () => this.guide.listPois()),
loadFloorData('guideSpacePoints', () => this.guide.listSpacePoints())
])
const ordinaryPois = pois.map((poi) => toSgsRenderPoi(toMuseumPoiFromSgs(poi, manifest.floors)))
const ordinaryPois = pois
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter(isVisitorSearchPoi)
.map(toSgsRenderPoi)
const repositoryBusinessPois = guidePois
.filter((poi) => poi.floorId === resolvedFloorId && poi.primaryCategory.id === 'business_poi')
.filter((poi) => (
poi.floorId === resolvedFloorId
&& poi.primaryCategory.id === 'business_poi'
&& isVisitorSearchPoi(poi)
))
.map(toGuideRenderPoi)
const repositorySpacePois = guideSpacePoints
.filter((poi) => poi.floorId === resolvedFloorId)
.filter((poi) => poi.floorId === resolvedFloorId && isVisitorSearchPoi(poi))
.map(toGuideRenderPoi)
const floorPoiMedianY = getMedianPoiY(ordinaryPois)
const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId, {
@@ -492,7 +491,9 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
})
}
const hallPois = museumHallPois.map(toSgsRenderPoi)
const hallPois = museumHallPois
.filter(isVisitorSearchPoi)
.map(toSgsRenderPoi)
const adaptedPois = dedupeRenderPoisById([
...hallPois,
...ordinaryPois,

View File

@@ -17,6 +17,17 @@ import {
isPoiOnIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
POI_CATEGORIES,
getPoiCategoryById,
getPoiLinkedSpaceIdentity,
getPoiStablePlaceIdentity,
isDefaultSearchDestinationPoi,
isFacilitySearchPoi,
isGenericPoiSourceCategory,
isQuickFindPoi,
isVisitorSearchPoi,
resolvePoiCategory,
type PoiCategoryId,
warnPoiCollectionIssues
} from '@/domain/poiCategories'
import {
@@ -46,33 +57,29 @@ import {
toRouteReadinessFromSgsDiagnostics
} from '@/data/adapters/sgsSdkGuideAdapter'
const searchableCategories = new Set([
'touring_poi',
'exhibition_hall',
'exhibition_hall_entrance',
'basic_service_facility',
'transport_circulation',
'accessibility_special_service',
'business_poi',
'operation_experience'
const toSearchText = (poi: MuseumPoi) => {
const resolvedCategory = resolvePoiCategory(poi)
const rawCategoryTerms = [poi.primaryCategory, ...poi.categories].flatMap((category) => [
...(isGenericPoiSourceCategory(category) ? [] : [category.label, category.id]),
category.iconType
])
const toSearchText = (poi: MuseumPoi) => [
return [
poi.name,
poi.hallName,
poi.floorId,
poi.floorLabel,
poi.primaryCategory.label,
poi.primaryCategory.iconType,
...poi.categories.flatMap((category) => [
category.label,
category.id,
category.iconType
])
...rawCategoryTerms,
// Search terms must use the same normalized visitor category as quick find.
// SDK labels such as "卫生间" otherwise miss the visitor-facing "洗手间" alias.
resolvedCategory?.id,
resolvedCategory?.label,
...(resolvedCategory?.keywords || [])
]
.filter(Boolean)
.join(' ')
.toLowerCase()
}
const normalizePoiLookupText = (value: string) => value
.trim()
@@ -117,11 +124,6 @@ const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({
entrances: poi.entrances
})
const isSearchableIndoorPoi = (poi: MuseumPoi) => (
searchableCategories.has(poi.primaryCategory.id)
&& isPoiOnIndoorNavigableFloor(poi)
)
const dedupePoisById = (pois: MuseumPoi[]) => {
const seen = new Set<string>()
return pois.filter((poi) => {
@@ -131,8 +133,6 @@ const dedupePoisById = (pois: MuseumPoi[]) => {
})
}
const getPoiSpaceIdentity = (poi: MuseumPoi) => poi.sourceSpaceId || poi.spaceId || ''
const getPoiSourceLookupIdentity = (id: string) => (
id.trim().replace(/^(?:hall|space)-/, '')
)
@@ -147,44 +147,159 @@ const mergePoiCategories = (...categoryGroups: MuseumCategory[][]) => {
})
}
const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => {
const hallPoisBySpaceId = new Map(
pois
.filter((poi) => poi.kind === 'hall' || Boolean(poi.hallId))
.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 hasRenderablePosition = (poi: MuseumPoi) => (
Array.isArray(poi.positionGltf)
&& poi.positionGltf.length === 3
&& poi.positionGltf.every((value) => Number.isFinite(value))
)
const mergedPois = pois.map((poi) => {
const spaceId = getPoiSpaceIdentity(poi)
const matchingSpacePoint = spaceId ? spacePointsBySpaceId.get(spaceId) : null
const matchingHallPoi = spaceId ? hallPoisBySpaceId.get(spaceId) : null
if (!matchingSpacePoint || matchingHallPoi?.id !== poi.id) return poi
const isVisitorSearchResult = (poi: MuseumPoi) => (
isVisitorSearchPoi(poi)
&& isPoiOnIndoorNavigableFloor(poi)
&& hasRenderablePosition(poi)
)
const normalizePlaceName = (value: string) => value
.trim()
.normalize('NFKC')
.toLowerCase()
.replace(/[\s\-_/()[\]{}]/g, '')
const positionKey = (poi: MuseumPoi) => {
if (!hasRenderablePosition(poi)) return ''
const [x, y, z] = poi.positionGltf!
return [x, y, z].map((value) => Math.round(value * 2) / 2).join(',')
}
const destinationQuickCategoryIds = new Set<PoiCategoryId>([
'exhibition-hall',
'cinema',
'dining',
'shopping'
])
const canonicalCategoryId = (poi: MuseumPoi) => resolvePoiCategory(poi)?.id || ''
const canonicalMergeKeys = (poi: MuseumPoi) => {
const categoryId = canonicalCategoryId(poi)
const keys: string[] = []
const linkedSpace = getPoiLinkedSpaceIdentity(poi)
if (linkedSpace) {
keys.push(`space:${poi.floorId}:${linkedSpace}:${categoryId || 'other'}`)
}
const normalizedName = normalizePlaceName(poi.name)
const location = positionKey(poi)
// A space, regular POI, and business POI can describe the same visitor
// place without sharing a spatial-area ID. Exact normalized name and
// render position are a stronger cross-source identity than source IDs.
if (normalizedName && location) {
keys.push(`fallback:${poi.floorId}:${categoryId || 'other'}:${normalizedName}:${location}`)
}
if (keys.length) return keys
const stableIdentity = getPoiStablePlaceIdentity(poi)
return stableIdentity ? [stableIdentity] : []
}
const canonicalPoiPriority = (poi: MuseumPoi) => {
const categoryId = canonicalCategoryId(poi)
const isDestinationCategory = categoryId
? destinationQuickCategoryIds.has(categoryId)
: isDefaultSearchDestinationPoi(poi)
let priority = hasRenderablePosition(poi) ? 8 : 0
if (isDestinationCategory) {
if (categoryId === 'dining' || categoryId === 'shopping') {
// Keep a renderable spatial representation ahead of a business record;
// source ordering then matches the renderer's canonical marker ID.
if (poi.kind === 'hall' || poi.kind === 'space') priority += 56
if (poi.kind === 'facility') priority += 40
} else if (poi.kind === 'hall' || poi.kind === 'space') {
priority += 56
} else {
priority += 16
}
} else if (poi.kind === 'facility') {
priority += 52
} else if (poi.kind === 'hall' || poi.kind === 'space') {
priority += 30
}
return priority
}
const mergeCanonicalPoi = (left: MuseumPoi, right: MuseumPoi) => {
const primary = canonicalPoiPriority(right) > canonicalPoiPriority(left) ? right : left
const secondary = primary === left ? right : left
return {
...poi,
primaryCategory: matchingSpacePoint.primaryCategory,
...primary,
categories: mergePoiCategories(
[matchingSpacePoint.primaryCategory],
matchingSpacePoint.categories
)
[primary.primaryCategory],
primary.categories,
[secondary.primaryCategory],
secondary.categories
),
hallId: primary.hallId || secondary.hallId,
hallName: primary.hallName || secondary.hallName,
spaceId: primary.spaceId || secondary.spaceId,
sourceSpaceId: primary.sourceSpaceId || secondary.sourceSpaceId,
sourcePlaceId: primary.sourcePlaceId || secondary.sourcePlaceId,
entrances: primary.entrances || secondary.entrances
}
})
const unmatchedSpacePoints = spacePoints.filter((poi) => {
const spaceId = getPoiSpaceIdentity(poi)
const matchingHallPoi = spaceId ? hallPoisBySpaceId.get(spaceId) : null
return !matchingHallPoi
}
/**
* Search queries return a single map-renderable representation of each real
* visitor place. Source collections remain intact for the renderer itself.
*/
const mergeCanonicalSearchPois = (pois: MuseumPoi[]) => {
const canonicalByKey = new Map<string, MuseumPoi>()
pois
.filter(isVisitorSearchResult)
.forEach((poi) => {
const keys = canonicalMergeKeys(poi)
if (!keys.length) keys.push(`poi:${poi.id}`)
const existingPois = Array.from(new Set(
keys
.map((key) => canonicalByKey.get(key))
.filter((candidate): candidate is MuseumPoi => Boolean(candidate))
))
const mergedPoi = existingPois.reduce(
(merged, existing) => mergeCanonicalPoi(existing, merged),
poi
)
if (existingPois.length) {
for (const [key, candidate] of canonicalByKey) {
if (existingPois.includes(candidate)) canonicalByKey.set(key, mergedPoi)
}
}
keys.forEach((key) => canonicalByKey.set(key, mergedPoi))
})
return dedupePoisById([
...mergedPois,
...unmatchedSpacePoints
return dedupePoisById(Array.from(canonicalByKey.values()))
}
const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => (
mergeCanonicalSearchPois([
...pois,
...spacePoints
])
)
const restrictToFloor = (pois: MuseumPoi[], floorId?: string) => (
floorId ? pois.filter((poi) => poi.floorId === floorId) : pois
)
const filterByKeyword = (pois: MuseumPoi[], keyword = '') => {
const normalizedKeyword = keyword.trim().toLowerCase()
return normalizedKeyword
? pois.filter((poi) => toSearchText(poi).includes(normalizedKeyword))
: pois
}
const getMedianPoiY = (pois: MuseumPoi[]) => {
@@ -243,8 +358,11 @@ export interface GuideRepository {
normalizeFloorId(labelOrId: string): string
listPois(): Promise<MuseumPoi[]>
listSpacePoints(): Promise<MuseumPoi[]>
listDestinationPois(floorId?: string): Promise<MuseumPoi[]>
listQuickFindPois(categoryId: PoiCategoryId, floorId?: string): Promise<MuseumPoi[]>
getQuickFindCategoryAvailability(floorId?: string): Promise<GuideQuickFindCategoryAvailability[]>
getPoiById(id: string): Promise<MuseumPoi | null>
searchPois(keyword?: string): Promise<MuseumPoi[]>
searchPois(keyword?: string, floorId?: string): Promise<MuseumPoi[]>
getLocationPreview(poiId: string): Promise<GuideLocationPreview | null>
getRouteReadiness(): Promise<GuideRouteReadiness>
getFloorDetail?(floorId: string): Promise<GuideFloorDetail | null>
@@ -252,8 +370,15 @@ export interface GuideRepository {
checkDataIntegrity?(): Promise<GuideDataIntegrityReport>
}
export interface GuideQuickFindCategoryAvailability {
id: PoiCategoryId
count: number
disabled: boolean
}
export class StaticGuideRepository implements GuideRepository {
private poiCache: MuseumPoi[] | null = null
private visitorSearchPoolCache: MuseumPoi[] | null = null
constructor(private readonly provider: StaticNavAssetsProvider = defaultStaticNavAssetsProvider) {}
@@ -284,7 +409,7 @@ export class StaticGuideRepository implements GuideRepository {
const adaptedPois = pois.map(toMuseumPoi)
warnPoiCollectionIssues('static-nav-assets', adaptedPois)
this.poiCache = dedupePoisById(adaptedPois
.filter(isSearchableIndoorPoi)
.filter(isPoiOnIndoorNavigableFloor)
)
return this.poiCache
@@ -294,18 +419,64 @@ export class StaticGuideRepository implements GuideRepository {
return []
}
private async getVisitorSearchPool() {
if (this.visitorSearchPoolCache) return this.visitorSearchPoolCache
const [pois, spacePoints] = await Promise.all([
this.listPois(),
this.listSpacePoints()
])
this.visitorSearchPoolCache = mergeCanonicalSearchPois([
...pois,
...spacePoints
])
return this.visitorSearchPoolCache
}
async listDestinationPois(floorId?: string) {
const searchPool = await this.getVisitorSearchPool()
// Static nav assets do not expose a standalone space collection. Their
// hall/destination POIs are the source-faithful fallback for default browse.
return restrictToFloor(searchPool.filter(isDefaultSearchDestinationPoi), floorId)
}
async listQuickFindPois(categoryId: PoiCategoryId, floorId?: string) {
const category = getPoiCategoryById(categoryId)
if (!category) return []
const searchPool = await this.getVisitorSearchPool()
return restrictToFloor(searchPool.filter((poi) => {
if (!isQuickFindPoi(poi, category)) return false
return destinationQuickCategoryIds.has(category.id)
|| isFacilitySearchPoi(poi)
}), floorId)
}
async getQuickFindCategoryAvailability(floorId?: string) {
const searchPool = await this.getVisitorSearchPool()
const floorPois = restrictToFloor(searchPool, floorId)
return POI_CATEGORIES.map((category) => {
const count = floorPois.filter((poi) => (
isQuickFindPoi(poi, category)
&& (destinationQuickCategoryIds.has(category.id) || isFacilitySearchPoi(poi))
)).length
return {
id: category.id,
count,
disabled: count === 0
}
})
}
async getPoiById(id: string) {
const pois = await this.listPois()
return pois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(pois, id)
}
async searchPois(keyword = '') {
const pois = await this.listPois()
const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) return pois
return pois.filter((poi) => toSearchText(poi).includes(normalizedKeyword))
async searchPois(keyword = '', floorId?: string) {
const searchPool = await this.getVisitorSearchPool()
return filterByKeyword(restrictToFloor(searchPool, floorId), keyword)
}
async getLocationPreview(poiId: string) {
@@ -547,6 +718,71 @@ export class SgsSdkGuideRepository implements GuideRepository {
return result
}
private async getVisitorSearchPool() {
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 searchPool = mergeCanonicalSearchPois([
...pois,
...spacePoints
])
if (sourceFailures.length && !searchPool.length) {
throw new Error(poiDataLoadErrorMessage)
}
if (import.meta.env.DEV && sourceFailures.length) {
console.warn('[SGS 点位数据] 搜索使用部分可用数据', sourceFailures.map(toErrorMessage))
}
return searchPool
}
async listDestinationPois(floorId?: string) {
const searchPool = await this.getVisitorSearchPool()
return restrictToFloor(searchPool.filter(isDefaultSearchDestinationPoi), floorId)
}
async listQuickFindPois(categoryId: PoiCategoryId, floorId?: string) {
const category = getPoiCategoryById(categoryId)
if (!category) return []
const searchPool = await this.getVisitorSearchPool()
return restrictToFloor(searchPool.filter((poi) => {
if (!isQuickFindPoi(poi, category)) return false
return destinationQuickCategoryIds.has(category.id)
|| isFacilitySearchPoi(poi)
}), floorId)
}
async getQuickFindCategoryAvailability(floorId?: string) {
const searchPool = await this.getVisitorSearchPool()
const floorPois = restrictToFloor(searchPool, floorId)
return POI_CATEGORIES.map((category) => {
const count = floorPois.filter((poi) => (
isQuickFindPoi(poi, category)
&& (destinationQuickCategoryIds.has(category.id) || isFacilitySearchPoi(poi))
)).length
return {
id: category.id,
count,
disabled: count === 0
}
})
}
async getPoiById(id: string) {
const normalizedId = id.trim()
if (!normalizedId) return null
@@ -581,7 +817,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
|| findPoiByNavIdNameFallback(pois, id)
}
async searchPois(keyword = '') {
async searchPois(keyword = '', floorId?: string) {
const sourceFailures: unknown[] = []
let pois: MuseumPoi[] = []
let spacePoints: MuseumPoi[] = []
@@ -607,9 +843,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
}
const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) return searchablePois
return searchablePois.filter((poi) => toSearchText(poi).includes(normalizedKeyword))
return filterByKeyword(restrictToFloor(searchablePois, floorId), normalizedKeyword)
}
async getLocationPreview(poiId: string) {

View File

@@ -23,6 +23,13 @@ import {
applyRouteReadinessGate,
NAV_ROUTE_READINESS
} from '@/domain/guideReadiness'
import {
getPoiCategoryById
} from '@/domain/poiCategories'
import type {
GuidePoiSearchMode,
GuidePoiSearchViewState
} from '@/domain/poiSearch'
const startSourceLabels: Record<string, string> = {
'facility-detail': '设施详情选择',
@@ -98,6 +105,12 @@ export interface GuideContentLocationRequest {
targetName?: string
}
type PoiSearchModeInput = {
mode: GuidePoiSearchMode
keyword?: string
categoryId?: GuidePoiSearchViewState['categoryId']
}
export class GuideUseCase {
private routeReadinessCache: GuideRouteReadiness | null = null
@@ -139,8 +152,115 @@ export class GuideUseCase {
return this.guide.searchPois(keyword)
}
getInitialSearchSpacePoints() {
return this.guide.searchPois()
getInitialSearchSpacePoints(floorId?: string) {
return this.guide.listDestinationPois(floorId)
}
private async resolvePoiSearchFloor(floorId?: string) {
const floors = await this.guide.getFloors()
const requestedFloorId = floorId?.trim() ? this.guide.normalizeFloorId(floorId) : ''
const floor = floors.find((item) => item.id === requestedFloorId)
|| floors.find((item) => item.label === floorId)
|| floors.find((item) => item.label === '1F')
|| floors[0]
return floor || {
id: requestedFloorId || floorId?.trim() || '',
label: floorId?.trim() || ''
}
}
private async loadPoiSearchView(
floorId: string | undefined,
input: PoiSearchModeInput
): Promise<GuidePoiSearchViewState> {
const floor = await this.resolvePoiSearchFloor(floorId)
const keyword = input.keyword?.trim() || ''
const categoryId = input.categoryId || ''
const category = categoryId ? getPoiCategoryById(categoryId) : null
const mode = input.mode === 'category' && !category
? 'default'
: input.mode
const [availability, results] = await Promise.all([
this.guide.getQuickFindCategoryAvailability(floor.id),
mode === 'category' && category
? this.guide.listQuickFindPois(category.id, floor.id)
: mode === 'keyword'
? this.guide.searchPois(keyword, floor.id)
: this.guide.listDestinationPois(floor.id)
])
return {
mode,
floorId: floor.id,
floorLabel: floor.label,
keyword: mode === 'keyword' ? keyword : category?.label || '',
categoryId: mode === 'category' && category ? category.id : '',
results,
visiblePoiIds: results.map((poi) => poi.id),
categories: availability.map((item) => {
const definition = getPoiCategoryById(item.id)
if (!definition) {
throw new Error(`Unknown quick-find category: ${item.id}`)
}
return {
definition,
count: item.count,
disabled: item.disabled
}
})
}
}
createInitialPoiSearchState(floorId?: string) {
return this.loadPoiSearchView(floorId, { mode: 'default' })
}
selectPoiSearchCategory(
state: GuidePoiSearchViewState | null,
categoryId: GuidePoiSearchViewState['categoryId'],
floorId = state?.floorId
) {
return this.loadPoiSearchView(floorId, {
mode: 'category',
categoryId
})
}
searchPoiKeyword(
state: GuidePoiSearchViewState | null,
keyword: string,
floorId = state?.floorId
) {
const normalizedKeyword = keyword.trim()
return this.loadPoiSearchView(floorId, normalizedKeyword
? {
mode: 'keyword',
keyword: normalizedKeyword
}
: {
mode: 'default'
})
}
clearPoiSearch(
state: GuidePoiSearchViewState | null,
floorId = state?.floorId
) {
return this.loadPoiSearchView(floorId, { mode: 'default' })
}
changePoiSearchFloor(
state: GuidePoiSearchViewState | null,
floorId: string
) {
const mode = state?.mode || 'default'
return this.loadPoiSearchView(floorId, {
mode,
keyword: state?.keyword,
categoryId: state?.categoryId
})
}
getPoiById(id: string) {

View File

@@ -0,0 +1,408 @@
import { describe, expect, it, vi } from 'vitest'
import {
resolvePoiCategory,
type PoiCategoryId
} from '@/domain/poiCategories'
import type { SgsSdkApiProvider, SgsSdkManifestPayload } from '@/data/providers/sgsSdkApiProvider'
import type { StaticNavAssetsProvider } from '@/data/providers/staticNavAssetsProvider'
import { SgsSdkGuideRepository, StaticGuideRepository } from '@/repositories/GuideRepository'
import {
SgsSdkGuideModelRepository,
StaticGuideModelRepository
} from '@/repositories/GuideModelRepository'
const manifest: SgsSdkManifestPayload = {
mapId: 'museum',
floors: [{
floorId: 'L1',
floorCode: 'L1',
floorName: '1F',
sortOrder: 1
}]
}
const position = (x: number): [number, number, number] => [x, 12, x]
const allQuickCategoryIds: readonly PoiCategoryId[] = [
'exhibition-hall',
'cinema',
'ticket-office',
'dining',
'shopping',
'service-center',
'restroom',
'nursing-room',
'elevator',
'escalator'
]
const floorSpaces = [
{ id: 'space-hall', name: '地球展厅', type: 'exhibition_hall', floorId: 'L1', center: { x: 1, y: 12, z: 1 } },
{ id: 'space-cinema', name: '穹幕影院', type: 'theater', floorId: 'L1', center: { x: 2, y: 12, z: 2 } },
{ id: 'space-dining', name: '餐饮区', type: 'restaurant', floorId: 'L1', center: { x: 3, y: 12, z: 3 } },
{ id: 'space-shopping', name: '文创商店', type: 'commercial', floorId: 'L1', center: { x: 4, y: 12, z: 4 } }
]
const floorPois = [
{ id: 'ticket', name: '售票处', type: 'ticket_office', floorId: 'L1', position: { x: 10, y: 12, z: 10 } },
{ id: 'service', name: '咨询服务台', type: 'service_desk', floorId: 'L1', position: { x: 11, y: 12, z: 11 } },
{
id: 'restroom',
name: '女卫 01',
type: 'toilet',
floorId: 'L1',
spatialAreaId: 'shared-service-zone',
position: { x: 12, y: 12, z: 12 }
},
{ id: 'nursing', name: '母婴室', type: 'mother_baby_room', floorId: 'L1', position: { x: 13, y: 12, z: 13 } },
{
id: 'elevator',
name: '无障碍电梯',
type: 'elevator',
floorId: 'L1',
spatialAreaId: 'shared-service-zone',
position: { x: 14, y: 12, z: 14 }
},
{ id: 'escalator', name: '自动扶梯', type: 'escalator', floorId: 'L1', position: { x: 15, y: 12, z: 15 } },
{
id: 'poi-dining',
name: '餐饮区',
type: 'restaurant',
floorId: 'L1',
spatialAreaId: 'space-dining',
spatialAreaName: '餐饮区',
position: { x: 3, y: 12, z: 3 }
},
{ id: 'door', name: '北入口', type: 'entrance_exit', floorId: 'L1', position: { x: 16, y: 12, z: 16 } },
{ id: 'anchor', name: '入口锚点', type: 'entrance_anchor', floorId: 'L1', position: { x: 17, y: 12, z: 17 } },
{ id: 'route-node', name: '路线节点', type: 'route_node', floorId: 'L1', position: { x: 18, y: 12, z: 18 } },
{ id: 'guide-stop', name: '讲解点', type: 'operation_experience', floorId: 'L1', position: { x: 19, y: 12, z: 19 } }
]
const businessPois = [
{
id: 'business-dining',
name: '餐饮区',
type: 'restaurant',
poiGroup: 'BUSINESS',
businessType: 'restaurant',
spatialAreaId: 'space-dining',
spatialAreaName: '餐饮区',
floorId: 'L1',
position: { x: 3, y: 12, z: 3 }
},
{
id: 'business-shopping',
name: '文创商店',
type: 'shop',
poiGroup: 'BUSINESS',
businessType: 'shop',
spatialAreaId: 'space-shopping',
spatialAreaName: '文创商店',
floorId: 'L1',
position: { x: 4, y: 12, z: 4 }
}
]
const createSgsProvider = (overrides: Partial<SgsSdkApiProvider> = {}) => ({
getManifest: vi.fn().mockResolvedValue(manifest),
getFloorPois: vi.fn().mockResolvedValue(floorPois),
getFloorBusinessPois: vi.fn().mockResolvedValue(businessPois),
getFloorSpaces: vi.fn().mockResolvedValue(floorSpaces),
getNavigablePlaces: vi.fn().mockResolvedValue([
{ id: 'nav-door', name: '入口', type: 'navigable_place', floorId: 'L1', position: { x: 20, y: 12, z: 20 } }
]),
...overrides
}) as unknown as SgsSdkApiProvider
describe('GuideRepository visitor search contracts', () => {
it('uses spaces for default browse, exposes all ten quick categories, and never returns anchors', async () => {
const provider = createSgsProvider()
const repository = new SgsSdkGuideRepository(provider)
const [defaultResults, availability] = await Promise.all([
repository.listDestinationPois('L1'),
repository.getQuickFindCategoryAvailability('L1')
])
expect(defaultResults.map((poi) => poi.id)).toEqual(expect.arrayContaining([
'hall-space-hall',
'hall-space-cinema',
'space-space-dining',
'space-space-shopping'
]))
expect(defaultResults.map((poi) => poi.id)).not.toEqual(expect.arrayContaining([
'restroom',
'elevator',
'ticket',
'door',
'anchor',
'route-node',
'guide-stop'
]))
expect(availability).toEqual(allQuickCategoryIds.map((id) => ({
id,
count: 1,
disabled: false
})))
await Promise.all(allQuickCategoryIds.map(async (categoryId) => {
const results = await repository.listQuickFindPois(categoryId, 'L1')
expect(results).toHaveLength(1)
expect(results[0].floorId).toBe('L1')
expect(results[0].positionGltf).toBeDefined()
expect(resolvePoiCategory(results[0])?.id).toBe(categoryId)
}))
const renderPois = await new SgsSdkGuideModelRepository(provider, repository).loadFloorPois('L1')
const renderPoiIds = new Set(renderPois.map((poi) => poi.id))
// Facilities in one spatial area are distinct visitor places and must both
// survive model marker deduplication.
expect(renderPoiIds.has('restroom')).toBe(true)
expect(renderPoiIds.has('elevator')).toBe(true)
for (const hiddenPoiId of ['door', 'anchor', 'route-node', 'guide-stop']) {
expect(renderPoiIds.has(hiddenPoiId)).toBe(false)
}
const quickResultIds = (await Promise.all(allQuickCategoryIds.map((categoryId) => (
repository.listQuickFindPois(categoryId, 'L1')
)))).flat().map((poi) => poi.id)
expect([...defaultResults, ...quickResultIds.map((id) => ({ id }))]
.every((poi) => renderPoiIds.has(poi.id))).toBe(true)
})
it('normalizes restaurant/shop sources, deduplicates a real place, and excludes non-visitor search records', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider())
const [dining, shopping, keywordDining, keywordRestroom, hiddenKeyword] = await Promise.all([
repository.listQuickFindPois('dining', 'L1'),
repository.listQuickFindPois('shopping', 'L1'),
repository.searchPois('餐饮', 'L1'),
repository.searchPois('洗手间', 'L1'),
repository.searchPois('入口', 'L1')
])
expect(dining.map((poi) => poi.id)).toEqual(['space-space-dining'])
expect(shopping.map((poi) => poi.id)).toEqual(['space-space-shopping'])
expect(keywordDining.map((poi) => poi.id)).toEqual(['space-space-dining'])
expect(keywordRestroom.map((poi) => poi.id)).toEqual(['restroom'])
expect(hiddenKeyword).toEqual([])
})
it('deduplicates co-located sources when they do not provide a shared spatial area ID', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([{
id: 'poi-cafe',
name: '中庭咖啡厅',
type: 'restaurant',
floorId: 'L1',
position: { x: 40, y: 12, z: 40 }
}]),
getFloorBusinessPois: vi.fn().mockResolvedValue([{
id: 'business-cafe',
name: '中庭咖啡厅',
type: 'restaurant',
poiGroup: 'BUSINESS',
businessType: 'restaurant',
floorId: 'L1',
position: { x: 40, y: 12, z: 40 }
}]),
getFloorSpaces: vi.fn().mockResolvedValue([{
id: 'space-cafe',
name: '中庭咖啡厅',
type: 'restaurant',
floorId: 'L1',
center: { x: 40, y: 12, z: 40 }
}])
}))
const [quickFind, keyword] = await Promise.all([
repository.listQuickFindPois('dining', 'L1'),
repository.searchPois('咖啡', 'L1')
])
expect(quickFind).toHaveLength(1)
expect(keyword).toHaveLength(1)
expect(quickFind[0]).toMatchObject({ kind: 'space', name: '中庭咖啡厅' })
expect(keyword.map((poi) => poi.id)).toEqual(quickFind.map((poi) => poi.id))
})
it('deduplicates source representations linked to one space before positional fallback', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([{
id: 'poi-cafe-counter',
name: '咖啡取餐台',
type: 'restaurant',
floorId: 'L1',
spatialAreaId: 'space-cafe',
position: { x: 45, y: 12, z: 45 }
}]),
getFloorBusinessPois: vi.fn().mockResolvedValue([{
id: 'business-cafe',
name: '中庭咖啡厅',
type: 'restaurant',
poiGroup: 'BUSINESS',
businessType: 'restaurant',
floorId: 'L1',
spatialAreaId: 'space-cafe',
position: { x: 46, y: 12, z: 46 }
}]),
getFloorSpaces: vi.fn().mockResolvedValue([{
id: 'space-cafe',
name: '中庭咖啡厅',
type: 'restaurant',
floorId: 'L1',
center: { x: 47, y: 12, z: 47 }
}])
}))
const [quickFind, keyword] = await Promise.all([
repository.listQuickFindPois('dining', 'L1'),
repository.searchPois('咖啡', 'L1')
])
expect(quickFind).toEqual([
expect.objectContaining({ id: 'space-space-cafe', kind: 'space' })
])
expect(keyword.map((poi) => poi.id)).toEqual(quickFind.map((poi) => poi.id))
})
it('keeps an unlinked commercial POI out of default destinations but exposes it through shopping', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([]),
getFloorBusinessPois: vi.fn().mockResolvedValue([{
id: 'business-commercial',
name: '纪念品商店',
type: 'commercial',
poiGroup: 'BUSINESS',
businessType: 'commercial',
floorId: 'L1',
position: { x: 30, y: 12, z: 30 }
}]),
getFloorSpaces: vi.fn().mockResolvedValue([])
}))
await expect(repository.listDestinationPois('L1')).resolves.toEqual([])
await expect(repository.listQuickFindPois('shopping', 'L1')).resolves.toMatchObject([
{ id: 'business-commercial', kind: 'facility' }
])
})
it('keeps an unlinked restaurant POI out of default destinations but exposes it through dining and keyword search', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([{
id: 'poi-restaurant',
name: '咖啡餐饮点',
type: 'restaurant',
floorId: 'L1',
position: { x: 31, y: 12, z: 31 }
}]),
getFloorBusinessPois: vi.fn().mockResolvedValue([]),
getFloorSpaces: vi.fn().mockResolvedValue([])
}))
await expect(repository.listDestinationPois('L1')).resolves.toEqual([])
await expect(repository.listQuickFindPois('dining', 'L1')).resolves.toMatchObject([
{ id: 'poi-restaurant', kind: 'facility' }
])
await expect(repository.searchPois('咖啡', 'L1')).resolves.toMatchObject([
{ id: 'poi-restaurant' }
])
})
it('falls back to static hall destinations when no independent space source exists', async () => {
const provider = {
baseUrl: '',
loadPoiIndex: vi.fn().mockResolvedValue([
{
id: 'static-hall',
name: '地球展厅',
floorId: 'L1',
primaryCategory: 'touring_poi',
primaryCategoryZh: '展厅',
iconType: 'exhibition_hall',
positionGltf: position(1)
},
{
id: 'static-restroom',
name: '男卫生间',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '服务设施',
iconType: 'toilet',
positionGltf: position(2)
},
{
id: 'static-service-desk',
name: '服务台',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '基础服务设施',
iconType: 'restroom',
positionGltf: position(3)
},
{
id: 'static-tea-room',
name: '茶水间',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '基础服务设施',
iconType: 'restroom',
positionGltf: position(4)
}
])
} as unknown as StaticNavAssetsProvider
const repository = new StaticGuideRepository(provider)
await expect(repository.listDestinationPois('L1')).resolves.toMatchObject([
{ id: 'static-hall', kind: 'hall' }
])
await expect(repository.listQuickFindPois('restroom', 'L1')).resolves.toMatchObject([
{ id: 'static-restroom' }
])
await expect(repository.searchPois('洗手间', 'L1')).resolves.toMatchObject([
{ id: 'static-restroom' }
])
await expect(repository.searchPois('服务', 'L1')).resolves.toEqual([
expect.objectContaining({ id: 'static-service-desk' })
])
await expect(repository.listQuickFindPois('service-center', 'L1')).resolves.toMatchObject([
{ id: 'static-service-desk' }
])
await expect(repository.getQuickFindCategoryAvailability('L1')).resolves.toEqual(expect.arrayContaining([
{ id: 'service-center', count: 1, disabled: false },
{ id: 'restroom', count: 1, disabled: false }
]))
})
it('keeps static guide records out of the model render pool', async () => {
const provider = {
loadFloorIndex: vi.fn().mockResolvedValue({
floors: [{ floorId: 'L1', poiDataAsset: 'poi_by_floor/L1.json' }]
}),
loadFloorPois: vi.fn().mockResolvedValue([
{
id: 'static-hall',
name: '地球展厅',
floorId: 'L1',
primaryCategory: 'touring_poi',
primaryCategoryZh: '展厅',
iconType: 'exhibition_hall',
positionGltf: position(1)
},
{
id: 'static-guide-point',
name: '讲解点',
floorId: 'L1',
primaryCategory: 'operation_experience',
primaryCategoryZh: '讲解点',
iconType: 'guide',
positionGltf: position(2)
}
])
} as unknown as StaticNavAssetsProvider
const renderPois = await new StaticGuideModelRepository(provider).loadFloorPois('L1')
expect(renderPois.map((poi) => poi.id)).toEqual(['static-hall'])
})
})

View File

@@ -0,0 +1,194 @@
import { describe, expect, it, vi } from 'vitest'
import type { MuseumFloor, MuseumPoi } from '@/domain/museum'
import type { PoiCategoryId } from '@/domain/poiCategories'
import type { GuideRepository } from '@/repositories/GuideRepository'
import type { GuideModelRepository } from '@/repositories/GuideModelRepository'
import { GuideUseCase } from '@/usecases/guideUseCase'
const floors: MuseumFloor[] = [
{ id: 'L1', label: '1F', order: 1 },
{ id: 'L2', label: '2F', order: 2 }
]
const quickCategoryIds: readonly PoiCategoryId[] = [
'exhibition-hall',
'cinema',
'ticket-office',
'dining',
'shopping',
'service-center',
'restroom',
'nursing-room',
'elevator',
'escalator'
]
const createPoi = (id: string, floorId: string, categoryId: string): MuseumPoi => ({
id,
name: id,
floorId,
floorLabel: floorId === 'L2' ? '2F' : '1F',
primaryCategory: {
id: categoryId,
label: categoryId,
iconType: categoryId
},
categories: [],
accessible: true
})
const destinationsByFloor: Record<string, MuseumPoi[]> = {
L1: [createPoi('space-l1-hall', 'L1', 'exhibition_hall')],
L2: [createPoi('space-l2-hall', 'L2', 'exhibition_hall')]
}
const quickResultsByFloor: Record<string, Partial<Record<PoiCategoryId, MuseumPoi[]>>> = {
L1: {
restroom: [createPoi('facility-l1-restroom', 'L1', 'toilet')]
},
L2: {
restroom: [createPoi('facility-l2-restroom', 'L2', 'toilet')]
}
}
const keywordResultsByFloor: Record<string, MuseumPoi[]> = {
L1: [createPoi('facility-l1-shop', 'L1', 'shop')],
L2: [createPoi('facility-l2-shop', 'L2', 'shop')]
}
const availability = quickCategoryIds.map((id, index) => ({
id,
count: index === quickCategoryIds.indexOf('restroom') ? 1 : 0,
disabled: id !== 'restroom'
}))
const createRepository = () => ({
getAssetBaseUrl: vi.fn(() => ''),
getFloors: vi.fn().mockResolvedValue(floors),
normalizeFloorId: vi.fn((floorId: string) => ({
'1F': 'L1',
'2F': 'L2'
}[floorId] || floorId)),
listPois: vi.fn().mockResolvedValue([]),
listSpacePoints: vi.fn().mockResolvedValue([]),
listDestinationPois: vi.fn((floorId?: string) => (
Promise.resolve(destinationsByFloor[floorId || 'L1'] || [])
)),
listQuickFindPois: vi.fn((categoryId: PoiCategoryId, floorId?: string) => (
Promise.resolve(quickResultsByFloor[floorId || 'L1']?.[categoryId] || [])
)),
getQuickFindCategoryAvailability: vi.fn().mockResolvedValue(availability),
getPoiById: vi.fn().mockResolvedValue(null),
searchPois: vi.fn((keyword?: string, floorId?: string) => (
Promise.resolve(keyword ? keywordResultsByFloor[floorId || 'L1'] || [] : [])
)),
getLocationPreview: vi.fn().mockResolvedValue(null),
getRouteReadiness: vi.fn().mockResolvedValue({})
})
const expectMapListParity = (state: { results: MuseumPoi[], visiblePoiIds: string[] }) => {
expect(state.visiblePoiIds).toEqual(state.results.map((poi) => poi.id))
}
describe('GuideUseCase point search state', () => {
it('loads initial destinations through listDestinationPois and never searchPois', async () => {
const repository = createRepository()
const useCase = new GuideUseCase(
repository as unknown as GuideRepository,
{} as GuideModelRepository
)
await expect(useCase.getInitialSearchSpacePoints('L2'))
.resolves.toEqual(destinationsByFloor.L2)
expect(repository.listDestinationPois).toHaveBeenCalledWith('L2')
expect(repository.searchPois).not.toHaveBeenCalled()
})
it('creates the default state from current-floor destinations and relays all ten availability entries', async () => {
const repository = createRepository()
const useCase = new GuideUseCase(
repository as unknown as GuideRepository,
{} as GuideModelRepository
)
const state = await useCase.createInitialPoiSearchState('L1')
expect(state).toMatchObject({
mode: 'default',
floorId: 'L1',
floorLabel: '1F',
keyword: '',
categoryId: ''
})
expect(state.results).toEqual(destinationsByFloor.L1)
expectMapListParity(state)
expect(state.categories.map((item) => ({
id: item.definition.id,
count: item.count,
disabled: item.disabled
}))).toEqual(availability)
expect(repository.getQuickFindCategoryAvailability).toHaveBeenCalledWith('L1')
expect(repository.listDestinationPois).toHaveBeenCalledWith('L1')
expect(repository.searchPois).not.toHaveBeenCalled()
})
it('keeps category, keyword, clear, and floor-change states on canonical result IDs', async () => {
const repository = createRepository()
const useCase = new GuideUseCase(
repository as unknown as GuideRepository,
{} as GuideModelRepository
)
const initial = await useCase.createInitialPoiSearchState('L1')
const category = await useCase.selectPoiSearchCategory(initial, 'restroom')
expect(category).toMatchObject({
mode: 'category',
floorId: 'L1',
categoryId: 'restroom'
})
expect(category.results).toEqual(quickResultsByFloor.L1.restroom)
expectMapListParity(category)
expect(repository.listQuickFindPois).toHaveBeenCalledWith('restroom', 'L1')
expect(repository.searchPois).not.toHaveBeenCalled()
const keyword = await useCase.searchPoiKeyword(category, 'shop')
expect(keyword).toMatchObject({
mode: 'keyword',
floorId: 'L1',
keyword: 'shop',
categoryId: ''
})
expect(keyword.results).toEqual(keywordResultsByFloor.L1)
expectMapListParity(keyword)
expect(repository.searchPois).toHaveBeenLastCalledWith('shop', 'L1')
const switchedFloor = await useCase.changePoiSearchFloor(keyword, 'L2')
expect(switchedFloor).toMatchObject({
mode: 'keyword',
floorId: 'L2',
floorLabel: '2F',
keyword: 'shop',
categoryId: ''
})
expect(switchedFloor.results).toEqual(keywordResultsByFloor.L2)
expectMapListParity(switchedFloor)
expect(repository.searchPois).toHaveBeenLastCalledWith('shop', 'L2')
const cleared = await useCase.clearPoiSearch(switchedFloor)
expect(cleared).toMatchObject({
mode: 'default',
floorId: 'L2',
floorLabel: '2F',
keyword: '',
categoryId: ''
})
expect(cleared.results).toEqual(destinationsByFloor.L2)
expectMapListParity(cleared)
expect(repository.listDestinationPois).toHaveBeenLastCalledWith('L2')
})
})

View File

@@ -373,6 +373,19 @@ describe('首页搜索与地图闭环', () => {
expect(map.props('activeFloor')).toBe('L2')
expect(map.props('indoorView')).toBe('floor')
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
search.vm.$emit('results-change', {
...categoryContext,
floorId: 'L1',
floorLabel: '1F',
visiblePoiIds: [],
active: true,
pending: true
})
await wrapper.vm.$nextTick()
expect(map.props('activeFloor')).toBe('L2')
expect(map.props('visiblePoiIds')).toEqual([])
})
it('楼层数据按高到低返回时,首次进入仍将 1F 作为自动缩放的默认目标', async () => {

View File

@@ -4,11 +4,20 @@ 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'
import type { GuidePoiSearchViewState } from '@/domain/poiSearch'
import {
HOME_POI_CATEGORIES,
POI_CATEGORIES,
type PoiCategoryId
} from '@/domain/poiCategories'
const guideMocks = vi.hoisted(() => ({
getFloors: vi.fn(),
getInitialSearchSpacePoints: vi.fn(),
searchPois: vi.fn()
createInitialPoiSearchState: vi.fn(),
selectPoiSearchCategory: vi.fn(),
searchPoiKeyword: vi.fn(),
clearPoiSearch: vi.fn(),
changePoiSearchFloor: vi.fn()
}))
const hostEnvironmentMocks = vi.hoisted(() => ({
@@ -33,56 +42,184 @@ const createPoi = (
name: string,
floorId: string,
floorLabel: string,
iconType: string,
positionGltf?: [number, number, number]
categoryId: string,
positionGltf: [number, number, number]
): MuseumPoi => ({
id,
name,
floorId,
floorLabel,
primaryCategory: {
id: iconType,
label: name.includes('卫生间') ? '卫生间' : name.includes('母婴') ? '母婴室' : '展厅',
iconType
id: categoryId,
label: categoryId,
iconType: categoryId
},
categories: [],
positionGltf,
accessible: true
})
const poiPool: MuseumPoi[] = [
const destinationFloor1 = [
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])
createPoi('space-1', '生态展区', 'floor-1', '1F', 'space_destination', [2, 0, 2])
]
const destinationFloor2 = [
createPoi('hall-2', '恐龙厅', 'floor-2', '2F', 'exhibition_hall', [3, 0, 3])
]
const restroomFloor1 = [
createPoi('restroom-1', '洗手间 A', 'floor-1', '1F', 'toilet', [4, 0, 4])
]
const restroomFloor2 = [
createPoi('restroom-2', '洗手间 B', 'floor-2', '2F', 'toilet', [5, 0, 5])
]
const keywordFloor1 = [
createPoi('dinosaur-1', '恐龙化石', 'floor-1', '1F', 'exhibition_hall', [6, 0, 6]),
createPoi('dinosaur-shop-1', '恐龙文创店', 'floor-1', '1F', 'shop', [7, 0, 7])
]
type SearchStateOptions = {
mode?: GuidePoiSearchViewState['mode']
floorId?: string
keyword?: string
categoryId?: PoiCategoryId | ''
results?: MuseumPoi[]
disabledCategoryIds?: PoiCategoryId[]
}
const floorFor = (floorId = 'floor-1') => (
floors.find((floor) => floor.id === floorId) || floors[0]
)
const createSearchState = ({
mode = 'default',
floorId = 'floor-1',
keyword = '',
categoryId = '',
results,
disabledCategoryIds = []
}: SearchStateOptions = {}): GuidePoiSearchViewState => {
const floor = floorFor(floorId)
const resolvedResults = results || (floor.id === 'floor-2' ? destinationFloor2 : destinationFloor1)
const disabled = new Set(disabledCategoryIds)
return {
mode,
floorId: floor.id,
floorLabel: floor.label,
keyword,
categoryId,
results: resolvedResults,
visiblePoiIds: resolvedResults.map((poi) => poi.id),
categories: POI_CATEGORIES.map((definition) => ({
definition,
count: disabled.has(definition.id)
? 0
: definition.id === categoryId
? resolvedResults.length
: 1,
disabled: disabled.has(definition.id)
}))
}
}
const createDeferred = <T>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
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
})
const renderedResultIds = (wrapper: ReturnType<typeof mount>) => (
wrapper.findAll('.result-row').map((row) => (
row.attributes('data-testid').replace('poi-result-', '')
))
)
return { promise, resolve, reject }
const lastResultState = (wrapper: ReturnType<typeof mount>) => (
(wrapper.emitted('results-change') || []).at(-1)?.[0] as {
visiblePoiIds: string[]
active: boolean
floorId: string
categoryId: string
keyword: string
pending?: boolean
}
)
beforeEach(() => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = false
guideMocks.getFloors.mockReset()
guideMocks.createInitialPoiSearchState.mockReset()
guideMocks.selectPoiSearchCategory.mockReset()
guideMocks.searchPoiKeyword.mockReset()
guideMocks.clearPoiSearch.mockReset()
guideMocks.changePoiSearchFloor.mockReset()
guideMocks.getFloors.mockResolvedValue(floors)
guideMocks.getInitialSearchSpacePoints.mockResolvedValue(poiPool)
guideMocks.searchPois.mockImplementation(async (keyword = '') => (
keyword
? poiPool.filter((poi) => poi.name.includes(keyword))
: poiPool
guideMocks.createInitialPoiSearchState.mockImplementation(async (floorId?: string) => (
createSearchState({ floorId })
))
guideMocks.selectPoiSearchCategory.mockImplementation(async (
_state: GuidePoiSearchViewState | null,
categoryId: PoiCategoryId,
floorId?: string
) => {
const isFloor2 = floorFor(floorId).id === 'floor-2'
const results = categoryId === 'restroom'
? isFloor2 ? restroomFloor2 : restroomFloor1
: []
return createSearchState({
mode: 'category',
floorId,
categoryId,
keyword: POI_CATEGORIES.find((category) => category.id === categoryId)?.label || '',
results
})
})
guideMocks.searchPoiKeyword.mockImplementation(async (
_state: GuidePoiSearchViewState | null,
keyword: string,
floorId?: string
) => createSearchState({
mode: 'keyword',
floorId,
keyword,
results: floorFor(floorId).id === 'floor-2' ? destinationFloor2 : keywordFloor1
}))
guideMocks.clearPoiSearch.mockImplementation(async (
_state: GuidePoiSearchViewState | null,
floorId?: string
) => createSearchState({ floorId }))
guideMocks.changePoiSearchFloor.mockImplementation(async (
state: GuidePoiSearchViewState | null,
floorId: string
) => {
if (state?.mode === 'category') {
return createSearchState({
mode: 'category',
floorId,
categoryId: state.categoryId,
keyword: state.keyword,
results: state.categoryId === 'restroom' ? restroomFloor2 : []
})
}
if (state?.mode === 'keyword') {
return createSearchState({
mode: 'keyword',
floorId,
keyword: state.keyword,
results: destinationFloor2
})
}
return createSearchState({ floorId })
})
vi.stubGlobal('uni', {
hideKeyboard: vi.fn(),
navigateTo: vi.fn(),
@@ -96,101 +233,72 @@ afterEach(() => {
vi.unstubAllGlobals()
})
describe('POI 搜索面板实际渲染与状态闭环', () => {
it('不渲染尚未支持的语音搜索入口', () => {
describe('PoiSearchPanel 搜索状态契约', () => {
it('固定展示 10 类快捷查找,并为收起和展开区域提供 aria-label', async () => {
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
const expectedLabels = ['展厅', '影院', '售票处', '餐饮', '购物', '服务', '洗手间', '母婴', '电梯', '扶梯']
const collapsedCategories = wrapper.findAll('.home-category-chip')
expect(collapsedCategories).toHaveLength(10)
expect(collapsedCategories.map((item) => item.text()).sort()).toEqual([...expectedLabels].sort())
expect(wrapper.get('.home-category-strip').attributes('aria-label')).toBe('快捷查找')
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
expect(wrapper.findAll('.home-shortcut-grid .category-item')).toHaveLength(10)
expect(wrapper.get('.category-scroll').attributes('aria-label')).toBe('快捷查找')
expect(HOME_POI_CATEGORIES).toHaveLength(10)
wrapper.unmount()
})
it('无结果分类具有禁用态和 aria-disabled点击不会发起分类查询', async () => {
guideMocks.createInitialPoiSearchState.mockResolvedValue(createSearchState({
disabledCategoryIds: ['cinema']
}))
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
const cinema = wrapper.get('[data-testid="poi-category-cinema"]')
expect(cinema.classes()).toContain('disabled')
expect(cinema.attributes('aria-disabled')).toBe('true')
await cinema.trigger('tap')
await flushMountedSearch()
expect(guideMocks.selectPoiSearchCategory).not.toHaveBeenCalled()
expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(false)
wrapper.unmount()
})
it('默认列表只消费 createInitialPoiSearchState 返回的当前楼层空间目的地', async () => {
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
expect(wrapper.find('.voice-icon').exists()).toBe(false)
})
it('首页收缩和展开复用同一份九项快捷入口,搜索页保留完整分类', async () => {
const escalatorPoi = createPoi('escalator-1', '自动扶梯', 'floor-1', '1F', 'escalator', [5, 0, 5])
guideMocks.getInitialSearchSpacePoints.mockResolvedValue([...poiPool, escalatorPoi])
const homeWrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
const homeCategories = homeWrapper.findAll('.home-category-chip')
expect(homeCategories).toHaveLength(9)
expect(homeCategories.map((item) => item.text())).toEqual([
'展览', '影院', '洗手间', '餐厅', '售票处',
'母婴室', '服务中心', '电梯', '扶梯'
])
expect(homeCategories.slice(0, 5).map((item) => item.text())).toEqual([
'展览', '影院', '洗手间', '餐厅', '售票处'
])
homeCategories.forEach((item) => {
expect(item.find('.poi-category-icon').exists()).toBe(true)
expect(item.find('.home-category-label').exists()).toBe(true)
})
await homeWrapper.get('[data-testid="poi-category-escalator"]').trigger('tap')
await flushMountedSearch()
expect(homeWrapper.find('[data-testid="poi-result-escalator-1"]').exists()).toBe(true)
await homeWrapper.get('[data-testid="poi-category-cancel"]').trigger('tap')
await flushMountedSearch()
await homeWrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
const expandedCategories = homeWrapper.findAll('.home-shortcut-grid .category-item')
expect(expandedCategories).toHaveLength(9)
expect(expandedCategories.map((item) => item.text())).toEqual([
'展览', '影院', '洗手间', '餐厅', '售票处',
'母婴室', '服务中心', '电梯', '扶梯'
])
expect(expandedCategories.slice(0, 5).map((item) => item.text()))
.toEqual(homeCategories.slice(0, 5).map((item) => item.text()))
homeWrapper.unmount()
const pageWrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
expect(pageWrapper.findAll('.category-item')).toHaveLength(12)
expect(pageWrapper.text()).toContain('影院')
expect(pageWrapper.text()).toContain('服务中心')
expect(pageWrapper.text()).toContain('文创')
expect(pageWrapper.text()).not.toContain('服务设施')
pageWrapper.unmount()
})
it('普通 H5 展开时保留内部标题与返回,并可分类和收起', async () => {
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
expect(wrapper.get('.home-fullscreen-nav').text()).toContain('点位搜索')
expect(wrapper.find('[data-testid="poi-search-cancel"]').exists()).toBe(true)
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(true)
await wrapper.get('[data-testid="poi-category-cancel"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.home-category-strip').exists()).toBe(true)
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
await wrapper.get('[data-testid="poi-search-cancel"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.home-category-strip').exists()).toBe(true)
expect(guideMocks.createInitialPoiSearchState).toHaveBeenCalledWith('floor-1')
expect(guideMocks.selectPoiSearchCategory).not.toHaveBeenCalled()
expect(guideMocks.searchPoiKeyword).not.toHaveBeenCalled()
expect(guideMocks.clearPoiSearch).not.toHaveBeenCalled()
expect(guideMocks.changePoiSearchFloor).not.toHaveBeenCalled()
expect(renderedResultIds(wrapper)).toEqual(['hall-1', 'space-1'])
expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(false)
wrapper.unmount()
})
it('内嵌微信小程序展开时不渲染内部标题与返回,并可分类和收起', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
it('快捷分类调用 selectPoiSearchCategory列表 ID 与 results-change visiblePoiIds 完全一致', async () => {
const initialState = createSearchState()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
@@ -200,61 +308,90 @@ describe('POI 搜索面板实际渲染与状态闭环', () => {
})
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.home-fullscreen-nav').exists()).toBe(false)
expect(wrapper.find('[data-testid="poi-search-cancel"]').exists()).toBe(false)
expect(wrapper.get('.poi-search-panel').element.firstElementChild?.classList.contains('search-box'))
.toBe(true)
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(true)
await wrapper.get('[data-testid="poi-category-cancel"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.home-category-strip').exists()).toBe(true)
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
await wrapper.get('.home-collapse-handle').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.home-category-strip').exists()).toBe(true)
wrapper.unmount()
})
it('展开态快捷入口退出全屏并进入当前楼层的分类结果', async () => {
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.poi-search-content').exists()).toBe(true)
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('.poi-search-content').exists()).toBe(false)
expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(true)
expect(wrapper.get('[data-testid="poi-home-category-results"]').text())
.toContain('当前楼层 1F · 2 个点位')
expect(uni.hideKeyboard).toHaveBeenCalled()
expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({
expect(guideMocks.selectPoiSearchCategory).toHaveBeenCalledWith(initialState, 'restroom', 'floor-1')
expect(renderedResultIds(wrapper)).toEqual(['restroom-1'])
expect(lastResultState(wrapper)).toMatchObject({
active: true,
categoryId: 'restroom',
floorId: 'floor-1',
categoryId: 'restroom',
visiblePoiIds: ['restroom-1']
})
expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper))
wrapper.unmount()
})
it('关键词确认命中分类时仍保留全屏搜索结果', async () => {
it('首页快捷分类请求期间清空列表并下发 pending 空地图 ID', async () => {
const initialState = createSearchState()
const deferredCategory = createDeferred<GuidePoiSearchViewState>()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
guideMocks.selectPoiSearchCategory.mockReturnValueOnce(deferredCategory.promise)
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(renderedResultIds(wrapper)).toEqual([])
expect(lastResultState(wrapper)).toMatchObject({
active: true,
pending: true,
visiblePoiIds: []
})
deferredCategory.resolve(createSearchState({
mode: 'category',
categoryId: 'restroom',
keyword: '洗手间',
results: restroomFloor1
}))
await flushMountedSearch()
expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper))
wrapper.unmount()
})
it('关键词确认调用 searchPoiKeyword并把关键词结果作为地图可见 ID', async () => {
const initialState = createSearchState()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await wrapper.get('.search-input').trigger('confirm', {
detail: { value: '恐龙' }
})
await flushMountedSearch()
expect(guideMocks.searchPoiKeyword).toHaveBeenCalledWith(initialState, '恐龙', 'floor-1')
expect(renderedResultIds(wrapper)).toEqual(['dinosaur-1', 'dinosaur-shop-1'])
expect(lastResultState(wrapper)).toMatchObject({
active: true,
keyword: '恐龙',
categoryId: '',
visiblePoiIds: ['dinosaur-1', 'dinosaur-shop-1']
})
expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper))
wrapper.unmount()
})
it('输入恰好为快捷分类名称时仍走统一关键词查询', async () => {
const initialState = createSearchState()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
@@ -270,90 +407,21 @@ describe('POI 搜索面板实际渲染与状态闭环', () => {
})
await flushMountedSearch()
expect(wrapper.find('.poi-search-content').exists()).toBe(true)
expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(false)
wrapper.unmount()
})
it('详情返回专用复位恢复默认快捷入口并清空搜索状态', 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()
wrapper.get('[data-testid="poi-result-list"]').element.scrollTop = 126
await (wrapper.vm as unknown as {
returnToCollapsedAfterDetail: () => Promise<void>
}).returnToCollapsedAfterDetail()
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(false)
expect(wrapper.find('.poi-search-content').exists()).toBe(false)
expect(wrapper.findAll('.home-category-chip')).toHaveLength(9)
expect(wrapper.findAll('.home-category-chip.active')).toHaveLength(0)
expect((wrapper.get('.search-input').element as HTMLInputElement).value).toBe('')
expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({
active: false,
keyword: '',
categoryId: '',
visiblePoiIds: [],
listScrollTop: 0
})
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()
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({
expect(guideMocks.searchPoiKeyword).toHaveBeenCalledWith(initialState, '洗手间', 'floor-1')
expect(guideMocks.selectPoiSearchCategory).not.toHaveBeenCalled()
expect(lastResultState(wrapper)).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']
keyword: '洗手间',
categoryId: ''
})
wrapper.unmount()
})
it('首页分类跟随当前地图楼层更新结果与 marker ID并对无结果给出反馈', async () => {
it('首页关键词请求期间清空列表并下发 pending 空地图 ID', async () => {
const initialState = createSearchState()
const deferredKeyword = createDeferred<GuidePoiSearchViewState>()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
guideMocks.searchPoiKeyword.mockReturnValueOnce(deferredKeyword.promise)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
@@ -363,186 +431,85 @@ describe('POI 搜索面板实际渲染与状态闭环', () => {
})
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('.search-box').trigger('tap')
await wrapper.get('.search-input').trigger('confirm', {
detail: { value: '恐龙' }
})
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({
expect(renderedResultIds(wrapper)).toEqual([])
expect(lastResultState(wrapper)).toMatchObject({
active: true,
pending: true,
visiblePoiIds: []
})
deferredKeyword.resolve(createSearchState({
mode: 'keyword',
keyword: '恐龙',
results: keywordFloor1
}))
await flushMountedSearch()
expect(lastResultState(wrapper).visiblePoiIds).toEqual(renderedResultIds(wrapper))
wrapper.unmount()
})
it('独立搜索页切换楼层调用 changePoiSearchFloor 并渲染新楼层当前模式结果', async () => {
const initialState = createSearchState()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
const categoryState = guideMocks.selectPoiSearchCategory.mock.results[0]?.value
const floor2 = wrapper.findAll('.floor-tab').find((item) => item.text() === '2F')
expect(floor2).toBeDefined()
await floor2!.trigger('tap')
await flushMountedSearch()
expect(guideMocks.changePoiSearchFloor).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'category', categoryId: 'restroom', floorId: 'floor-1' }),
'floor-2'
)
expect(renderedResultIds(wrapper)).toEqual(['restroom-2'])
expect(categoryState).toBeDefined()
wrapper.unmount()
})
it('清除关键词调用 clearPoiSearch 并恢复默认空间目的地列表', async () => {
const initialState = createSearchState()
guideMocks.createInitialPoiSearchState.mockResolvedValue(initialState)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await wrapper.get('.search-input').trigger('confirm', {
detail: { value: '恐龙' }
})
await flushMountedSearch()
const clearButton = wrapper.get('.clear-button')
await clearButton.trigger('tap')
await flushMountedSearch()
expect(guideMocks.clearPoiSearch).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'keyword', keyword: '恐龙' }),
'floor-1'
)
expect(renderedResultIds(wrapper)).toEqual(['hall-1', 'space-1'])
expect(lastResultState(wrapper)).toMatchObject({
active: false,
categoryId: '',
keyword: '',
visiblePoiIds: []
})
wrapper.unmount()
})
it('独立搜索页进入详情时传递关键词、分类、楼层、结果数和列表位置', async () => {
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
const floor2Tab = wrapper.findAll('.floor-tab').find((item) => item.text() === '2F')
expect(floor2Tab).toBeDefined()
await floor2Tab!.trigger('tap')
await flushMountedSearch()
wrapper.get('[data-testid="poi-result-list"]').element.scrollTop = 128
await wrapper.get('[data-testid="poi-result-restroom-2"]').trigger('tap')
expect(uni.navigateTo).toHaveBeenCalledTimes(1)
const url = vi.mocked(uni.navigateTo).mock.calls[0][0].url
const query = new URLSearchParams(url.split('?')[1])
expect(query.get('source')).toBe('search')
expect(query.get('searchOrigin')).toBe('page')
expect(query.get('searchKeyword')).toBe('洗手间')
expect(query.get('searchCategoryId')).toBe('restroom')
expect(query.get('searchFloorId')).toBe('floor-2')
expect(query.get('resultCount')).toBe('3')
expect(query.get('floorResultCount')).toBe('1')
expect(query.get('visiblePoiIds')).toBe('restroom-2')
expect(query.get('listScrollTop')).toBe('128')
wrapper.unmount()
})
it('页面重新显示时恢复原结果列表位置', async () => {
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
const resultList = wrapper.get('[data-testid="poi-result-list"]')
resultList.element.scrollTop = 128
await wrapper.get('[data-testid="poi-result-restroom-1"]').trigger('tap')
resultList.element.scrollTop = 0
await (wrapper.vm as unknown as {
restoreResultListScroll: () => Promise<void>
}).restoreResultListScroll()
expect(resultList.element.scrollTop).toBe(128)
wrapper.unmount()
})
it('部分数据不会固化,分类点击会重新读取并恢复结果', async () => {
guideMocks.getInitialSearchSpacePoints
.mockResolvedValueOnce([poiPool[0]])
.mockResolvedValueOnce(poiPool)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(guideMocks.getInitialSearchSpacePoints).toHaveBeenCalledTimes(2)
expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true)
wrapper.unmount()
})
it('旧的部分结果晚到时不会覆盖最新完整缓存', async () => {
const oldRequest = createDeferred<MuseumPoi[]>()
const latestRequest = createDeferred<MuseumPoi[]>()
guideMocks.getInitialSearchSpacePoints
.mockImplementationOnce(() => oldRequest.promise)
.mockImplementationOnce(() => latestRequest.promise)
const wrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
expect(guideMocks.getInitialSearchSpacePoints).toHaveBeenCalledTimes(1)
await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushPromises()
expect(guideMocks.getInitialSearchSpacePoints).toHaveBeenCalledTimes(2)
latestRequest.resolve(poiPool)
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true)
oldRequest.resolve([poiPool[0]])
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-cancel"]').trigger('tap')
await flushMountedSearch()
await wrapper.get('.search-box').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-result-restroom-1"]').exists()).toBe(true)
wrapper.unmount()
})
it('服务中心只展示真实服务台,不把普通服务空间误归类', async () => {
const serviceSpace = createPoi(
'space-service-lounge',
'贵宾休息室',
'floor-1',
'1F',
'service_space',
[5, 0, 5]
)
const serviceDesk = createPoi(
'poi-service-desk',
'咨询服务台',
'floor-1',
'1F',
'service_desk',
[6, 0, 6]
)
guideMocks.getInitialSearchSpacePoints.mockResolvedValue([serviceSpace, serviceDesk])
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
await wrapper.get('[data-testid="poi-category-service-center"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-result-poi-service-desk"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="poi-result-space-service-lounge"]').exists()).toBe(false)
wrapper.unmount()
})
it('弱网失败显示可重试状态,恢复后重新渲染结果', async () => {
guideMocks.getInitialSearchSpacePoints
.mockRejectedValueOnce(new Error('network down'))
.mockResolvedValueOnce(poiPool)
const wrapper = mount(PoiSearchPanel, { props: { variant: 'page' } })
await flushMountedSearch()
expect(wrapper.get('[data-testid="poi-empty"]').text()).toContain('点位加载失败')
expect(wrapper.find('[data-testid="poi-retry"]').exists()).toBe(true)
await wrapper.get('[data-testid="poi-retry"]').trigger('tap')
await flushMountedSearch()
expect(wrapper.find('[data-testid="poi-retry"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="poi-result-hall-1"]').exists()).toBe(true)
wrapper.unmount()
})
})

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { toMuseumPoi } from '@/data/adapters/navAssetsAdapter'
import { resolvePoiCategory } from '@/domain/poiCategories'
import type { StaticNavPoiPayload } from '@/data/providers/staticNavAssetsProvider'
const createStaticPoi = (name: string, iconType = 'elevator'): StaticNavPoiPayload => ({
id: `poi-${name}`,
name,
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '基础服务设施',
iconType,
categories: [{
topCategory: 'basic_service_facility',
topCategoryZh: '基础服务设施',
subcategory: iconType,
iconType
}],
positionGltf: [1, 0, 1]
})
const createTouringPoi = (name: string, iconType = 'exhibition'): StaticNavPoiPayload => ({
...createStaticPoi(name, iconType),
primaryCategory: 'touring_poi',
primaryCategoryZh: '展厅与参观点',
categories: [{
topCategory: 'touring_poi',
topCategoryZh: '展厅与参观点',
subcategory: iconType,
iconType
}]
})
describe('static nav POI adapter', () => {
it('repairs legacy static facility icons from visitor-facing names', () => {
const stairs = toMuseumPoi(createStaticPoi('西侧楼梯'))
const escalator = toMuseumPoi(createStaticPoi('中央扶梯'))
const nursing = toMuseumPoi(createStaticPoi('母婴室', 'toilet'))
const restroom = toMuseumPoi(createStaticPoi('无障碍洗手间', 'elevator'))
expect(stairs.primaryCategory.iconType).toBe('stairs')
expect(resolvePoiCategory(stairs)).toBeNull()
expect(escalator.primaryCategory.iconType).toBe('escalator')
expect(resolvePoiCategory(escalator)?.id).toBe('escalator')
expect(nursing.primaryCategory.iconType).toBe('mother_baby_room')
expect(resolvePoiCategory(nursing)?.id).toBe('nursing-room')
expect(restroom.primaryCategory.iconType).toBe('accessible_toilet')
expect(resolvePoiCategory(restroom)?.id).toBe('restroom')
})
it('repairs static generic restroom icons for service facilities without misclassifying tea rooms', () => {
const serviceDesk = toMuseumPoi(createStaticPoi('服务台', 'restroom'))
const locker = toMuseumPoi(createStaticPoi('存包处', 'restroom'))
const teaRoom = toMuseumPoi(createStaticPoi('茶水间', 'restroom'))
expect(resolvePoiCategory(serviceDesk)?.id).toBe('service-center')
expect(resolvePoiCategory(locker)?.id).toBe('service-center')
expect(resolvePoiCategory(teaRoom)).toBeNull()
})
it('splits generic touring fallback records into their visitor-facing semantic kinds', () => {
const cinema = toMuseumPoi(createTouringPoi('巨幕影院'))
const ticketOffice = toMuseumPoi(createTouringPoi('展厅售票点'))
expect(cinema).toMatchObject({
kind: 'space',
primaryCategory: { id: 'space_theater', iconType: 'theater' }
})
expect(resolvePoiCategory(cinema)?.id).toBe('cinema')
expect(ticketOffice).toMatchObject({
kind: 'facility',
primaryCategory: { id: 'basic_service_facility', iconType: 'ticket_office' }
})
expect(resolvePoiCategory(ticketOffice)?.id).toBe('ticket-office')
})
})