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

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

View File

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