整改点位搜索数据加载与分类逻辑
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -1640,7 +1640,7 @@ const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => {
|
||||
}
|
||||
|
||||
searchVisiblePoiIds.value = [...state.visiblePoiIds]
|
||||
if (state.floorId) {
|
||||
if (state.floorId && !state.pending) {
|
||||
activeGuideFloor.value = state.floorId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
408
tests/unit/GuideRepositorySearch.spec.ts
Normal file
408
tests/unit/GuideRepositorySearch.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
194
tests/unit/GuideUseCase.spec.ts
Normal file
194
tests/unit/GuideUseCase.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
76
tests/unit/navAssetsAdapter.spec.ts
Normal file
76
tests/unit/navAssetsAdapter.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user