1949 lines
47 KiB
Vue
1949 lines
47 KiB
Vue
<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>
|