Files
frontend-miniapp/src/components/search/PoiSearchPanel.vue
lyf eac4b74bdc
Some checks failed
CI / verify (push) Has been cancelled
优化首页搜索与讲解详情语言切换
2026-07-09 15:01:33 +08:00

1744 lines
39 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 }]"
@touchstart="handlePanelTouchStart"
@touchend="handlePanelTouchEnd"
@touchcancel="resetPanelTouch"
@mousedown="handlePanelMouseStart"
@mousemove="handlePanelMouseMove"
@mouseup="handlePanelMouseEnd"
@mouseleave="resetPanelMouse"
>
<view v-if="variant === 'home' && showSearchContent" class="home-fullscreen-nav">
<view class="home-back-button" @tap.stop="collapseHomePanel">
<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 v-else class="voice-icon"></view>
</view>
<view v-if="variant === 'home' && !showSearchContent" class="home-category-strip">
<view
v-for="item in primaryFacilities"
:key="item.id"
class="home-category-chip"
@tap="handleFacilityShortcut(item)"
>
<view class="home-category-icon line-icon" :class="`icon-${item.icon}`">
<view class="icon-part a"></view>
<view class="icon-part b"></view>
<view class="icon-part c"></view>
</view>
<text class="home-category-label">{{ item.label }}</text>
</view>
</view>
<view v-if="showSearchContent" class="poi-search-content">
<view v-if="variant === 'home'" class="home-collapse-handle" @tap.stop="collapseHomePanel">
<view class="home-collapse-bar"></view>
</view>
<view class="category-scroll">
<view class="category-grid">
<view
v-for="item in primaryFacilityColumns"
:key="item.id"
class="category-item"
:class="{ active: searchKeyword === item.label }"
@tap="handleFacilityShortcut(item)"
>
<view class="line-icon" :class="`icon-${item.icon}`">
<view class="icon-part a"></view>
<view class="icon-part b"></view>
<view class="icon-part c"></view>
</view>
<text class="category-label">{{ item.label }}</text>
</view>
</view>
</view>
<view class="section-title-row">
<text class="section-title">服务设施</text>
</view>
<view class="facility-chip-row">
<view
v-for="item in serviceFacilities"
:key="item.id"
class="facility-chip"
:class="{ active: searchKeyword === item.label }"
@tap="handleFacilityShortcut(item)"
>
<view class="chip-icon" :class="`chip-${item.icon}`"></view>
<text class="facility-chip-text">{{ item.label }}</text>
</view>
</view>
<view class="result-card">
<view class="result-card-header">
<text class="museum-title">深圳自然博物馆</text>
<text class="result-count">{{ floorResults.length }} 个点位</text>
</view>
<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 class="result-list">
<view
v-for="item in floorResults"
:key="item.id"
class="result-row"
@tap="handleResultTap(item)"
>
<view class="pin-icon"></view>
<view class="result-copy">
<text class="result-name">{{ item.name }}</text>
<text class="result-meta">{{ item.floorLabel }} · {{ item.primaryCategory.label }}</text>
</view>
<text class="result-arrow"></text>
</view>
<view v-if="!floorResults.length" class="empty-state">
<text class="empty-title">{{ emptyTitle }}</text>
<text class="empty-desc">{{ emptyDesc }}</text>
</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,
isPoiOnIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
guideUseCase
} from '@/usecases/guideUseCase'
export interface PoiSearchShortcut {
label: string
keywords: string[]
}
interface ShortcutItem extends PoiSearchShortcut {
id: string
icon: string
}
const props = withDefaults(defineProps<{
initialKeyword?: string
variant?: 'home' | 'page'
autofocus?: boolean
}>(), {
initialKeyword: '',
variant: 'page',
autofocus: false
})
const emit = defineEmits<{
resultTap: [poi: MuseumPoi]
'expanded-change': [expanded: boolean]
}>()
const primaryFacilities: ShortcutItem[] = [
{ id: 'exhibition', label: '展览', icon: 'exhibition', keywords: ['展览', '展厅', '展馆', 'exhibition_hall', 'temp_exhibition'] },
{ id: 'cinema', label: '影院', icon: 'cinema', keywords: ['影院', '剧场', '影厅', 'cinema', 'theater'] },
{ id: 'restroom', label: '洗手间', icon: 'restroom', keywords: ['洗手间', '卫生间', 'toilet', 'accessible_toilet'] },
{ id: 'restaurant', label: '餐厅', icon: 'restaurant', keywords: ['餐厅', '餐饮', '咖啡', 'restaurant', 'commercial'] },
{ id: 'ticket-office', label: '售票处', icon: 'ticket', keywords: ['售票处', '售票', '票务', 'ticket_office'] },
{ id: 'nursing', label: '母婴室', icon: 'nursing', keywords: ['母婴室', 'mother_baby_room'] },
{ id: 'service-center', label: '服务中心', icon: 'service', keywords: ['服务中心', '服务台', '咨询台', 'service_desk', 'service_space'] },
{ id: 'elevator', label: '电梯', icon: 'elevator', keywords: ['电梯', 'elevator'] },
{ id: 'escalator', label: '扶梯', icon: 'escalator', keywords: ['扶梯', 'escalator'] },
{ id: 'stairs', label: '楼梯', icon: 'stairs', keywords: ['楼梯', 'stairs', 'stair'] }
]
const primaryFacilityVisibleColumnCount = 4
const primaryFacilityRowCount = 2
const primaryFacilityColumns = computed(() => {
const firstScreenItems = primaryFacilities.slice(0, primaryFacilityVisibleColumnCount * primaryFacilityRowCount)
const overflowItems = primaryFacilities.slice(primaryFacilityVisibleColumnCount * primaryFacilityRowCount)
const orderedItems: ShortcutItem[] = []
for (let columnIndex = 0; columnIndex < primaryFacilityVisibleColumnCount; columnIndex += 1) {
const topItem = firstScreenItems[columnIndex]
const bottomItem = firstScreenItems[columnIndex + primaryFacilityVisibleColumnCount]
if (topItem) orderedItems.push(topItem)
if (bottomItem) orderedItems.push(bottomItem)
}
return orderedItems.concat(overflowItems)
})
const serviceFacilities: ShortcutItem[] = [
{ id: 'drink', label: '饮料售卖机', icon: 'drink', keywords: ['饮料售卖机', '饮料', '售卖机', 'vending'] },
{ id: 'ticket', label: '票务售卖机', icon: 'ticket', keywords: ['票务售卖机', '票务', '售票', 'ticket_office'] },
{ id: 'souvenir', label: '纪念品售卖机', icon: 'bag', keywords: ['纪念品售卖机', '纪念品', '商店', 'souvenir'] }
]
const searchKeyword = ref('')
const searchDraftKeyword = ref('')
const searchInputFocused = ref(false)
const homeExpanded = ref(false)
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('1F')
const pois = ref<MuseumPoi[]>([])
const isLoading = ref(false)
const initialSpaceLoadError = ref('')
let searchRequestSeq = 0
const showSearchContent = computed(() => props.variant === 'page' || homeExpanded.value)
const isHomeCollapsed = computed(() => props.variant === 'home' && !showSearchContent.value)
const collapseDragThreshold = 48
const homeExpandTapGuardMs = 360
const displayFloors = computed(() => [...floors.value]
.filter(isIndoorNavigableFloor)
.sort(compareFloorsTopToBottom))
const floorResults = computed(() => {
const matched = pois.value.filter((poi) => poi.floorLabel === activeFloor.value)
return matched.slice(0, props.variant === 'home' ? 8 : 12)
})
const emptyTitle = computed(() => (
isLoading.value ? '正在读取点位' : initialSpaceLoadError.value || '暂无匹配点位'
))
const emptyDesc = computed(() => (
initialSpaceLoadError.value
? '请检查 SGS 空间点位数据接口后重试'
: '可切换楼层或搜索服务设施'
))
const isVisiblePoi = (poi: MuseumPoi) => poi.primaryCategory.id !== 'touring_poi'
&& isPoiOnIndoorNavigableFloor(poi)
const dedupePois = (items: MuseumPoi[]) => {
const seenIds = new Set<string>()
return items.filter((poi) => {
if (!poi.id || seenIds.has(poi.id)) return false
seenIds.add(poi.id)
return true
})
}
const isPoiMatchingKeywords = (poi: MuseumPoi, keywords: string[]) => {
const searchableText = [
poi.name,
poi.floorId,
poi.floorLabel,
poi.primaryCategory.id,
poi.primaryCategory.label,
poi.primaryCategory.iconType,
...poi.categories.flatMap((category) => [
category.id,
category.label,
category.iconType
])
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return keywords.some((keyword) => searchableText.includes(keyword.trim().toLowerCase()))
}
const findShortcutByKeyword = (keyword: string) => {
const normalized = keyword.trim().toLowerCase()
if (!normalized) return null
return [...primaryFacilities, ...serviceFacilities].find((shortcut) => (
shortcut.label.toLowerCase() === normalized
|| shortcut.keywords.some((item) => item.trim().toLowerCase() === normalized)
)) || null
}
const syncActiveFloor = () => {
if (!displayFloors.value.length) {
activeFloor.value = ''
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 syncActiveFloorToResults = (items: MuseumPoi[]) => {
if (!displayFloors.value.length) {
activeFloor.value = ''
return
}
const resultFloorLabels = new Set(items.map((poi) => poi.floorLabel).filter(Boolean))
if (activeFloor.value && resultFloorLabels.has(activeFloor.value)) return
const firstFloorWithResult = displayFloors.value.find((floor) => resultFloorLabels.has(floor.label))
if (firstFloorWithResult) {
activeFloor.value = firstFloorWithResult.label
return
}
syncActiveFloor()
}
const loadFloors = async () => {
try {
floors.value = await guideUseCase.getFloors()
syncActiveFloor()
} catch (error) {
console.error('加载点位搜索楼层失败:', error)
floors.value = []
activeFloor.value = ''
}
}
const loadPois = async (keyword = '') => {
const requestSeq = ++searchRequestSeq
isLoading.value = true
initialSpaceLoadError.value = ''
pois.value = []
try {
const result = await guideUseCase.searchPois(keyword)
if (requestSeq !== searchRequestSeq) return
const visiblePois = result.filter(isVisiblePoi)
pois.value = visiblePois
syncActiveFloorToResults(visiblePois)
} catch (error) {
if (requestSeq !== searchRequestSeq) return
console.error('加载点位搜索结果失败:', error)
pois.value = []
uni.showToast({
title: '点位数据读取失败',
icon: 'none'
})
} finally {
if (requestSeq === searchRequestSeq) {
isLoading.value = false
}
}
}
const loadInitialSpacePoints = async () => {
const requestSeq = ++searchRequestSeq
isLoading.value = true
initialSpaceLoadError.value = ''
pois.value = []
try {
const result = await guideUseCase.getInitialSearchSpacePoints()
if (requestSeq !== searchRequestSeq) return
const visiblePois = result.filter(isVisiblePoi)
pois.value = visiblePois
syncActiveFloorToResults(visiblePois)
} catch (error) {
if (requestSeq !== searchRequestSeq) return
console.error('加载 SDK 空间点位失败:', error)
pois.value = []
initialSpaceLoadError.value = '空间点位读取失败'
uni.showToast({
title: '空间点位读取失败',
icon: 'none'
})
} finally {
if (requestSeq === searchRequestSeq) {
isLoading.value = false
}
}
}
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
}
homeExpanded.value = true
}
const shouldBlockHomeContentTap = () => (
props.variant === 'home'
&& 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 handleSearchFocus = () => {
if (props.variant === 'home') {
expandHomePanel()
}
}
const handleSearchBoxTap = () => {
if (props.variant !== 'home' || homeExpanded.value) return
expandHomePanel()
void focusSearchInput()
}
const collapseHomePanel = () => {
if (props.variant !== 'home') return
homeExpanded.value = false
searchInputFocused.value = false
uni.hideKeyboard()
}
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) {
resetPanelTouch()
return
}
const deltaX = point.clientX - touchStartX.value
const deltaY = point.clientY - touchStartY.value
const isIntentionalDownSwipe = deltaY > collapseDragThreshold
&& deltaY > Math.abs(deltaX) * 1.2
if (isIntentionalDownSwipe) {
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
const isIntentionalDownDrag = deltaY > collapseDragThreshold
&& deltaY > Math.abs(deltaX) * 1.2
if (isIntentionalDownDrag) {
collapseHomePanel()
resetPanelMouse()
}
}
const handlePanelMouseEnd = (event: any) => {
if (!isPanelMouseDown.value || props.variant !== 'home' || !homeExpanded.value) {
resetPanelMouse()
return
}
const point = getGesturePoint(event)
if (!point) {
resetPanelMouse()
return
}
const deltaX = point.clientX - touchStartX.value
const deltaY = point.clientY - touchStartY.value
const isIntentionalDownDrag = deltaY > collapseDragThreshold
&& deltaY > Math.abs(deltaX) * 1.2
if (isIntentionalDownDrag) {
collapseHomePanel()
}
resetPanelMouse()
}
const handleSearchInput = (event: any) => {
searchDraftKeyword.value = event.detail.value
if (props.variant === 'home') {
expandHomePanel()
}
}
const handleSearchClear = async () => {
searchDraftKeyword.value = ''
searchKeyword.value = ''
await loadInitialSpacePoints()
await focusSearchInput()
}
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
expandHomePanel()
const matchedShortcut = findShortcutByKeyword(searchKeyword.value)
if (matchedShortcut) {
await searchShortcut(matchedShortcut)
return
}
await loadPois(searchKeyword.value)
}
const searchShortcut = async (shortcut: PoiSearchShortcut) => {
const requestSeq = ++searchRequestSeq
searchKeyword.value = shortcut.label
searchDraftKeyword.value = shortcut.label
expandHomePanel()
isLoading.value = true
initialSpaceLoadError.value = ''
pois.value = []
try {
const groups = await Promise.all(shortcut.keywords.map((keyword) => guideUseCase.searchPois(keyword)))
if (requestSeq !== searchRequestSeq) return
const matchedPois = dedupePois(groups.flat())
.filter(isVisiblePoi)
.filter((poi) => isPoiMatchingKeywords(poi, shortcut.keywords))
pois.value = matchedPois
syncActiveFloorToResults(matchedPois)
} catch (error) {
if (requestSeq !== searchRequestSeq) return
console.error('加载点位分类搜索结果失败:', error)
pois.value = []
uni.showToast({
title: '点位数据读取失败',
icon: 'none'
})
} finally {
if (requestSeq === searchRequestSeq) {
isLoading.value = false
}
}
}
const handleFacilityShortcut = (shortcut: ShortcutItem) => {
void searchShortcut(shortcut)
}
const handleFloorChange = (floor: string) => {
activeFloor.value = floor
}
const encodeQueryValue = (value: string) => encodeURIComponent(value)
const createPoiLocationQuery = (poi: MuseumPoi) => (
`id=${encodeQueryValue(poi.id)}`
+ `&target=${encodeQueryValue(poi.name)}`
+ `&floorId=${encodeQueryValue(poi.floorId)}`
+ `&floorLabel=${encodeQueryValue(poi.floorLabel)}`
)
const handleResultTap = (poi: MuseumPoi) => {
if (shouldBlockHomeContentTap()) return
emit('resultTap', poi)
if (poi.primaryCategory.id === 'operation_experience') {
uni.navigateTo({
url: `/pages/route/detail?facilityId=${encodeQueryValue(poi.id)}&target=${encodeQueryValue(poi.name)}&floorId=${encodeQueryValue(poi.floorId)}&state=preview`
})
return
}
uni.navigateTo({
url: `/pages/facility/detail?${createPoiLocationQuery(poi)}`
})
}
const applyInitialKeyword = async (keyword: string) => {
const normalizedKeyword = keyword.trim()
searchKeyword.value = normalizedKeyword
searchDraftKeyword.value = normalizedKeyword
if (props.variant === 'page') {
homeExpanded.value = true
} else if (normalizedKeyword) {
expandHomePanel()
} else {
homeExpanded.value = false
}
if (normalizedKeyword) {
await loadPois(normalizedKeyword)
return
}
await loadInitialSpacePoints()
}
watch(() => props.initialKeyword, (keyword) => {
void applyInitialKeyword(keyword || '')
})
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(() => {
// #ifdef H5
setH5HomeSearchLock(false)
// #endif
})
defineExpose({
expandHomePanel,
collapseHomePanel,
focusSearchInput,
searchShortcut
})
</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: 8px;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
.variant-home.is-collapsed .home-category-strip {
margin-top: 7px;
gap: 7px;
padding-bottom: 1px;
}
.home-category-strip::-webkit-scrollbar {
display: none;
}
.home-category-chip {
min-width: 112px;
height: 58px;
flex: 0 0 112px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 11px;
box-sizing: border-box;
background: #e0df00;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
background-image:
linear-gradient(116deg, rgba(255, 255, 255, 0.22) 0 32%, rgba(255, 255, 255, 0) 33%),
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.02));
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.16);
}
.variant-home.is-collapsed .home-category-chip {
min-width: 76px;
height: 42px;
flex-basis: 76px;
justify-content: center;
gap: 5px;
padding: 0 7px;
border-radius: 7px;
box-shadow: none;
}
.home-category-chip:nth-child(2) {
background-color: #18c2b6;
}
.home-category-chip:nth-child(3) {
background-color: #b99cf0;
}
.home-category-chip:nth-child(4) {
background-color: #8ad9ae;
}
.home-category-chip:nth-child(5) {
background-color: #ffc0a1;
}
.home-category-chip:nth-child(6) {
background-color: #5ed0e4;
}
.home-category-chip:nth-child(7) {
background-color: #c9a463;
}
.home-category-chip:nth-child(8) {
background-color: #f4f5ef;
}
.home-category-chip:active {
transform: translateY(1px);
}
.home-category-icon {
width: 24px;
height: 23px;
flex: 0 0 24px;
order: 2;
color: #050704;
}
.home-category-icon.line-icon {
width: 24px;
height: 23px;
}
.home-category-icon .icon-part {
transform: scale(0.8);
transform-origin: center;
}
.variant-home.is-collapsed .home-category-icon,
.variant-home.is-collapsed .home-category-icon.line-icon {
width: 20px;
height: 19px;
flex-basis: 20px;
}
.variant-home.is-collapsed .home-category-icon .icon-part {
transform: scale(0.68);
}
.home-category-label {
min-width: 0;
max-width: 70px;
font-size: 14px;
line-height: 18px;
font-weight: 800;
color: #050704;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.variant-home.is-collapsed .home-category-label {
max-width: 42px;
font-size: 11px;
line-height: 14px;
}
.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,
.voice-icon {
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,
.variant-home.is-collapsed .voice-icon {
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;
}
.voice-icon {
position: relative;
}
.variant-home.is-collapsed .voice-icon::before {
left: 11px;
top: 7px;
width: 10px;
height: 13px;
}
.variant-home.is-collapsed .voice-icon::after {
left: 10px;
bottom: 6px;
width: 12px;
height: 7px;
}
.voice-icon::before {
content: '';
position: absolute;
left: 8px;
top: 4px;
width: 10px;
height: 14px;
border: 2px solid #151713;
border-radius: 999px;
box-sizing: border-box;
}
.voice-icon::after {
content: '';
position: absolute;
left: 7px;
bottom: 3px;
width: 12px;
height: 7px;
border-bottom: 2px solid #151713;
border-left: 2px solid transparent;
border-right: 2px solid transparent;
border-radius: 0 0 999px 999px;
box-sizing: border-box;
}
.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;
}
.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 6px 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
box-sizing: border-box;
overflow: hidden;
background: #fffef2;
border: 1px solid #ecebd2;
border-radius: 8px;
}
.category-item.active {
background: #fffddc;
border-color: #d8d900;
}
.category-item.active::after {
content: '';
position: absolute;
left: 14px;
right: 14px;
bottom: 0;
height: 4px;
background: var(--museum-accent);
}
.line-icon {
width: 26px;
height: 24px;
position: relative;
color: #151713;
}
.category-item.active .line-icon {
color: #151713;
}
.icon-part {
position: absolute;
box-sizing: border-box;
}
.icon-service .icon-part.a,
.icon-exhibition .icon-part.a,
.icon-cinema .icon-part.a,
.icon-ticket .icon-part.a,
.icon-drink .icon-part.a,
.icon-bag .icon-part.a {
left: 4px;
right: 4px;
top: 5px;
bottom: 5px;
border: 2px solid currentColor;
border-radius: 3px;
}
.icon-service .icon-part.b,
.icon-exhibition .icon-part.b,
.icon-cinema .icon-part.b,
.icon-ticket .icon-part.b,
.icon-drink .icon-part.b,
.icon-bag .icon-part.b {
left: 9px;
right: 9px;
top: 1px;
height: 5px;
border: 2px solid currentColor;
border-bottom: 0;
border-radius: 6px 6px 0 0;
}
.icon-elevator .icon-part.a,
.icon-escalator .icon-part.a,
.icon-restroom .icon-part.a,
.icon-nursing .icon-part.a,
.icon-restaurant .icon-part.a,
.icon-stairs .icon-part.a,
.icon-entrance .icon-part.a {
left: 4px;
right: 4px;
top: 3px;
bottom: 3px;
border: 2px solid currentColor;
border-radius: 4px;
}
.icon-elevator .icon-part.b {
left: 9px;
top: 7px;
width: 8px;
height: 10px;
border-left: 2px solid currentColor;
border-right: 2px solid currentColor;
}
.icon-escalator .icon-part.b,
.icon-entrance .icon-part.b {
left: 7px;
top: 12px;
width: 13px;
height: 2px;
background: currentColor;
transform: rotate(-22deg);
}
.icon-restroom .icon-part.b,
.icon-nursing .icon-part.b {
left: 8px;
top: 8px;
width: 4px;
height: 4px;
background: currentColor;
border-radius: 50%;
box-shadow: 8px 0 0 currentColor;
}
.icon-exhibition .icon-part.c {
left: 6px;
right: 6px;
bottom: 2px;
height: 2px;
background: currentColor;
border-radius: 2px;
}
.icon-cinema .icon-part.b {
left: 2px;
right: 2px;
top: 4px;
height: 4px;
border: 0;
border-top: 2px solid currentColor;
border-bottom: 2px solid currentColor;
border-radius: 0;
}
.icon-restaurant .icon-part.b {
left: 7px;
top: 6px;
width: 2px;
height: 12px;
background: currentColor;
box-shadow: 5px 0 0 currentColor, 10px 0 0 currentColor;
}
.icon-restaurant .icon-part.c {
right: 3px;
top: 5px;
width: 7px;
height: 15px;
border-left: 2px solid currentColor;
border-bottom: 2px solid currentColor;
border-radius: 0 0 0 5px;
}
.icon-stairs .icon-part.b {
left: 6px;
top: 7px;
width: 14px;
height: 12px;
border-left: 2px solid currentColor;
border-bottom: 2px solid currentColor;
}
.icon-stairs .icon-part.c {
left: 10px;
top: 11px;
width: 10px;
height: 8px;
border-left: 2px solid currentColor;
border-bottom: 2px solid currentColor;
}
.category-label {
max-width: 100%;
font-size: 12px;
line-height: 16px;
font-weight: 700;
color: #151713;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.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);
}
.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;
}
.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;
}
@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>