chore: freeze guide and explain update

This commit is contained in:
lyf
2026-06-24 18:00:25 +08:00
parent feb7310a46
commit 67c6609ae6
104 changed files with 3203572 additions and 40713 deletions

View File

@@ -25,6 +25,8 @@
:show-controls="false"
:show-poi="shouldShowIndoorPois"
:target-focus="targetFocusRequest"
:route-preview="routePreview"
:show-route="showRoute"
@floor-change="handleThreeFloorChange"
@poi-click="handlePoiClick"
@selection-clear="handleSelectionClear"
@@ -49,15 +51,31 @@
<view v-if="dimmed" class="dim-layer"></view>
</view>
<view v-if="showSearch" class="guide-search-field" :style="searchFieldStyle" @tap="handleSearchTap">
<view
v-if="showSearch"
class="guide-search-field"
:class="`variant-${searchVariant}`"
:style="searchFieldStyle"
@tap="handleSearchTap"
>
<view v-if="searchVariant === 'home'" class="search-icon leading">
<view class="search-icon-circle"></view>
<view class="search-icon-handle"></view>
</view>
<text class="search-field-text">{{ searchText }}</text>
<view class="search-icon">
<view v-if="showVoiceIcon" class="voice-icon">
<view class="voice-head"></view>
<view class="voice-stem"></view>
<view class="voice-base"></view>
</view>
<view v-else-if="searchVariant !== 'home'" class="search-icon">
<view class="search-icon-circle"></view>
<view class="search-icon-handle"></view>
</view>
</view>
<view
v-if="showModeRow"
class="guide-mode-row"
:class="`layout-${modeLayout}`"
:style="modeRowStyle"
@@ -112,7 +130,16 @@
<text class="layer-mode-label">{{ layerModeActionLabel }}</text>
</view>
<view v-if="showIndoorRightControls && showFloor" class="floor-switcher" :style="floorSwitcherStyle">
<view
v-if="showIndoorRightControls && showFloor"
class="floor-switcher"
:class="`side-${floorSide}`"
:style="floorSwitcherStyle"
>
<view v-if="showFloorHeader" class="floor-header">
<text class="floor-header-icon"></text>
<text class="floor-header-label">楼层</text>
</view>
<view
v-for="floor in floorLabels"
:key="floor"
@@ -124,6 +151,25 @@
</view>
</view>
<view v-if="showZoomControls" class="zoom-controls" :style="zoomControlsStyle">
<view class="zoom-btn" @tap="handleZoomClick('in')">
<text class="zoom-text">+</text>
</view>
<view class="zoom-divider"></view>
<view class="zoom-btn" @tap="handleZoomClick('out')">
<text class="zoom-text"></text>
</view>
</view>
<view v-if="showLocationControl" class="location-control" :style="locationControlStyle" @tap="handleResetView">
<view class="location-ring"></view>
<view class="location-dot"></view>
</view>
<view v-if="showMoreControl" class="more-control" :style="moreControlStyle" @tap="handleMoreTap">
<text class="more-control-text">更多</text>
</view>
<view v-if="tools.length" class="tool-stack" :style="toolStackStyle">
<view
v-for="tool in tools"
@@ -153,6 +199,9 @@ import type {
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
import type {
GuideRouteResult
} from '@/domain/museum'
interface GuideFloorOption {
id: string
@@ -192,6 +241,17 @@ const props = withDefaults(defineProps<{
layerModeToggleTop?: string
toolsTop?: string
tools?: string[]
searchVariant?: 'default' | 'home'
showVoiceIcon?: boolean
showModeRow?: boolean
showFloorHeader?: boolean
showZoomControls?: boolean
showLocationControl?: boolean
showMoreControl?: boolean
floorSide?: 'left' | 'right'
zoomControlsTop?: string
locationControlBottom?: string
moreControlBottom?: string
showSearch?: boolean
showFloor?: boolean
showLayerModeToggle?: boolean
@@ -207,6 +267,8 @@ const props = withDefaults(defineProps<{
normalizeFloorId?: (labelOrId: string) => string
dimmed?: boolean
targetFocusRequest?: TargetPoiFocusRequest | null
routePreview?: GuideRouteResult | null
showRoute?: boolean
}>(), {
searchText: '搜索设施、展厅、入口',
activeMode: '3d',
@@ -219,6 +281,17 @@ const props = withDefaults(defineProps<{
layerModeToggleTop: '154px',
toolsTop: '406px',
tools: () => [] as string[],
searchVariant: 'default',
showVoiceIcon: false,
showModeRow: true,
showFloorHeader: false,
showZoomControls: false,
showLocationControl: false,
showMoreControl: false,
floorSide: 'right',
zoomControlsTop: '520px',
locationControlBottom: '34px',
moreControlBottom: '34px',
showSearch: true,
showFloor: true,
showLayerModeToggle: false,
@@ -233,7 +306,9 @@ const props = withDefaults(defineProps<{
floors: () => [] as GuideFloorOption[],
normalizeFloorId: (labelOrId: string) => labelOrId,
dimmed: false,
targetFocusRequest: null
targetFocusRequest: null,
routePreview: null,
showRoute: false
})
const emit = defineEmits<{
@@ -241,6 +316,7 @@ const emit = defineEmits<{
modeChange: [mode: '2d' | '3d']
floorChange: [floor: string]
toolClick: [tool: string]
moreClick: []
indoorViewChange: [view: IndoorViewMode]
layerModeChange: [mode: LayerDisplayMode]
poiClick: [poi: GuideRenderPoi]
@@ -290,6 +366,18 @@ const toolStackStyle = computed(() => ({
top: props.toolsTop
}))
const zoomControlsStyle = computed(() => ({
top: props.zoomControlsTop
}))
const locationControlStyle = computed(() => ({
bottom: props.locationControlBottom
}))
const moreControlStyle = computed(() => ({
bottom: props.moreControlBottom
}))
const shouldShowIndoorPois = computed(() => (
props.mapType === 'indoor'
) || Boolean(props.targetFocusRequest))
@@ -354,6 +442,19 @@ const handleToolClick = (tool: string) => {
emit('toolClick', tool)
}
const handleResetView = () => {
indoorRendererRef.value?.resetCamera?.()
emit('toolClick', '重置')
}
const handleZoomClick = (direction: 'in' | 'out') => {
emit('toolClick', direction === 'in' ? '放大' : '缩小')
}
const handleMoreTap = () => {
emit('moreClick')
}
const handleThreeFloorChange = (floorId: string) => {
const floor = props.floors.find((item) => item.id === floorId)
emit('floorChange', floor?.label || floorId)
@@ -475,6 +576,17 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
z-index: 40;
}
.guide-search-field.variant-home {
left: 20px;
right: 20px;
height: 48px;
padding: 0 14px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #d7dad3;
border-radius: 12px;
box-shadow: 0 8px 22px rgba(36, 49, 42, 0.08);
}
.search-field-text {
flex: 1;
min-width: 0;
@@ -486,6 +598,13 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
text-overflow: ellipsis;
}
.variant-home .search-field-text {
padding: 0 10px;
font-size: 15px;
line-height: 22px;
color: #8b9097;
}
.search-icon {
position: relative;
width: 16px;
@@ -493,6 +612,64 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
flex-shrink: 0;
}
.search-icon.leading {
width: 22px;
height: 22px;
}
.search-icon.leading .search-icon-circle {
top: 2px;
left: 1px;
width: 14px;
height: 14px;
border-width: 2px;
}
.search-icon.leading .search-icon-handle {
left: 14px;
top: 15px;
width: 10px;
height: 2px;
}
.voice-icon {
position: relative;
width: 22px;
height: 24px;
flex: 0 0 22px;
}
.voice-head {
position: absolute;
left: 6px;
top: 1px;
width: 10px;
height: 15px;
box-sizing: border-box;
border: 2px solid #151713;
border-radius: 7px;
}
.voice-stem {
position: absolute;
left: 10px;
top: 15px;
width: 2px;
height: 6px;
background: #151713;
border-radius: 2px;
}
.voice-base {
position: absolute;
left: 5px;
bottom: 0;
width: 12px;
height: 2px;
background: #151713;
border-radius: 2px;
}
.search-icon-circle {
position: absolute;
top: 2px;
@@ -607,7 +784,6 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
.floor-switcher {
position: absolute;
right: 16px;
width: 46px;
height: auto;
max-height: calc(100vh - 260px);
@@ -621,6 +797,49 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
z-index: 35;
}
.floor-switcher.side-right {
right: 16px;
}
.floor-switcher.side-left {
left: 18px;
}
.floor-header {
min-height: 50px;
padding: 7px 4px 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
box-sizing: border-box;
background: #ffffff;
border-radius: 8px 8px 0 0;
}
.floor-header::after {
content: '';
position: absolute;
left: 7px;
right: 7px;
bottom: 0;
height: 1px;
background: #ecede7;
}
.floor-header-icon {
font-size: 16px;
line-height: 16px;
color: #151713;
}
.floor-header-label {
font-size: 11px;
line-height: 14px;
color: #545861;
}
.layer-mode-toggle {
position: absolute;
right: 16px;
@@ -718,6 +937,110 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
color: #1f2329;
}
.zoom-controls {
position: absolute;
right: 18px;
width: 48px;
overflow: hidden;
background: rgba(255, 255, 255, 0.94);
border: 1px solid #dde5df;
border-radius: 10px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 35;
}
.zoom-btn {
height: 48px;
display: flex;
align-items: center;
justify-content: center;
}
.zoom-divider {
height: 1px;
margin: 0 9px;
background: #ecede7;
}
.zoom-text {
font-size: 28px;
line-height: 32px;
font-weight: 300;
color: #151713;
}
.location-control,
.more-control {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.95);
border: 1px solid #dde5df;
border-radius: 10px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 35;
}
.location-control {
left: 18px;
width: 48px;
height: 48px;
}
.location-ring {
width: 20px;
height: 20px;
box-sizing: border-box;
border: 2px solid #151713;
border-radius: 50%;
}
.location-ring::before,
.location-ring::after {
content: '';
position: absolute;
background: #151713;
border-radius: 2px;
}
.location-ring::before {
left: 23px;
top: 10px;
width: 2px;
height: 28px;
}
.location-ring::after {
left: 10px;
top: 23px;
width: 28px;
height: 2px;
}
.location-dot {
position: absolute;
width: 6px;
height: 6px;
background: #151713;
border-radius: 50%;
}
.more-control {
right: 18px;
min-width: 54px;
height: 36px;
padding: 0 12px;
}
.more-control-text {
font-size: 13px;
line-height: 18px;
font-weight: 500;
color: #151713;
}
@media (min-width: 768px) {
.guide-map-shell {
max-width: 430px;

View File

@@ -2,6 +2,8 @@
<view class="guide-page-frame" :class="`variant-${variant}`">
<GuideTopTabs
:active-tab="activeTab"
:variant="topTabsVariant"
:top="topTabsTop"
@tab-change="handleTabChange"
/>
<view
@@ -36,6 +38,8 @@ import type { GuideTopTab } from '@/utils/guideTopTabs'
withDefaults(defineProps<{
activeTab?: GuideTopTab
variant?: 'overlay' | 'static'
topTabsVariant?: 'underline' | 'segmented'
topTabsTop?: string
showBack?: boolean
showCancel?: boolean
backLabel?: string
@@ -43,6 +47,8 @@ withDefaults(defineProps<{
}>(), {
activeTab: 'guide',
variant: 'overlay',
topTabsVariant: 'underline',
topTabsTop: '0',
showBack: false,
showCancel: false,
backLabel: '返回',

View File

@@ -1,5 +1,9 @@
<template>
<view class="guide-top-tabs">
<view
class="guide-top-tabs"
:class="`variant-${variant}`"
:style="{ top }"
>
<view
v-for="tab in GUIDE_TOP_TABS"
:key="tab.id"
@@ -7,7 +11,10 @@
:class="{ active: activeTab === tab.id }"
@tap="handleTabTap(tab.id)"
>
<view v-if="activeTab === tab.id" class="guide-top-tab-indicator"></view>
<view
v-if="activeTab === tab.id && variant === 'underline'"
class="guide-top-tab-indicator"
></view>
<text class="guide-top-tab-label">{{ tab.label }}</text>
</view>
</view>
@@ -21,8 +28,12 @@ import {
withDefaults(defineProps<{
activeTab?: GuideTopTab
variant?: 'underline' | 'segmented'
top?: string
}>(), {
activeTab: 'guide'
activeTab: 'guide',
variant: 'underline',
top: '0'
})
const emit = defineEmits<{
@@ -37,7 +48,6 @@ const handleTabTap = (tab: GuideTopTab) => {
<style scoped lang="scss">
.guide-top-tabs {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 44px;
@@ -53,6 +63,19 @@ const handleTabTap = (tab: GuideTopTab) => {
will-change: transform;
}
.guide-top-tabs.variant-segmented {
left: 50%;
right: auto;
width: 174px;
height: 38px;
padding: 2px;
background: #ffffff;
border: 1px solid #151713;
border-radius: 8px;
box-shadow: 0 8px 20px rgba(36, 49, 42, 0.08);
transform: translateX(-50%) translateZ(0);
}
.guide-top-tab {
position: relative;
width: 75px;
@@ -62,6 +85,17 @@ const handleTabTap = (tab: GuideTopTab) => {
justify-content: center;
}
.variant-segmented .guide-top-tab {
flex: 1;
width: auto;
height: 32px;
border-radius: 6px;
}
.variant-segmented .guide-top-tab.active {
background: #000000;
}
.guide-top-tab-label {
position: relative;
z-index: 2;
@@ -76,6 +110,16 @@ const handleTabTap = (tab: GuideTopTab) => {
color: #1f2329;
}
.variant-segmented .guide-top-tab-label {
font-size: 15px;
line-height: 20px;
color: #151713;
}
.variant-segmented .guide-top-tab.active .guide-top-tab-label {
color: var(--museum-accent);
}
.guide-top-tab-indicator {
position: absolute;
left: 50%;

View File

@@ -22,10 +22,10 @@
<view class="action-row">
<view class="action-btn secondary" @tap="emit('viewOutdoorMap')">
<text class="action-text">查看室外地图</text>
<text class="action-text">室外入口参考</text>
</view>
<view class="action-btn primary" @tap="handleShowTargetLocation">
<text class="action-text">重新定位目标</text>
<text class="action-text">查看三维位置</text>
</view>
</view>
</view>

View File

@@ -0,0 +1,421 @@
<template>
<view v-if="visible" class="route-planner-panel">
<view class="panel-handle"></view>
<view class="panel-header">
<view class="panel-title-group">
<text class="panel-title">室内到室内</text>
<text v-if="summary" class="panel-summary">{{ summary }}</text>
</view>
<view class="panel-clear" @tap="handleClear">
<text class="panel-clear-text">清除</text>
</view>
</view>
<view class="point-row">
<view class="point-card" @tap="openPicker('start')">
<view class="point-dot start"></view>
<text class="point-label">起点</text>
<text class="point-name" :class="{ placeholder: !startPoint }">
{{ startPoint?.name || '选择起点' }}
</text>
<text v-if="startPoint" class="point-meta">{{ pointMeta(startPoint) }}</text>
</view>
<view class="swap-btn" @tap="handleSwap">
<text class="swap-text"></text>
</view>
<view class="point-card" @tap="openPicker('end')">
<view class="point-dot end"></view>
<text class="point-label">终点</text>
<text class="point-name" :class="{ placeholder: !endPoint }">
{{ endPoint?.name || '选择终点' }}
</text>
<text v-if="endPoint" class="point-meta">{{ pointMeta(endPoint) }}</text>
</view>
</view>
<view v-if="loading || error || summary" class="panel-status">
<text v-if="loading" class="panel-status-text">路径加载中</text>
<text v-else-if="error" class="panel-status-text error">{{ error }}</text>
<text v-else class="panel-status-text">{{ summary }}</text>
</view>
<view
class="view-route-btn"
:class="{ disabled: !canViewRoute }"
@tap="handlePrimaryAction"
>
<text class="view-route-text">{{ primaryActionText }}</text>
</view>
<RoutePointPicker
:visible="pickerMode !== ''"
:title="pickerTitle"
:placeholder="pickerPlaceholder"
:options="pointOptions"
:selected-poi-id="activeSelectedPoiId"
:loading="pickerLoading"
:error="pickerError"
:empty-text="pickerEmptyText"
@close="closePicker"
@select="handlePointSelect"
@search="handlePickerSearch"
@keyword-change="handlePickerKeywordChange"
/>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import RoutePointPicker, {
type RoutePointOption
} from '@/components/navigation/RoutePointPicker.vue'
type PickerMode = '' | 'start' | 'end'
const props = withDefaults(defineProps<{
visible?: boolean
startPoint?: RoutePointOption | null
endPoint?: RoutePointOption | null
pointOptions?: RoutePointOption[]
hasRoutePreview?: boolean
loading?: boolean
error?: string
summary?: string
pickerLoading?: boolean
pickerError?: string
pickerEmptyText?: string
}>(), {
visible: true,
startPoint: null,
endPoint: null,
pointOptions: () => [] as RoutePointOption[],
hasRoutePreview: false,
loading: false,
error: '',
summary: '',
pickerLoading: false,
pickerError: '',
pickerEmptyText: '暂无匹配地点'
})
const emit = defineEmits<{
'update:startPoint': [point: RoutePointOption | null]
'update:endPoint': [point: RoutePointOption | null]
startChange: [point: RoutePointOption]
endChange: [point: RoutePointOption]
pickerOpen: [mode: 'start' | 'end']
pickerClose: []
search: [payload: { mode: 'start' | 'end'; keyword: string }]
swap: []
clear: []
viewRoute: [payload: { startPoint: RoutePointOption; endPoint: RoutePointOption }]
simulateGuide: []
}>()
const pickerMode = ref<PickerMode>('')
const pickerTitle = computed(() => (
pickerMode.value === 'start' ? '选择起点' : '选择终点'
))
const pickerPlaceholder = computed(() => (
pickerMode.value === 'start' ? '搜索起点' : '搜索终点'
))
const activeSelectedPoiId = computed(() => (
pickerMode.value === 'start'
? props.startPoint?.poiId || ''
: props.endPoint?.poiId || ''
))
const canViewRoute = computed(() => Boolean(
props.startPoint
&& props.endPoint
&& !props.loading
&& props.startPoint.poiId !== props.endPoint.poiId
))
const primaryActionText = computed(() => (
props.hasRoutePreview ? '模拟导览' : '生成线路'
))
const pointMeta = (point: RoutePointOption) => {
return point.categoryLabel
? `${point.floorLabel} · ${point.categoryLabel}`
: point.floorLabel
}
const openPicker = (mode: 'start' | 'end') => {
pickerMode.value = mode
emit('search', { mode, keyword: '' })
emit('pickerOpen', mode)
}
const closePicker = () => {
pickerMode.value = ''
emit('pickerClose')
}
const handlePointSelect = (point: RoutePointOption) => {
if (pickerMode.value === 'start') {
emit('update:startPoint', point)
emit('startChange', point)
} else if (pickerMode.value === 'end') {
emit('update:endPoint', point)
emit('endChange', point)
}
closePicker()
}
const handlePickerSearch = (keyword: string) => {
if (!pickerMode.value) return
emit('search', { mode: pickerMode.value, keyword })
}
const handlePickerKeywordChange = (keyword: string) => {
if (!pickerMode.value) return
emit('search', { mode: pickerMode.value, keyword })
}
const handleSwap = () => {
emit('swap')
}
const handleClear = () => {
emit('clear')
}
const handleViewRoute = () => {
if (!canViewRoute.value || !props.startPoint || !props.endPoint) return
emit('viewRoute', {
startPoint: props.startPoint,
endPoint: props.endPoint
})
}
const handlePrimaryAction = () => {
if (!canViewRoute.value) return
if (props.hasRoutePreview) {
emit('simulateGuide')
return
}
handleViewRoute()
}
</script>
<style scoped lang="scss">
.route-planner-panel {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 30px);
padding: 10px 14px 14px;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #ffffff;
border-radius: 16px;
box-shadow: 0 10px 28px rgba(36, 49, 42, 0.12);
z-index: 1003;
}
.panel-handle {
width: 38px;
height: 4px;
margin: 0 auto 10px;
background: #d8dbd2;
border-radius: 999px;
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.panel-title-group {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.panel-title {
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.panel-summary {
font-size: 12px;
line-height: 18px;
color: #696962;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.panel-clear {
flex: 0 0 auto;
height: 28px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #f5f7f2;
border: 1px solid #e4e5df;
border-radius: 8px;
}
.panel-clear-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #545861;
}
.point-row {
position: relative;
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 52px;
}
.point-card {
position: relative;
min-width: 0;
min-height: 52px;
padding: 8px 10px 8px 34px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
column-gap: 8px;
align-items: center;
box-sizing: border-box;
background: #f6f7f4;
border: 1px solid #e5e6de;
border-radius: 10px;
}
.point-dot {
position: absolute;
left: 12px;
top: 21px;
width: 8px;
height: 8px;
border-radius: 50%;
}
.point-dot.start {
background: #1fbf6b;
}
.point-dot.end {
background: #ef5552;
}
.point-label {
font-size: 11px;
line-height: 15px;
color: #8b8b84;
}
.point-name {
margin-top: 0;
font-size: 14px;
line-height: 20px;
font-weight: 600;
color: #1f2329;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.point-name.placeholder {
color: #545861;
}
.point-meta {
grid-column: 2;
margin-top: -2px;
font-size: 11px;
line-height: 15px;
color: #696962;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.swap-btn {
position: absolute;
right: 0;
top: 0;
width: 42px;
height: 112px;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #d7dad3;
border-radius: 10px;
}
.swap-text {
font-size: 22px;
line-height: 24px;
font-weight: 500;
color: #151713;
}
.panel-status {
margin-top: 10px;
min-height: 20px;
}
.panel-status-text {
font-size: 12px;
line-height: 18px;
color: #696962;
}
.panel-status-text.error {
color: #b44b42;
}
.view-route-btn {
margin-top: 12px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
background: #000000;
border-radius: 10px;
}
.view-route-btn.disabled {
background: #d8dbd2;
}
.view-route-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: var(--museum-accent);
}
.view-route-btn.disabled .view-route-text {
color: #8b8b84;
}
</style>

View File

@@ -0,0 +1,328 @@
<template>
<view v-if="visible" class="route-point-picker">
<view class="picker-mask" @tap="handleClose"></view>
<view class="picker-panel">
<view class="picker-header">
<text class="picker-title">{{ title }}</text>
<view class="picker-close" @tap="handleClose">
<text class="picker-close-text">关闭</text>
</view>
</view>
<view class="picker-search">
<input
class="picker-search-input"
:value="keyword"
:placeholder="placeholder"
placeholder-class="picker-search-placeholder"
confirm-type="search"
@input="handleKeywordInput"
@confirm="handleSearchConfirm"
/>
</view>
<view v-if="loading" class="picker-state">
<text class="picker-state-text">加载中</text>
</view>
<view v-else-if="error" class="picker-state">
<text class="picker-state-text error">{{ error }}</text>
</view>
<view v-else-if="filteredOptions.length === 0" class="picker-state">
<text class="picker-state-text">{{ emptyText }}</text>
</view>
<scroll-view v-else class="picker-list" scroll-y>
<view
v-for="option in filteredOptions"
:key="option.poiId"
class="picker-option"
:class="{ active: selectedPoiId === option.poiId }"
@tap="handleSelect(option)"
>
<view class="option-main">
<text class="option-name">{{ option.name }}</text>
<text class="option-meta">{{ formatMeta(option) }}</text>
</view>
<view v-if="selectedPoiId === option.poiId" class="option-check">
<text class="option-check-text">已选</text>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
export interface RoutePointOption {
poiId: string
name: string
floorId: string
floorLabel: string
categoryLabel?: string
}
const props = withDefaults(defineProps<{
visible?: boolean
title?: string
placeholder?: string
options?: RoutePointOption[]
selectedPoiId?: string
initialKeyword?: string
loading?: boolean
error?: string
emptyText?: string
}>(), {
visible: false,
title: '选择目标',
placeholder: '搜索目标列表',
options: () => [] as RoutePointOption[],
selectedPoiId: '',
initialKeyword: '',
loading: false,
error: '',
emptyText: '暂无匹配地点'
})
const emit = defineEmits<{
close: []
select: [option: RoutePointOption]
search: [keyword: string]
keywordChange: [keyword: string]
}>()
const keyword = ref(props.initialKeyword)
watch(
() => props.visible,
(visible) => {
if (visible) {
keyword.value = props.initialKeyword
}
}
)
watch(
() => props.initialKeyword,
(value) => {
if (!props.visible) {
keyword.value = value
}
}
)
const normalizedKeyword = computed(() => keyword.value.trim().toLowerCase())
const filteredOptions = computed(() => {
if (!normalizedKeyword.value) return props.options
return props.options.filter((option) => {
const fields = [
option.name,
option.floorId,
option.floorLabel,
option.categoryLabel || ''
]
return fields.some((field) => field.toLowerCase().includes(normalizedKeyword.value))
})
})
const formatMeta = (option: RoutePointOption) => {
return option.categoryLabel
? `${option.floorLabel} · ${option.categoryLabel}`
: option.floorLabel
}
const handleKeywordInput = (event: Event) => {
const detailValue = (event as Event & { detail?: { value?: string } }).detail?.value
const targetValue = (event.target as HTMLInputElement | null)?.value
keyword.value = detailValue ?? targetValue ?? ''
emit('keywordChange', keyword.value)
}
const handleSearchConfirm = () => {
emit('search', keyword.value)
}
const handleClose = () => {
emit('close')
}
const handleSelect = (option: RoutePointOption) => {
emit('select', option)
}
</script>
<style scoped lang="scss">
.route-point-picker {
position: fixed;
inset: 0;
z-index: 1200;
}
.picker-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.28);
}
.picker-panel {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 12px);
max-height: 72vh;
padding: 16px 14px 14px;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #ffffff;
border-radius: 16px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.14);
}
.picker-header {
height: 28px;
display: flex;
align-items: center;
justify-content: space-between;
}
.picker-title {
min-width: 0;
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.picker-close {
height: 28px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #f5f7f2;
border: 1px solid #e4e5df;
border-radius: 8px;
}
.picker-close-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #545861;
}
.picker-search {
margin-top: 14px;
height: 38px;
display: flex;
align-items: center;
padding: 0 12px;
box-sizing: border-box;
background: var(--museum-bg-cream);
border: 1px solid #ffffff;
border-radius: 8px;
}
.picker-search-input {
width: 100%;
height: 36px;
font-size: 14px;
line-height: 20px;
color: #1f2329;
}
.picker-search-placeholder {
color: #8b8b84;
}
.picker-list {
margin-top: 10px;
max-height: 48vh;
}
.picker-option {
min-height: 62px;
padding: 12px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
box-sizing: border-box;
background: #ffffff;
border-bottom: 1px solid #ecede7;
}
.picker-option.active {
background: #f6f7f4;
}
.option-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.option-name {
font-size: 15px;
line-height: 21px;
font-weight: 600;
color: #1f2329;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.option-meta {
font-size: 12px;
line-height: 16px;
color: #696962;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.option-check {
flex: 0 0 auto;
height: 26px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
background: #000000;
border-radius: 13px;
}
.option-check-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: var(--museum-accent);
}
.picker-state {
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.picker-state-text {
max-width: 260px;
font-size: 13px;
line-height: 20px;
color: #696962;
}
.picker-state-text.error {
color: #b44b42;
}
</style>