Files
frontend-miniapp/src/components/search/PoiSearchPanel.vue

1949 lines
47 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view
class="poi-search-panel"
:class="[`variant-${variant}`, {
expanded: showSearchContent,
'is-collapsed': isHomeCollapsed,
'category-results-open': isHomeCategoryMode
}]"
@touchstart="handlePanelTouchStart"
@touchend="handlePanelTouchEnd"
@touchcancel="resetPanelTouch"
@mousedown="handlePanelMouseStart"
@mousemove="handlePanelMouseMove"
@mouseup="handlePanelMouseEnd"
@mouseleave="resetPanelMouse"
>
<view v-if="showHomeFullscreenNav" class="home-fullscreen-nav">
<view class="home-back-button" data-testid="poi-search-cancel" @tap.stop="handleCancel">
<text class="home-back-icon"></text>
</view>
<text class="home-fullscreen-title">点位搜索</text>
<view class="home-nav-spacer"></view>
</view>
<view class="search-box" @tap="handleSearchBoxTap" @click="handleSearchBoxTap">
<view class="search-icon"></view>
<input
class="search-input"
type="text"
confirm-type="search"
:focus="searchInputFocused"
placeholder="请输入地点进行搜索"
placeholder-class="search-placeholder"
:value="searchDraftKeyword"
@mousedown="handleSearchBoxTap"
@click="handleSearchBoxTap"
@tap="handleSearchBoxTap"
@focus="handleSearchFocus"
@input="handleSearchInput"
@confirm="handleSearchConfirm"
/>
<view v-if="searchDraftKeyword" class="clear-button" @tap.stop="handleSearchClear">
<text class="clear-text">×</text>
</view>
</view>
<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, disabled: isCategoryDisabled(item) }"
:data-testid="`poi-category-${item.id}`"
:aria-disabled="isCategoryDisabled(item)"
@tap="handleFacilityShortcut(item)"
>
<view class="home-category-icon">
<svg class="poi-category-icon" viewBox="0 0 32 32" aria-hidden="true">
<use :href="getPoiCategoryIconHref(item.id)" />
</svg>
</view>
<text class="home-category-label">{{ item.label }}</text>
</view>
</view>
<view
v-if="isHomeCategoryMode"
class="home-category-results"
data-testid="poi-home-category-results"
>
<view class="home-category-results-header">
<view class="home-category-results-copy">
<text class="home-category-results-title">{{ activeCategory?.label || searchKeyword }}</text>
<text class="home-category-results-meta">
当前楼层 {{ activeFloor || '待确认' }} · {{ floorResults.length }} 个点位
</text>
</view>
<view
class="home-category-results-close"
data-testid="poi-category-cancel"
aria-label="取消分类查询"
@tap.stop="handleCancel"
>
<text class="home-category-results-close-text">×</text>
</view>
</view>
<text v-if="dataWarning" class="data-warning">{{ dataWarning }}</text>
<view
ref="resultListRef"
class="result-list home-result-list"
data-testid="poi-result-list"
@scroll="handleResultListScroll"
>
<view
v-for="item in floorResults"
:key="item.id"
class="result-row"
:class="{ disabled: !isPoiLocatable(item) }"
:data-testid="`poi-result-${item.id}`"
:aria-disabled="!isPoiLocatable(item)"
@tap="handleResultTap(item)"
>
<view class="pin-icon"></view>
<view class="result-copy">
<text class="result-name">{{ poiDisplayName(item) }}</text>
<text class="result-meta">{{ poiResultMeta(item) }}</text>
</view>
<text v-if="isPoiLocatable(item)" class="result-arrow"></text>
</view>
<view v-if="!floorResults.length" class="empty-state" data-testid="poi-empty">
<text class="empty-title">{{ emptyTitle }}</text>
<text class="empty-desc">{{ emptyDesc }}</text>
<view
v-if="loadError && !isLoading"
class="retry-button"
data-testid="poi-retry"
@tap.stop="retryCurrentSearch"
>
<text class="retry-button-text">重新加载</text>
</view>
</view>
</view>
</view>
<view v-if="showSearchContent" class="poi-search-content">
<view v-if="variant === 'home'" class="home-collapse-handle" @tap.stop="handleCancel">
<view class="home-collapse-bar"></view>
</view>
<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, disabled: isCategoryDisabled(item) }"
:data-testid="`poi-category-${item.id}`"
:aria-disabled="isCategoryDisabled(item)"
@tap="handleFacilityShortcut(item)"
>
<view class="category-icon-shell">
<svg class="poi-category-icon" viewBox="0 0 32 32" aria-hidden="true">
<use :href="getPoiCategoryIconHref(item.id)" />
</svg>
</view>
<text class="category-label">{{ item.label }}</text>
</view>
</view>
</view>
<view class="result-card">
<view class="result-card-header">
<text class="museum-title">深圳自然博物馆</text>
<text class="result-count">当前楼层 {{ floorResults.length }} 个点位</text>
</view>
<text v-if="dataWarning" class="data-warning result-card-warning">{{ dataWarning }}</text>
<view class="result-body">
<view class="floor-tabs">
<view
v-for="floor in displayFloors"
:key="floor.label"
class="floor-tab"
:class="{ active: activeFloor === floor.label }"
@tap="handleFloorChange(floor.label)"
>
<text class="floor-tab-text">{{ floor.label }}</text>
</view>
</view>
<view
ref="resultListRef"
class="result-list"
data-testid="poi-result-list"
@scroll="handleResultListScroll"
>
<view
v-for="item in floorResults"
:key="item.id"
class="result-row"
:class="{ disabled: !isPoiLocatable(item) }"
:data-testid="`poi-result-${item.id}`"
:aria-disabled="!isPoiLocatable(item)"
@tap="handleResultTap(item)"
>
<view class="pin-icon"></view>
<view class="result-copy">
<text class="result-name">{{ poiDisplayName(item) }}</text>
<text class="result-meta">{{ poiResultMeta(item) }}</text>
</view>
<text v-if="isPoiLocatable(item)" class="result-arrow"></text>
</view>
<view v-if="!floorResults.length" class="empty-state" data-testid="poi-empty">
<text class="empty-title">{{ emptyTitle }}</text>
<text class="empty-desc">{{ emptyDesc }}</text>
<view
v-if="loadError && !isLoading"
class="retry-button"
data-testid="poi-retry"
@tap.stop="retryCurrentSearch"
>
<text class="retry-button-text">重新加载</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import type {
MuseumFloor,
MuseumPoi
} from '@/domain/museum'
import {
compareFloorsTopToBottom,
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
guideUseCase
} from '@/usecases/guideUseCase'
import {
createVisitorPoiPresentations
} from '@/view-models/visitorPoiPresentation'
import {
HOME_POI_CATEGORIES,
POI_CATEGORIES,
getPoiCategoryIconHref,
getPoiCategoryById,
resolvePoiCategory,
type PoiCategoryDefinition,
type PoiCategoryId
} from '@/domain/poiCategories'
import type {
GuidePoiSearchViewState,
PoiCategoryResultState,
PoiSearchContext
} from '@/domain/poiSearch'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
const props = withDefaults(defineProps<{
initialKeyword?: string
variant?: 'home' | 'page'
autofocus?: boolean
currentFloorId?: string
currentFloorLabel?: string
locationPreviewSelection?: boolean
}>(), {
initialKeyword: '',
variant: 'page',
autofocus: false,
currentFloorId: '',
currentFloorLabel: '',
locationPreviewSelection: false
})
const emit = defineEmits<{
resultTap: [poi: MuseumPoi, context: PoiSearchContext]
'expanded-change': [expanded: boolean]
'category-mode-change': [active: boolean]
'results-change': [state: PoiCategoryResultState]
cancel: []
}>()
const homeCategories = HOME_POI_CATEGORIES
const searchCategories = POI_CATEGORIES
const searchCategoryColumns = computed(() => (
props.variant === 'home' ? homeCategories : searchCategories
))
const searchKeyword = ref('')
const searchDraftKeyword = ref('')
const searchInputFocused = ref(false)
const homeExpanded = ref(false)
const homeCategoryMode = ref(false)
const activeCategoryId = ref<PoiCategoryId | ''>('')
const touchStartX = ref(0)
const touchStartY = ref(0)
const isPanelTouching = ref(false)
const isPanelMouseDown = ref(false)
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('')
const excludedPoiCount = ref(0)
const duplicatePoiCount = ref(0)
const resultListScrollTop = ref(0)
const resultListRef = ref<HTMLElement | { $el?: HTMLElement } | null>(null)
let searchRequestSeq = 0
const collapseDragThreshold = 48
const homeExpandTapGuardMs = 360
const showSearchContent = computed(() => props.variant === 'page' || homeExpanded.value)
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const showHomeFullscreenNav = computed(() => (
props.variant === 'home'
&& showSearchContent.value
&& !shouldUseHostNavigation.value
))
const isHomeCategoryMode = computed(() => (
props.variant === 'home' && homeCategoryMode.value && !homeExpanded.value
))
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)
.sort(compareFloorsTopToBottom))
const floorResults = computed(() => (
activeFloor.value
? pois.value.filter((poi) => poi.floorLabel === activeFloor.value)
: []
))
const emptyTitle = computed(() => {
if (isLoading.value) return '正在读取点位'
if (loadError.value) return loadError.value
return '当前楼层暂无匹配点位'
})
const emptyDesc = computed(() => {
if (loadError.value) return '请检查网络后重试,当前搜索条件已保留'
if (props.variant === 'home') return '当前楼层没有此类点位,可更换分类继续查看'
return activeCategory.value
? `可切换楼层查看其他${activeCategory.value.label}点位`
: '可切换楼层或更换关键词'
})
const isPoiLocatable = (poi: MuseumPoi) => Boolean(poi.positionGltf)
// 数据质量统计保留给诊断日志,普通游客只看到可恢复的加载状态。
const dataWarning = computed(() => {
const diagnostic = {
excluded: excludedPoiCount.value,
duplicates: duplicatePoiCount.value,
floorLoadError: floorLoadError.value
}
if (import.meta.env.DEV && Object.values(diagnostic).some(Boolean)) {
console.debug('[guide-poi-diagnostics]', diagnostic)
}
return ''
})
const isCategoryDisabled = (category: PoiCategoryDefinition) => {
const state = categoryStatesById.value.get(category.id)
return isLoading.value
|| !state
|| state.disabled
}
const activeFloorOption = computed(() => (
displayFloors.value.find((floor) => floor.label === activeFloor.value) || null
))
const createSearchContext = (): PoiSearchContext => {
const visiblePoiIds = searchViewState.value?.visiblePoiIds || []
const resultCount = pois.value.length
return {
origin: props.variant,
keyword: searchKeyword.value,
categoryId: activeCategoryId.value,
floorId: searchViewState.value?.floorId || activeFloorOption.value?.id || props.currentFloorId || '',
floorLabel: searchViewState.value?.floorLabel || activeFloor.value || props.currentFloorLabel || '',
resultCount,
floorResultCount: floorResults.value.length,
visiblePoiIds,
listScrollTop: resultListScrollTop.value
}
}
const emitResultsState = () => {
const context = createSearchContext()
const active = props.variant === 'home'
&& (searchViewState.value?.mode === 'category' || searchViewState.value?.mode === 'keyword')
emit('results-change', {
...context,
visiblePoiIds: active ? context.visiblePoiIds : [],
active
})
}
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
homeCategoryMode.value = nextValue
emit('category-mode-change', nextValue)
}
const syncActiveFloor = () => {
if (!displayFloors.value.length) {
activeFloor.value = ''
return
}
const requestedFloor = displayFloors.value.find((floor) => (
floor.id === props.currentFloorId
|| floor.label === props.currentFloorLabel
))
if (props.variant === 'home' && requestedFloor) {
activeFloor.value = requestedFloor.label
return
}
if (displayFloors.value.some((floor) => floor.label === activeFloor.value)) return
activeFloor.value = displayFloors.value.find((floor) => floor.label === '1F')?.label
|| displayFloors.value[0].label
}
const loadFloors = async () => {
floorLoadError.value = ''
try {
floors.value = await guideUseCase.getFloors()
syncActiveFloor()
} catch (error) {
console.error('加载点位搜索楼层失败:', error)
if (!floors.value.length) activeFloor.value = ''
floorLoadError.value = '楼层信息加载失败'
}
}
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 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()
}
const loadInitialSpacePoints = async () => {
const requestSeq = ++searchRequestSeq
isLoading.value = true
searchError.value = ''
try {
const state = await guideUseCase.createInitialPoiSearchState(activeFloorId())
if (requestSeq !== searchRequestSeq) return
await commitPoiSearchViewState(state, requestSeq)
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位基础数据失败:')
} finally {
if (requestSeq === searchRequestSeq) isLoading.value = false
}
}
const loadPois = async (keyword = '') => {
if (!keyword.trim()) {
await loadInitialSpacePoints()
return
}
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 state = await guideUseCase.searchPoiKeyword(
searchViewState.value,
keyword,
activeFloorId()
)
if (requestSeq !== searchRequestSeq) return
await commitPoiSearchViewState(state, requestSeq)
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位搜索结果失败:')
} finally {
if (requestSeq === searchRequestSeq) isLoading.value = false
}
}
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()
searchInputFocused.value = true
}
const expandHomePanel = () => {
if (props.variant !== 'home') return
if (!homeExpanded.value) {
homeContentTapBlockedUntil.value = Date.now() + homeExpandTapGuardMs
}
setHomeCategoryMode(false)
homeExpanded.value = true
}
const shouldBlockHomeContentTap = () => (
props.variant === 'home'
&& homeExpanded.value
&& Date.now() < homeContentTapBlockedUntil.value
)
// #ifdef H5
const homeSearchLockClass = 'guide-home-search-fullscreen-lock'
let removeViewportResizeListener: (() => void) | null = null
const updateHomeSearchViewportHeight = () => {
if (typeof document === 'undefined') return
const viewportHeight = window.visualViewport?.height || window.innerHeight
document.documentElement.style.setProperty('--home-search-viewport-height', `${viewportHeight}px`)
}
const setH5HomeSearchLock = (locked: boolean) => {
if (typeof document === 'undefined') return
document.documentElement.classList.toggle(homeSearchLockClass, locked)
document.body?.classList.toggle(homeSearchLockClass, locked)
if (!locked) {
document.documentElement.style.removeProperty('--home-search-viewport-height')
removeViewportResizeListener?.()
removeViewportResizeListener = null
return
}
updateHomeSearchViewportHeight()
if (!removeViewportResizeListener) {
const target = window.visualViewport || window
target.addEventListener('resize', updateHomeSearchViewportHeight)
removeViewportResizeListener = () => target.removeEventListener('resize', updateHomeSearchViewportHeight)
}
}
// #endif
const collapseHomePanel = () => {
if (props.variant !== 'home') return
homeExpanded.value = false
searchInputFocused.value = false
uni.hideKeyboard()
}
const resetSearchState = async () => {
searchKeyword.value = ''
searchDraftKeyword.value = ''
activeCategoryId.value = ''
resultListScrollTop.value = 0
searchError.value = ''
setHomeCategoryMode(false)
homeExpanded.value = false
searchInputFocused.value = false
if (searchViewState.value?.mode !== 'default') {
pois.value = []
emitPendingHomeSearchResults()
}
await runPoiSearchRequest(() => guideUseCase.clearPoiSearch(
searchViewState.value,
activeFloorId()
))
}
// 从详情页返回时只清理搜索 UI 与分类 marker不参与地图目标焦点的复位。
const returnToCollapsedAfterDetail = async () => {
await resetSearchState()
uni.hideKeyboard()
}
const handleCancel = async () => {
await resetSearchState()
uni.hideKeyboard()
emit('cancel')
}
const resetPanelTouch = () => {
isPanelTouching.value = false
touchStartX.value = 0
touchStartY.value = 0
}
const resetPanelMouse = () => {
isPanelMouseDown.value = false
touchStartX.value = 0
touchStartY.value = 0
}
const getGesturePoint = (event: any) => {
const touchPoint = event.changedTouches?.[0] || event.touches?.[0]
if (touchPoint) return touchPoint
if (typeof event.clientX === 'number' && typeof event.clientY === 'number') return event
return null
}
const handlePanelTouchStart = (event: any) => {
if (props.variant !== 'home' || !homeExpanded.value) return
const point = getGesturePoint(event)
if (!point) return
touchStartX.value = point.clientX
touchStartY.value = point.clientY
isPanelTouching.value = true
}
const handlePanelTouchEnd = (event: any) => {
if (!isPanelTouching.value || props.variant !== 'home' || !homeExpanded.value) {
resetPanelTouch()
return
}
const point = getGesturePoint(event)
if (point) {
const deltaX = point.clientX - touchStartX.value
const deltaY = point.clientY - touchStartY.value
if (deltaY > collapseDragThreshold && deltaY > Math.abs(deltaX) * 1.2) collapseHomePanel()
}
resetPanelTouch()
}
const handlePanelMouseStart = (event: any) => {
if (props.variant !== 'home' || !homeExpanded.value) return
const point = getGesturePoint(event)
if (!point) return
touchStartX.value = point.clientX
touchStartY.value = point.clientY
isPanelMouseDown.value = true
}
const handlePanelMouseMove = (event: any) => {
if (!isPanelMouseDown.value || props.variant !== 'home' || !homeExpanded.value) return
const point = getGesturePoint(event)
if (!point) return
const deltaX = point.clientX - touchStartX.value
const deltaY = point.clientY - touchStartY.value
if (deltaY > collapseDragThreshold && deltaY > Math.abs(deltaX) * 1.2) {
collapseHomePanel()
resetPanelMouse()
}
}
const handlePanelMouseEnd = (event: any) => {
if (!isPanelMouseDown.value || props.variant !== 'home' || !homeExpanded.value) {
resetPanelMouse()
return
}
const point = getGesturePoint(event)
if (point) {
const deltaX = point.clientX - touchStartX.value
const deltaY = point.clientY - touchStartY.value
if (deltaY > collapseDragThreshold && deltaY > Math.abs(deltaX) * 1.2) collapseHomePanel()
}
resetPanelMouse()
}
const enterFullSearch = () => {
if (props.variant !== 'home' || homeExpanded.value) return
const cameFromCategory = homeCategoryMode.value
expandHomePanel()
if (cameFromCategory) {
activeCategoryId.value = ''
searchKeyword.value = ''
searchDraftKeyword.value = ''
pois.value = []
emitPendingHomeSearchResults()
void loadInitialSpacePoints()
}
}
const handleSearchFocus = () => enterFullSearch()
const handleSearchBoxTap = () => {
if (props.variant !== 'home' || homeExpanded.value) return
enterFullSearch()
void focusSearchInput()
}
const handleSearchInput = (event: any) => {
searchDraftKeyword.value = event.detail.value
enterFullSearch()
}
const handleSearchClear = async () => {
searchDraftKeyword.value = ''
searchKeyword.value = ''
activeCategoryId.value = ''
setHomeCategoryMode(false)
if (searchViewState.value?.mode !== 'default') {
pois.value = []
emitPendingHomeSearchResults()
}
await runPoiSearchRequest(() => guideUseCase.clearPoiSearch(
searchViewState.value,
activeFloorId()
))
await focusSearchInput()
}
type SearchShortcutOptions = {
homeCategoryResults?: boolean
}
const searchShortcut = async (
categoryInput: PoiCategoryDefinition | PoiCategoryId,
options: SearchShortcutOptions = {}
) => {
const category = typeof categoryInput === 'string'
? getPoiCategoryById(categoryInput)
: categoryInput
if (!category || isCategoryDisabled(category)) return
const requestSeq = ++searchRequestSeq
const useHomeResultList = props.variant === 'home'
&& (options.homeCategoryResults ?? !homeExpanded.value)
if (useHomeResultList) {
homeExpanded.value = false
searchInputFocused.value = false
uni.hideKeyboard()
}
searchKeyword.value = category.label
searchDraftKeyword.value = category.label
activeCategoryId.value = category.id
resultListScrollTop.value = 0
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 state = await guideUseCase.selectPoiSearchCategory(
searchViewState.value,
category.id,
activeFloorId()
)
if (requestSeq !== searchRequestSeq) return
await commitPoiSearchViewState(state, requestSeq)
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位分类搜索结果失败:')
} finally {
if (requestSeq === searchRequestSeq) isLoading.value = false
}
}
const handleFacilityShortcut = (category: PoiCategoryDefinition) => {
void searchShortcut(category, {
// 快捷入口始终进入首页底部分类结果;关键词确认仍保留全屏搜索结果。
homeCategoryResults: props.variant === 'home'
})
}
const handleSearchConfirm = async (event?: any) => {
const value = typeof event?.detail?.value === 'string'
? event.detail.value
: searchDraftKeyword.value
searchKeyword.value = value.trim()
searchDraftKeyword.value = searchKeyword.value
setHomeCategoryMode(false)
if (props.variant === 'home' && !homeExpanded.value) expandHomePanel()
activeCategoryId.value = ''
resultListScrollTop.value = 0
await loadPois(searchKeyword.value)
}
const handleFloorChange = (floor: string) => {
if (activeFloor.value === floor) return
void refreshCurrentPoiSearchFloor(floor)
}
const handleResultListScroll = (event: any) => {
const scrollTop = Number(event?.detail?.scrollTop ?? event?.currentTarget?.scrollTop ?? 0)
resultListScrollTop.value = Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0
}
const getResultListElement = (): HTMLElement | null => {
const target = resultListRef.value
if (!target) return null
if ('$el' in target) return target.$el || null
return target as HTMLElement
}
const captureResultListScroll = () => {
const scrollTop = Number(getResultListElement()?.scrollTop)
if (Number.isFinite(scrollTop)) resultListScrollTop.value = Math.max(0, scrollTop)
}
const restoreResultListScroll = async () => {
const scrollTop = resultListScrollTop.value
if (scrollTop <= 0) return
await nextTick()
const resultListElement = getResultListElement()
if (resultListElement) resultListElement.scrollTop = scrollTop
}
const poiPresentations = computed(() => createVisitorPoiPresentations(pois.value))
const poiDisplayName = (poi: MuseumPoi) => (
poiPresentations.value.get(poi.id)?.displayName || '未命名点位'
)
const poiResultMeta = (poi: MuseumPoi) => {
const categoryLabel = resolvePoiCategory(poi)?.label || poi.primaryCategory?.label || '其他'
return isPoiLocatable(poi) ? categoryLabel : `${categoryLabel} · 暂无地图坐标`
}
const encodeQueryValue = (value: string | number) => encodeURIComponent(String(value))
const createPoiLocationQuery = (poi: MuseumPoi, context: PoiSearchContext) => {
const values: Record<string, string | number> = {
id: poi.id,
target: poiDisplayName(poi),
floorId: poi.floorId,
floorLabel: poi.floorLabel,
source: 'search',
searchOrigin: context.origin,
searchKeyword: context.keyword,
searchCategoryId: context.categoryId,
searchFloorId: context.floorId,
searchFloorLabel: context.floorLabel,
resultCount: context.resultCount,
floorResultCount: context.floorResultCount,
visiblePoiIds: context.visiblePoiIds.join(','),
listScrollTop: context.listScrollTop
}
return Object.entries(values)
.map(([key, value]) => `${key}=${encodeQueryValue(value)}`)
.join('&')
}
const handleResultTap = (poi: MuseumPoi) => {
if (shouldBlockHomeContentTap()) return
if (!isPoiLocatable(poi)) {
uni.showToast({ title: '该点位缺少地图坐标,暂无法定位', icon: 'none' })
return
}
// H5 普通 view 的滚动事件并非始终派发,进入详情前直接读取真实位置。
captureResultListScroll()
const context = createSearchContext()
if (props.variant === 'home' || props.locationPreviewSelection) {
emit('resultTap', poi, context)
return
}
uni.navigateTo({
url: `/pages/facility/detail?${createPoiLocationQuery(poi, context)}`
})
}
const retryCurrentSearch = async () => {
if (floorLoadError.value) await loadFloors()
if (activeCategory.value) {
await searchShortcut(activeCategory.value)
return
}
if (searchKeyword.value) {
await loadPois(searchKeyword.value)
return
}
await loadInitialSpacePoints()
}
const applyInitialKeyword = async (keyword: string) => {
const normalizedKeyword = keyword.trim()
searchKeyword.value = normalizedKeyword
searchDraftKeyword.value = normalizedKeyword
if (props.variant === 'page') homeExpanded.value = true
if (props.variant === 'home' && normalizedKeyword) expandHomePanel()
if (props.variant === 'home' && !normalizedKeyword) homeExpanded.value = false
activeCategoryId.value = ''
if (normalizedKeyword) {
await loadPois(normalizedKeyword)
return
}
await loadInitialSpacePoints()
}
watch(() => props.initialKeyword, (keyword) => {
void applyInitialKeyword(keyword || '')
})
watch([() => props.currentFloorId, () => props.currentFloorLabel], () => {
if (props.variant !== 'home') return
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) => {
emit('expanded-change', expanded)
// #ifdef H5
setH5HomeSearchLock(props.variant === 'home' && expanded)
// #endif
}, { immediate: true })
onMounted(async () => {
await loadFloors()
await applyInitialKeyword(props.initialKeyword)
if (props.autofocus) void focusSearchInput()
})
onUnmounted(() => {
searchRequestSeq += 1
// #ifdef H5
setH5HomeSearchLock(false)
// #endif
})
defineExpose({
expandHomePanel,
collapseHomePanel,
resetSearchState,
returnToCollapsedAfterDetail,
focusSearchInput,
searchShortcut,
retryCurrentSearch,
restoreResultListScroll
})
</script>
<style scoped lang="scss">
/* #ifdef H5 */
:global(html.guide-home-search-fullscreen-lock),
:global(body.guide-home-search-fullscreen-lock) {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
}
/* #endif */
.poi-search-panel {
width: 100%;
max-width: 100%;
box-sizing: border-box;
color: #262421;
font-family: '鸿蒙黑体', 'HarmonyOS Sans SC', 'HarmonyOS Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
overflow-x: hidden;
}
.variant-home.expanded {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
}
.variant-page {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.home-fullscreen-nav {
height: 38px;
flex: 0 0 38px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 38px;
align-items: center;
margin-bottom: 10px;
}
.home-back-button,
.home-nav-spacer {
width: 38px;
height: 38px;
}
.home-back-button {
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #ffffff;
border: 1px solid #e1e4da;
}
.home-back-icon {
display: flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
font-size: 0;
line-height: 0;
font-weight: 600;
color: #151713;
}
.home-back-icon::before {
content: '';
width: 10px;
height: 10px;
border-left: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: translateX(2px) rotate(45deg);
}
.home-fullscreen-title {
min-width: 0;
font-size: 17px;
line-height: 22px;
font-weight: 800;
color: #151713;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-box {
height: 48px;
flex: 0 0 48px;
display: flex;
align-items: center;
padding: 0 14px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #d8dbd0;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(36, 49, 42, 0.08);
}
.variant-home .search-box {
background: #f5f5ed;
border-color: rgba(224, 225, 0, 0.72);
box-shadow: none;
}
.variant-home.is-collapsed .search-box {
height: 38px;
flex-basis: 38px;
padding: 0 12px;
border-radius: 7px;
}
.home-category-strip {
margin-top: 10px;
display: flex;
gap: 6px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
}
.variant-home.is-collapsed .home-category-strip {
margin-top: 7px;
gap: 6px;
}
.home-category-chip {
min-width: calc((100% - 24px) / 5);
flex: 0 0 calc((100% - 24px) / 5);
min-height: 68px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 2px 5px;
box-sizing: border-box;
background: #f5f5ed;
border: 1px solid #dfe2d7;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(21, 23, 19, 0.1);
transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.14s ease;
}
.variant-home.is-collapsed .home-category-chip {
min-height: 68px;
}
.home-category-strip::-webkit-scrollbar {
display: none;
}
.home-category-chip:active {
transform: translateY(1px);
background: #eef06d;
border-color: #c8ca00;
}
.home-category-chip.active {
background: #f2f48d;
border-color: #bfc100;
}
.home-category-icon {
width: 28px;
height: 28px;
flex: 0 0 28px;
}
.poi-category-icon {
display: block;
width: 100%;
height: 100%;
color: #151713;
pointer-events: none;
}
.home-category-chip.active .poi-category-icon,
.category-item.active .poi-category-icon {
color: #151713;
}
.home-category-label {
min-width: 0;
max-width: 100%;
font-size: 11px;
line-height: 15px;
font-weight: 700;
color: #151713;
text-align: center;
white-space: nowrap;
}
.home-category-results {
max-height: min(42vh, 350px);
margin-top: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fffef7;
border: 1px solid #e8e7d4;
border-radius: 8px;
}
.home-category-results-header {
min-height: 54px;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 10px 9px 12px;
box-sizing: border-box;
border-bottom: 1px solid #efeedf;
}
.home-category-results-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.home-category-results-title {
font-size: 14px;
line-height: 19px;
font-weight: 800;
color: #151713;
}
.home-category-results-meta {
font-size: 11px;
line-height: 15px;
color: #6e746c;
}
.home-category-results-close {
width: 32px;
height: 32px;
flex: 0 0 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f0f1e9;
}
.home-category-results-close-text {
font-size: 20px;
line-height: 20px;
color: #4f554d;
}
.home-result-list {
min-height: 112px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.search-icon {
width: 19px;
height: 19px;
position: relative;
flex-shrink: 0;
margin-right: 14px;
border: 2px solid #151713;
border-radius: 50%;
box-sizing: border-box;
}
.search-icon::after {
content: '';
position: absolute;
right: -7px;
bottom: -5px;
width: 10px;
height: 2px;
background: #151713;
border-radius: 2px;
transform: rotate(45deg);
}
.variant-home.is-collapsed .search-icon {
width: 17px;
height: 17px;
margin-right: 11px;
border-width: 2px;
}
.variant-home.is-collapsed .search-icon::after {
right: -6px;
bottom: -4px;
width: 9px;
}
.search-input {
min-width: 0;
flex: 1;
height: 100%;
font-size: 16px;
line-height: 22px;
font-weight: 500;
color: #262421;
}
.search-input :deep(input),
.search-input :deep(.uni-input-input) {
font-size: 16px;
line-height: 22px;
}
.variant-home.is-collapsed .search-input,
.variant-home.is-collapsed .search-input :deep(input),
.variant-home.is-collapsed .search-input :deep(.uni-input-input) {
font-size: 14px;
line-height: 19px;
}
.search-placeholder {
color: #8b8d85;
}
.variant-home.is-collapsed .search-placeholder {
font-size: 14px;
}
.clear-button {
width: 26px;
height: 26px;
flex-shrink: 0;
}
.clear-button {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #eef0e8;
}
.variant-home.is-collapsed .clear-button {
width: 32px;
height: 32px;
}
.variant-home.is-collapsed .clear-text {
font-size: 17px;
line-height: 18px;
}
.clear-text {
font-size: 18px;
line-height: 20px;
color: #5b6058;
}
.poi-search-content {
margin-top: 12px;
}
.variant-page .poi-search-content {
margin-top: 14px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: env(safe-area-inset-bottom);
}
.home-collapse-handle {
height: 18px;
margin: -4px 0 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.home-collapse-bar {
width: 42px;
height: 4px;
background: #d5d8cf;
border-radius: 999px;
}
.variant-home .poi-search-content {
max-height: min(50vh, 430px);
overflow-y: auto;
padding-right: 2px;
}
.variant-home.expanded .poi-search-content {
flex: 1;
min-height: 0;
max-height: none;
display: flex;
flex-direction: column;
overflow: hidden;
padding-right: 0;
}
.category-scroll {
width: 100%;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 4px;
scrollbar-width: none;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
}
.variant-home .category-scroll {
width: calc(100% - 8px);
margin: 0 4px;
padding-bottom: 0;
}
.category-scroll::-webkit-scrollbar {
display: none;
}
.category-grid {
--category-gap: 8px;
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(2, minmax(70px, auto));
grid-auto-columns: calc((100% - 32px) / 5);
gap: var(--category-gap);
width: calc(125% + 2px);
min-width: calc(125% + 2px);
}
.category-item {
min-width: 0;
min-height: 70px;
position: relative;
padding: 9px 4px 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
box-sizing: border-box;
overflow: visible;
background: #f5f5ed;
border: 1px solid #dfe2d7;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(21, 23, 19, 0.08);
transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.14s ease;
}
.category-item.active {
background: #f2f48d;
border-color: #bfc100;
}
.home-category-chip.disabled,
.category-item.disabled {
opacity: 0.42;
cursor: not-allowed;
}
.category-item.active::after {
content: '';
position: absolute;
left: 10px;
right: 10px;
bottom: -1px;
height: 3px;
background: var(--museum-accent);
border-radius: 3px 3px 0 0;
}
.category-icon-shell {
width: 28px;
height: 28px;
flex: 0 0 28px;
}
.category-item:active {
transform: translateY(1px);
background: #eef06d;
border-color: #c8ca00;
}
.variant-home .home-shortcut-grid {
display: grid;
grid-auto-flow: row;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-rows: repeat(2, 72px);
grid-auto-columns: auto;
width: 100%;
min-width: 320px;
gap: 6px;
}
.variant-home .category-item {
min-height: 72px;
height: 72px;
padding: 6px 2px 5px;
gap: 4px;
}
.category-label {
max-width: 100%;
font-size: 11px;
line-height: 15px;
font-weight: 700;
color: #151713;
text-align: center;
white-space: nowrap;
}
.category-item.active .category-label {
color: #151713;
}
.section-title-row {
position: relative;
margin-top: 16px;
margin-bottom: 10px;
padding-left: 12px;
}
.section-title-row::before {
content: '';
position: absolute;
left: 0;
top: 3px;
width: 4px;
height: 16px;
background: var(--museum-accent);
}
.section-title {
font-size: 14px;
line-height: 20px;
font-weight: 800;
color: #151713;
}
.facility-chip-row {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
overscroll-behavior-x: contain;
}
.facility-chip-row::-webkit-scrollbar {
display: none;
}
.facility-chip {
height: 34px;
flex: 0 0 auto;
position: relative;
display: flex;
align-items: center;
gap: 7px;
padding: 0 12px;
box-sizing: border-box;
background: #fffef6;
border: 1px solid #e8e7d2;
border-radius: 8px;
}
.facility-chip.active {
background: #fffddc;
border-color: #d6d700;
}
.facility-chip.active::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: -1px;
height: 3px;
background: var(--museum-accent);
}
.chip-icon {
width: 14px;
height: 14px;
flex: 0 0 14px;
position: relative;
border: 2px solid currentColor;
border-radius: 3px;
color: #151713;
box-sizing: border-box;
}
.facility-chip.active .chip-icon {
color: #151713;
}
.facility-chip-text {
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: #151713;
white-space: nowrap;
}
.facility-chip.active .facility-chip-text {
color: #151713;
}
.result-card {
margin-top: 14px;
overflow: hidden;
background: #fffef7;
border: 1px solid #ecebd8;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(75, 78, 60, 0.08);
}
.data-warning {
display: block;
padding: 7px 10px;
font-size: 11px;
line-height: 16px;
color: #665b18;
background: #fffbd8;
border-bottom: 1px solid #eee9b5;
}
.result-card-warning {
flex: 0 0 auto;
}
.result-card-header {
height: 46px;
padding: 0 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
box-sizing: border-box;
border-bottom: 1px solid #efeedf;
}
.museum-title,
.result-count {
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
.museum-title {
color: #151713;
}
.result-count {
color: #6e746c;
white-space: nowrap;
}
.result-body {
display: flex;
min-height: 168px;
}
.floor-tabs {
width: 62px;
flex: 0 0 62px;
padding: 9px 0;
box-sizing: border-box;
background: #f6f5e8;
}
.floor-tab {
position: relative;
height: 31px;
display: flex;
align-items: center;
justify-content: center;
color: #676c64;
}
.floor-tab.active {
color: #151713;
font-weight: 800;
}
.floor-tab.active::after {
content: '';
position: absolute;
left: 8px;
right: 8px;
bottom: 2px;
height: 3px;
background: var(--museum-accent);
}
.floor-tab-text {
font-size: 12px;
line-height: 16px;
}
.result-list {
min-width: 0;
flex: 1;
}
.variant-page .category-grid,
.variant-page .category-scroll,
.variant-page .section-title-row,
.variant-page .facility-chip-row {
flex: 0 0 auto;
}
.variant-page .result-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.variant-page .result-body {
flex: 1;
min-height: 0;
}
.variant-page .floor-tabs,
.variant-page .result-list {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.variant-page .result-list {
padding-bottom: calc(env(safe-area-inset-bottom) + 10px);
box-sizing: border-box;
}
.variant-home.expanded .category-grid,
.variant-home.expanded .category-scroll,
.variant-home.expanded .section-title-row,
.variant-home.expanded .facility-chip-row {
flex: 0 0 auto;
}
.variant-home.expanded .result-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.variant-home.expanded .result-body {
flex: 1;
min-height: 0;
}
.variant-home.expanded .floor-tabs,
.variant-home.expanded .result-list {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.result-row {
min-height: 58px;
display: flex;
align-items: center;
gap: 9px;
padding: 10px 12px;
box-sizing: border-box;
border-bottom: 1px solid #efefe1;
}
.result-row:last-child {
border-bottom: 0;
}
.result-row.disabled {
opacity: 0.58;
background: #f5f5ef;
cursor: not-allowed;
}
.result-row.disabled .pin-icon {
border-color: #8b8f87;
}
.result-row.disabled .pin-icon::after {
background: #8b8f87;
}
.pin-icon {
width: 15px;
height: 18px;
flex: 0 0 15px;
position: relative;
border: 2px solid #151713;
border-radius: 8px 8px 8px 0;
transform: rotate(-45deg);
box-sizing: border-box;
}
.pin-icon::after {
content: '';
position: absolute;
left: 3px;
top: 3px;
width: 4px;
height: 4px;
background: #151713;
border-radius: 50%;
}
.result-copy {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.result-name,
.result-meta {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-name {
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #151713;
}
.result-meta {
font-size: 11px;
line-height: 15px;
color: #6c716a;
}
.result-arrow {
font-size: 20px;
line-height: 20px;
color: #8b9188;
}
.empty-state {
min-height: 112px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 20px;
box-sizing: border-box;
}
.empty-title {
font-size: 14px;
line-height: 20px;
font-weight: 700;
color: #151713;
}
.empty-desc {
font-size: 12px;
line-height: 17px;
color: #7a7f78;
text-align: center;
}
.retry-button {
height: 34px;
margin-top: 6px;
padding: 0 14px;
display: flex;
align-items: center;
justify-content: center;
background: #151713;
border-radius: 8px;
}
.retry-button-text {
font-size: 12px;
line-height: 16px;
font-weight: 700;
color: var(--museum-accent);
}
@media (max-width: 360px) {
.category-grid {
grid-template-rows: repeat(2, minmax(62px, auto));
}
.variant-page .category-item,
.variant-home.expanded .category-item {
min-height: 62px;
padding: 7px 4px;
gap: 5px;
}
.floor-tabs {
width: 52px;
flex-basis: 52px;
}
}
</style>