简化来馆面板并优化地图点位展示
This commit is contained in:
@@ -59,7 +59,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onBeforeUnmount, onMounted, nextTick } from 'vue'
|
||||
import { computed, ref, onBeforeUnmount, onMounted, nextTick, watch } from 'vue'
|
||||
import { getMuseumEntranceLocation } from '@/services/tencent/TencentMapService'
|
||||
|
||||
const isH5 = typeof window !== 'undefined'
|
||||
@@ -103,6 +103,14 @@ interface Polyline {
|
||||
dottedLine?: boolean
|
||||
}
|
||||
|
||||
export interface OutdoorMapMarker {
|
||||
id: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
interface MapFloor {
|
||||
id: string
|
||||
label: string
|
||||
@@ -112,14 +120,21 @@ const props = withDefaults(defineProps<{
|
||||
activeFloor?: string
|
||||
floors?: MapFloor[]
|
||||
polylines?: Polyline[]
|
||||
outdoorMarkers?: OutdoorMapMarker[]
|
||||
activeOutdoorMarkerId?: string
|
||||
outdoorFocusOffsetY?: number
|
||||
}>(), {
|
||||
activeFloor: '1F',
|
||||
floors: () => [] as MapFloor[],
|
||||
polylines: () => [] as Polyline[]
|
||||
polylines: () => [] as Polyline[],
|
||||
outdoorMarkers: () => [] as OutdoorMapMarker[],
|
||||
activeOutdoorMarkerId: '',
|
||||
outdoorFocusOffsetY: 0
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
markerClick: [markerId: number]
|
||||
outdoorMarkerClick: [markerId: string]
|
||||
floorChange: [floor: string]
|
||||
enter3DMode: []
|
||||
mapTap: [location: { latitude: number; longitude: number }]
|
||||
@@ -138,9 +153,11 @@ const mapScale = ref(17)
|
||||
// 当前楼层
|
||||
const currentFloor = ref(props.activeFloor)
|
||||
const mapComponentRef = ref<unknown>(null)
|
||||
const userLocationMarker = ref<Marker | null>(null)
|
||||
let mapAuthErrorObserver: MutationObserver | null = null
|
||||
let mapAuthErrorLogged = false
|
||||
const mapAuthErrorText = '鉴权失败,请传入正确的key'
|
||||
const OUTDOOR_MARKER_ID_BASE = 20000
|
||||
|
||||
// 楼层列表
|
||||
const floors = computed(() => props.floors)
|
||||
@@ -213,8 +230,46 @@ const entranceMarker = ref<Marker>({
|
||||
}
|
||||
})
|
||||
|
||||
const outdoorMarkerIdMap = computed(() => {
|
||||
const markerMap = new Map<number, string>()
|
||||
props.outdoorMarkers.forEach((marker, index) => {
|
||||
markerMap.set(OUTDOOR_MARKER_ID_BASE + index, marker.id)
|
||||
})
|
||||
return markerMap
|
||||
})
|
||||
|
||||
const outdoorDisplayMarkers = computed<Marker[]>(() => (
|
||||
props.outdoorMarkers.map((marker, index) => {
|
||||
const isActive = marker.id === props.activeOutdoorMarkerId
|
||||
|
||||
return {
|
||||
id: OUTDOOR_MARKER_ID_BASE + index,
|
||||
latitude: marker.latitude,
|
||||
longitude: marker.longitude,
|
||||
iconPath: '/static/icons/marker-location.svg',
|
||||
width: isActive ? 42 : 32,
|
||||
height: isActive ? 42 : 32,
|
||||
title: marker.title,
|
||||
callout: {
|
||||
content: marker.subtitle ? `${marker.title}\n${marker.subtitle}` : marker.title,
|
||||
display: isActive ? 'ALWAYS' : 'BYCLICK',
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
bgColor: isActive ? '#262421' : '#FFFFFF',
|
||||
color: isActive ? '#E0E100' : '#262421',
|
||||
fontSize: 12
|
||||
}
|
||||
}
|
||||
})
|
||||
))
|
||||
|
||||
// 地图标记点列表
|
||||
const markers = computed(() => [museumMarker.value, entranceMarker.value])
|
||||
const markers = computed(() => [
|
||||
museumMarker.value,
|
||||
entranceMarker.value,
|
||||
...outdoorDisplayMarkers.value,
|
||||
...(userLocationMarker.value ? [userLocationMarker.value] : [])
|
||||
])
|
||||
|
||||
// 显示的路线列表(转换格式以适配 uni-app map 组件)
|
||||
const displayPolylines = computed(() => {
|
||||
@@ -233,8 +288,7 @@ const displayPolylines = computed(() => {
|
||||
* 更新用户位置标记
|
||||
*/
|
||||
const updateUserLocation = (location: { latitude: number; longitude: number }) => {
|
||||
const userMarkerIndex = markers.value.findIndex((m) => m.id === 999)
|
||||
const userMarker: Marker = {
|
||||
userLocationMarker.value = {
|
||||
id: 999,
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
@@ -252,22 +306,13 @@ const updateUserLocation = (location: { latitude: number; longitude: number }) =
|
||||
fontSize: 12
|
||||
}
|
||||
}
|
||||
|
||||
if (userMarkerIndex >= 0) {
|
||||
markers.value[userMarkerIndex] = userMarker
|
||||
} else {
|
||||
markers.value.push(userMarker)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除用户位置标记
|
||||
*/
|
||||
const clearUserLocation = () => {
|
||||
const userMarkerIndex = markers.value.findIndex((m) => m.id === 999)
|
||||
if (userMarkerIndex >= 0) {
|
||||
markers.value.splice(userMarkerIndex, 1)
|
||||
}
|
||||
userLocationMarker.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,6 +325,31 @@ const moveTo = (location: { latitude: number; longitude: number }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
||||
|
||||
const getLatitudeDegreesPerPixel = (latitude: number, scale: number) => {
|
||||
const latitudeRadians = latitude * Math.PI / 180
|
||||
const worldPixelSize = 256 * (2 ** scale)
|
||||
return 360 * Math.cos(latitudeRadians) / worldPixelSize
|
||||
}
|
||||
|
||||
const moveToWithVerticalOffset = (
|
||||
location: { latitude: number; longitude: number },
|
||||
offsetY: number
|
||||
) => {
|
||||
const effectiveOffsetY = clamp(offsetY, 0, 360)
|
||||
if (effectiveOffsetY <= 0) {
|
||||
moveTo(location)
|
||||
return
|
||||
}
|
||||
|
||||
const latitudeOffset = getLatitudeDegreesPerPixel(location.latitude, mapScale.value) * effectiveOffsetY
|
||||
mapCenter.value = {
|
||||
latitude: location.latitude - latitudeOffset,
|
||||
longitude: location.longitude
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
updateUserLocation,
|
||||
@@ -289,8 +359,12 @@ defineExpose({
|
||||
|
||||
// 处理标记点点击
|
||||
const handleMarkerTap = (e: any) => {
|
||||
const markerId = e.detail.markerId || e.markerId
|
||||
const markerId = Number(e.detail?.markerId ?? e.markerId)
|
||||
console.log('点击标记点:', markerId)
|
||||
const outdoorMarkerId = outdoorMarkerIdMap.value.get(markerId)
|
||||
if (outdoorMarkerId) {
|
||||
emit('outdoorMarkerClick', outdoorMarkerId)
|
||||
}
|
||||
emit('markerClick', markerId)
|
||||
}
|
||||
|
||||
@@ -328,8 +402,7 @@ const handleLocation = () => {
|
||||
}
|
||||
|
||||
// 添加或更新用户位置标记
|
||||
const userMarkerIndex = markers.value.findIndex(m => m.id === 999)
|
||||
const userMarker: Marker = {
|
||||
userLocationMarker.value = {
|
||||
id: 999,
|
||||
latitude: res.latitude,
|
||||
longitude: res.longitude,
|
||||
@@ -348,12 +421,6 @@ const handleLocation = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (userMarkerIndex >= 0) {
|
||||
markers.value[userMarkerIndex] = userMarker
|
||||
} else {
|
||||
markers.value.push(userMarker)
|
||||
}
|
||||
|
||||
console.log('定位成功:', res)
|
||||
},
|
||||
fail: (err) => {
|
||||
@@ -388,6 +455,23 @@ const handleEnter3D = () => {
|
||||
emit('enter3DMode')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.activeOutdoorMarkerId, props.outdoorMarkers, props.outdoorFocusOffsetY] as const,
|
||||
([activeOutdoorMarkerId, outdoorMarkers, outdoorFocusOffsetY]) => {
|
||||
if (!activeOutdoorMarkerId) return
|
||||
const activeMarker = outdoorMarkers.find((marker) => marker.id === activeOutdoorMarkerId)
|
||||
if (!activeMarker) return
|
||||
moveToWithVerticalOffset(
|
||||
{
|
||||
latitude: activeMarker.latitude,
|
||||
longitude: activeMarker.longitude
|
||||
},
|
||||
outdoorFocusOffsetY
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const getMapComponentElement = () => {
|
||||
const rawRef = mapComponentRef.value
|
||||
if (rawRef instanceof HTMLElement) return rawRef
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
@tap="collapsePanel"
|
||||
></view>
|
||||
<view
|
||||
ref="arrivalPanelRef"
|
||||
v-if="visible"
|
||||
class="arrival-panel"
|
||||
:class="{ collapsed: isCollapsed }"
|
||||
@@ -13,7 +14,7 @@
|
||||
<view v-if="isCollapsed" class="arrival-collapsed" @tap="expandPanel">
|
||||
<view class="arrival-collapsed-copy">
|
||||
<text class="arrival-collapsed-title">来馆</text>
|
||||
<text class="arrival-collapsed-summary">公交 / 地铁 / 停车</text>
|
||||
<text class="arrival-collapsed-summary">{{ selectedTarget?.title || activeTypeLabel }}</text>
|
||||
</view>
|
||||
<view class="arrival-collapsed-action">
|
||||
<text class="arrival-collapsed-action-text">展开</text>
|
||||
@@ -21,83 +22,64 @@
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="arrival-handle" @tap="collapsePanel"></view>
|
||||
<view class="arrival-handle" @tap="collapsePanel"></view>
|
||||
|
||||
<view class="arrival-header">
|
||||
<view class="arrival-title-group">
|
||||
<text class="arrival-kicker">来馆</text>
|
||||
<text class="arrival-title">选择第三方地图检索目标</text>
|
||||
</view>
|
||||
<view class="arrival-collapse" @tap="collapsePanel">
|
||||
<text class="arrival-collapse-text">收起</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="arrival-notes">
|
||||
<text class="arrival-note">公交站、停车场信息以第三方地图实时结果为准</text>
|
||||
<text class="arrival-note">暂未接入停车余位</text>
|
||||
</view>
|
||||
|
||||
<view class="arrival-section">
|
||||
<text class="arrival-section-title">导航到场馆</text>
|
||||
<view
|
||||
v-for="target in venueTargets"
|
||||
:key="target.id"
|
||||
class="arrival-target-row"
|
||||
@tap="handleTargetSelect(target)"
|
||||
>
|
||||
<view class="arrival-target-dot"></view>
|
||||
<view class="arrival-target-copy">
|
||||
<text class="arrival-target-title">{{ target.title }}</text>
|
||||
<text class="arrival-target-keyword">{{ target.keyword }}</text>
|
||||
<view class="arrival-header">
|
||||
<view class="arrival-title-group">
|
||||
<text class="arrival-kicker">来馆</text>
|
||||
</view>
|
||||
<text class="arrival-target-action">选择地图</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="arrival-section">
|
||||
<text class="arrival-section-title">推荐公共交通</text>
|
||||
<view
|
||||
v-for="target in transitTargets"
|
||||
:key="target.id"
|
||||
class="arrival-target-row"
|
||||
@tap="handleTargetSelect(target)"
|
||||
>
|
||||
<view class="arrival-target-dot"></view>
|
||||
<view class="arrival-target-copy">
|
||||
<text class="arrival-target-title">{{ target.title }}</text>
|
||||
<text class="arrival-target-keyword">{{ target.keyword }}</text>
|
||||
<view class="arrival-collapse" @tap="collapsePanel">
|
||||
<text class="arrival-collapse-text">收起</text>
|
||||
</view>
|
||||
<text class="arrival-target-action">选择地图</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="arrival-section">
|
||||
<text class="arrival-section-title">实时查询</text>
|
||||
<view
|
||||
v-for="target in realtimeTargets"
|
||||
:key="target.id"
|
||||
class="arrival-target-row"
|
||||
@tap="handleTargetSelect(target)"
|
||||
>
|
||||
<view class="arrival-target-dot"></view>
|
||||
<view class="arrival-target-copy">
|
||||
<text class="arrival-target-title">{{ target.title }}</text>
|
||||
<text class="arrival-target-keyword">{{ target.keyword }}</text>
|
||||
<view class="arrival-tabs">
|
||||
<view
|
||||
v-for="option in typeOptions"
|
||||
:key="option.type"
|
||||
class="arrival-tab"
|
||||
:class="{ active: option.type === activeType }"
|
||||
@tap="handleTypeChange(option.type)"
|
||||
>
|
||||
<text class="arrival-tab-text">{{ option.label }}</text>
|
||||
</view>
|
||||
<text class="arrival-target-action">选择地图</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="arrival-list">
|
||||
<view
|
||||
v-for="target in targets"
|
||||
:key="target.id"
|
||||
class="arrival-target-row"
|
||||
:class="{ active: target.id === selectedTargetId }"
|
||||
@tap="handleTargetSelect(target)"
|
||||
>
|
||||
<view class="arrival-target-dot"></view>
|
||||
<view class="arrival-target-copy">
|
||||
<text class="arrival-target-title">{{ target.title }}</text>
|
||||
<text class="arrival-target-subtitle">{{ target.subtitle }}</text>
|
||||
<text v-if="target.description" class="arrival-target-desc">{{ target.description }}</text>
|
||||
</view>
|
||||
<text v-if="target.id === selectedTargetId" class="arrival-target-state">已选</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="arrival-primary"
|
||||
:class="{ disabled: !selectedTarget }"
|
||||
@tap="handleNavigateTap"
|
||||
>
|
||||
<text class="arrival-primary-text">第三方导航</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="selectedTarget"
|
||||
v-if="providerSheetVisible && selectedTarget"
|
||||
class="provider-scrim"
|
||||
@tap="closeProviderSheet"
|
||||
></view>
|
||||
<view
|
||||
v-if="selectedTarget"
|
||||
v-if="providerSheetVisible && selectedTarget"
|
||||
class="provider-sheet"
|
||||
@tap.stop
|
||||
>
|
||||
@@ -126,12 +108,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import {
|
||||
ARRIVAL_REALTIME_TARGETS,
|
||||
ARRIVAL_TRANSIT_TARGETS,
|
||||
ARRIVAL_VENUE_TARGETS,
|
||||
type ArrivalSearchTarget
|
||||
ARRIVAL_TARGET_TYPES,
|
||||
type ArrivalSearchTarget,
|
||||
type ArrivalTargetType
|
||||
} from '@/data/arrivalSearchTargets'
|
||||
import {
|
||||
openThirdPartyMapSearch,
|
||||
@@ -141,25 +122,44 @@ import {
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible?: boolean
|
||||
defaultCollapsed?: boolean
|
||||
activeType: ArrivalTargetType
|
||||
targets?: ArrivalSearchTarget[]
|
||||
selectedTargetId?: string
|
||||
selectedTarget?: ArrivalSearchTarget | null
|
||||
}>(), {
|
||||
visible: false,
|
||||
defaultCollapsed: false
|
||||
targets: () => [] as ArrivalSearchTarget[],
|
||||
selectedTargetId: '',
|
||||
selectedTarget: null
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:activeType': [type: ArrivalTargetType]
|
||||
selectTarget: [target: ArrivalSearchTarget]
|
||||
collapsedChange: [collapsed: boolean]
|
||||
layoutChange: [layout: { height: number; collapsed: boolean }]
|
||||
}>()
|
||||
|
||||
const venueTargets = computed(() => ARRIVAL_VENUE_TARGETS)
|
||||
const transitTargets = computed(() => ARRIVAL_TRANSIT_TARGETS)
|
||||
const realtimeTargets = computed(() => ARRIVAL_REALTIME_TARGETS)
|
||||
const typeOptions = computed(() => ARRIVAL_TARGET_TYPES)
|
||||
const mapProviders = computed(() => THIRD_PARTY_MAP_PROVIDERS)
|
||||
const selectedTarget = ref<ArrivalSearchTarget | null>(null)
|
||||
const arrivalPanelRef = ref<unknown>(null)
|
||||
const isCollapsed = ref(false)
|
||||
const providerSheetVisible = ref(false)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const activeTypeLabel = computed(() => (
|
||||
typeOptions.value.find((option) => option.type === props.activeType)?.label || '来馆'
|
||||
))
|
||||
|
||||
const handleTypeChange = (type: ArrivalTargetType) => {
|
||||
if (type === props.activeType) return
|
||||
closeProviderSheet()
|
||||
emit('update:activeType', type)
|
||||
}
|
||||
|
||||
const handleTargetSelect = (target: ArrivalSearchTarget) => {
|
||||
selectedTarget.value = target
|
||||
closeProviderSheet()
|
||||
emit('selectTarget', target)
|
||||
}
|
||||
|
||||
const collapsePanel = () => {
|
||||
@@ -173,11 +173,76 @@ const expandPanel = () => {
|
||||
}
|
||||
|
||||
const closeProviderSheet = () => {
|
||||
selectedTarget.value = null
|
||||
providerSheetVisible.value = false
|
||||
}
|
||||
|
||||
const getArrivalPanelElement = () => {
|
||||
const rawRef = arrivalPanelRef.value
|
||||
if (rawRef instanceof HTMLElement) return rawRef
|
||||
|
||||
const maybeComponent = rawRef as { $el?: Element } | null
|
||||
if (maybeComponent?.$el instanceof HTMLElement) return maybeComponent.$el
|
||||
|
||||
if (typeof document === 'undefined') return null
|
||||
return document.querySelector<HTMLElement>('.arrival-panel')
|
||||
}
|
||||
|
||||
const emitLayoutChange = () => {
|
||||
if (!props.visible) {
|
||||
emit('layoutChange', { height: 0, collapsed: false })
|
||||
return
|
||||
}
|
||||
|
||||
void nextTick(() => {
|
||||
const panelElement = getArrivalPanelElement()
|
||||
emit('layoutChange', {
|
||||
height: panelElement?.getBoundingClientRect().height || 0,
|
||||
collapsed: isCollapsed.value
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const disconnectLayoutObserver = () => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
|
||||
const startLayoutObserver = () => {
|
||||
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
|
||||
emitLayoutChange()
|
||||
return
|
||||
}
|
||||
|
||||
void nextTick(() => {
|
||||
const panelElement = getArrivalPanelElement()
|
||||
if (!panelElement) {
|
||||
emitLayoutChange()
|
||||
return
|
||||
}
|
||||
|
||||
disconnectLayoutObserver()
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
emitLayoutChange()
|
||||
})
|
||||
resizeObserver.observe(panelElement)
|
||||
emitLayoutChange()
|
||||
})
|
||||
}
|
||||
|
||||
const handleNavigateTap = () => {
|
||||
if (!props.selectedTarget) {
|
||||
uni.showToast({
|
||||
title: '请先选择点位',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
providerSheetVisible.value = true
|
||||
}
|
||||
|
||||
const handleProviderSelect = (provider: ThirdPartyMapProviderOption) => {
|
||||
const target = selectedTarget.value
|
||||
const target = props.selectedTarget
|
||||
if (!target) return
|
||||
|
||||
openThirdPartyMapSearch(provider.provider, {
|
||||
@@ -190,16 +255,37 @@ watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
isCollapsed.value = props.defaultCollapsed
|
||||
emit('collapsedChange', props.defaultCollapsed)
|
||||
} else {
|
||||
selectedTarget.value = null
|
||||
isCollapsed.value = false
|
||||
emit('collapsedChange', false)
|
||||
startLayoutObserver()
|
||||
} else {
|
||||
closeProviderSheet()
|
||||
isCollapsed.value = false
|
||||
disconnectLayoutObserver()
|
||||
emit('collapsedChange', false)
|
||||
emit('layoutChange', { height: 0, collapsed: false })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selectedTargetId,
|
||||
() => {
|
||||
closeProviderSheet()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.targets.length, isCollapsed.value] as const,
|
||||
() => {
|
||||
startLayoutObserver()
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnectLayoutObserver()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
expandPanel
|
||||
})
|
||||
@@ -210,7 +296,7 @@ defineExpose({
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2090;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.arrival-panel {
|
||||
@@ -219,8 +305,8 @@ defineExpose({
|
||||
bottom: calc(env(safe-area-inset-bottom) + 16px);
|
||||
z-index: 2100;
|
||||
width: min(430px, calc(100vw - 24px));
|
||||
max-height: min(72vh, 620px);
|
||||
padding: 10px 14px 16px;
|
||||
max-height: min(58vh, 520px);
|
||||
padding: 10px 14px 14px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
@@ -346,12 +432,115 @@ defineExpose({
|
||||
color: #424754;
|
||||
}
|
||||
|
||||
.arrival-notes {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
.arrival-tabs {
|
||||
height: 40px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
margin-top: 14px;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
background: #f3f3f3;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.arrival-tab {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.arrival-tab.active {
|
||||
background: #262421;
|
||||
}
|
||||
|
||||
.arrival-tab-text {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 700;
|
||||
color: #424754;
|
||||
}
|
||||
|
||||
.arrival-tab.active .arrival-tab-text {
|
||||
color: #e0e100;
|
||||
}
|
||||
|
||||
.arrival-list {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.arrival-target-row {
|
||||
min-height: 68px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 8px;
|
||||
border-top: 1px solid #eceee7;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.arrival-target-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.arrival-target-row.active {
|
||||
background: #f5f5ed;
|
||||
}
|
||||
|
||||
.arrival-target-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
background: #d7d9cf;
|
||||
border: 2px solid #696962;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.arrival-target-row.active .arrival-target-dot {
|
||||
background: #e0e100;
|
||||
border-color: #262421;
|
||||
}
|
||||
|
||||
.arrival-target-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.arrival-target-title {
|
||||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
font-weight: 700;
|
||||
color: #262421;
|
||||
}
|
||||
|
||||
.arrival-target-subtitle,
|
||||
.arrival-target-desc {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: #696962;
|
||||
}
|
||||
|
||||
.arrival-target-desc {
|
||||
color: #424754;
|
||||
}
|
||||
|
||||
.arrival-target-state {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
font-weight: 700;
|
||||
color: #262421;
|
||||
}
|
||||
|
||||
.arrival-notes {
|
||||
margin-top: 10px;
|
||||
padding: 9px 10px;
|
||||
background: #f5f5ed;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -362,68 +551,25 @@ defineExpose({
|
||||
color: #424754;
|
||||
}
|
||||
|
||||
.arrival-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.arrival-section-title {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 700;
|
||||
color: #262421;
|
||||
}
|
||||
|
||||
.arrival-target-row {
|
||||
min-height: 58px;
|
||||
.arrival-primary {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid #eceee7;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
background: #262421;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.arrival-section-title + .arrival-target-row {
|
||||
border-top: 0;
|
||||
.arrival-primary.disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.arrival-target-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
flex-shrink: 0;
|
||||
background: #e0e100;
|
||||
border: 2px solid #262421;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.arrival-target-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.arrival-target-title {
|
||||
.arrival-primary-text {
|
||||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
font-weight: 600;
|
||||
color: #262421;
|
||||
}
|
||||
|
||||
.arrival-target-keyword {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: #696962;
|
||||
}
|
||||
|
||||
.arrival-target-action {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
font-weight: 600;
|
||||
color: #262421;
|
||||
font-weight: 700;
|
||||
color: #e0e100;
|
||||
}
|
||||
|
||||
.provider-scrim {
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
:active-floor="activeFloor"
|
||||
:floors="props.floors"
|
||||
:polylines="outdoorNavPolylines"
|
||||
:outdoor-markers="outdoorMarkers"
|
||||
:active-outdoor-marker-id="activeOutdoorMarkerId"
|
||||
:outdoor-focus-offset-y="outdoorFocusOffsetY"
|
||||
@outdoor-marker-click="handleOutdoorMarkerClick"
|
||||
@map-tap="handleMapTap"
|
||||
/>
|
||||
</view>
|
||||
@@ -265,6 +269,14 @@ interface OutdoorNavPolyline {
|
||||
width: number
|
||||
}
|
||||
|
||||
interface OutdoorMapMarker {
|
||||
id: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
type CameraViewMode = 'reset' | 'top' | 'oblique'
|
||||
|
||||
interface TargetPoiFocusRequest {
|
||||
@@ -347,6 +359,9 @@ const props = withDefaults(defineProps<{
|
||||
autoSwitchThresholdLow?: number
|
||||
autoSwitchThresholdHigh?: number
|
||||
outdoorNavPolylines?: OutdoorNavPolyline[]
|
||||
outdoorMarkers?: OutdoorMapMarker[]
|
||||
activeOutdoorMarkerId?: string
|
||||
outdoorFocusOffsetY?: number
|
||||
}>(), {
|
||||
searchText: '请输入地点进行搜索',
|
||||
activeMode: '3d',
|
||||
@@ -395,7 +410,10 @@ const props = withDefaults(defineProps<{
|
||||
disableAutoExit: false,
|
||||
autoSwitchThresholdLow: 1.0,
|
||||
autoSwitchThresholdHigh: 1.3,
|
||||
outdoorNavPolylines: () => [] as OutdoorNavPolyline[]
|
||||
outdoorNavPolylines: () => [] as OutdoorNavPolyline[],
|
||||
outdoorMarkers: () => [] as OutdoorMapMarker[],
|
||||
activeOutdoorMarkerId: '',
|
||||
outdoorFocusOffsetY: 0
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -416,6 +434,7 @@ const emit = defineEmits<{
|
||||
initialModelReady: [event: { view: IndoorViewMode; floorId?: string; elapsedMs?: number }]
|
||||
initialModelFailed: [event: { view: IndoorViewMode; floorId?: string; message: string; elapsedMs?: number }]
|
||||
mapTap: [location: { latitude: number; longitude: number }]
|
||||
outdoorMarkerClick: [markerId: string]
|
||||
}>()
|
||||
|
||||
// 监听视角切换,循环切换:reset -> top -> oblique -> reset
|
||||
@@ -739,6 +758,10 @@ const handleMapTap = (location: { latitude?: number; longitude?: number }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOutdoorMarkerClick = (markerId: string) => {
|
||||
emit('outdoorMarkerClick', markerId)
|
||||
}
|
||||
|
||||
// 暴露 clearRoute 方法供父组件显式调用路线清除
|
||||
defineExpose({
|
||||
clearRoute: () => {
|
||||
|
||||
@@ -1,89 +1,123 @@
|
||||
export type ArrivalTargetGroup = 'venue' | 'transit' | 'realtime'
|
||||
export type ArrivalRealtimeKind = 'bus' | 'parking'
|
||||
export type ArrivalTargetType = 'bus' | 'metro' | 'parking'
|
||||
|
||||
export interface ArrivalTargetTypeOption {
|
||||
type: ArrivalTargetType
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface ArrivalSearchTarget {
|
||||
id: string
|
||||
type: ArrivalTargetType
|
||||
title: string
|
||||
subtitle: string
|
||||
description?: string
|
||||
keyword: string
|
||||
region: string
|
||||
group: ArrivalTargetGroup
|
||||
realtimeKind?: ArrivalRealtimeKind
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
export const ARRIVAL_SEARCH_REGION = '深圳市'
|
||||
|
||||
export const ARRIVAL_SEARCH_TARGETS: ArrivalSearchTarget[] = [
|
||||
{
|
||||
id: 'shenzhen-natural-history-museum',
|
||||
title: '深圳自然博物馆',
|
||||
keyword: '深圳自然博物馆',
|
||||
region: ARRIVAL_SEARCH_REGION,
|
||||
group: 'venue'
|
||||
},
|
||||
{
|
||||
id: 'shabo-station-exit-d',
|
||||
title: '沙壆站 D口',
|
||||
keyword: '沙壆站D口',
|
||||
region: ARRIVAL_SEARCH_REGION,
|
||||
group: 'transit'
|
||||
},
|
||||
{
|
||||
id: 'natural-museum-west-station',
|
||||
title: '自然博物馆西站',
|
||||
keyword: '自然博物馆西站',
|
||||
region: ARRIVAL_SEARCH_REGION,
|
||||
group: 'transit'
|
||||
},
|
||||
export const ARRIVAL_TARGET_TYPES: ArrivalTargetTypeOption[] = [
|
||||
{ type: 'bus', label: '公交' },
|
||||
{ type: 'metro', label: '地铁' },
|
||||
{ type: 'parking', label: '停车场' }
|
||||
]
|
||||
|
||||
export const ARRIVAL_TARGETS: ArrivalSearchTarget[] = [
|
||||
{
|
||||
id: 'museum-bus-stop',
|
||||
title: '深圳自然博物馆 公交站',
|
||||
type: 'bus',
|
||||
title: '深圳自然博物馆公交站',
|
||||
subtitle: '场馆周边公交接驳点',
|
||||
description: '适合到馆前在第三方地图中继续查看实时公交和步行路径。',
|
||||
keyword: '深圳自然博物馆 公交站',
|
||||
region: ARRIVAL_SEARCH_REGION,
|
||||
group: 'realtime',
|
||||
realtimeKind: 'bus'
|
||||
},
|
||||
{
|
||||
id: 'shabo-metro-bus-stop',
|
||||
title: '沙壆地铁站 公交站',
|
||||
keyword: '沙壆地铁站 公交站',
|
||||
region: ARRIVAL_SEARCH_REGION,
|
||||
group: 'realtime',
|
||||
realtimeKind: 'bus'
|
||||
latitude: 22.69258,
|
||||
longitude: 114.36372
|
||||
},
|
||||
{
|
||||
id: 'natural-museum-west-bus-stop',
|
||||
title: '自然博物馆西 公交站',
|
||||
type: 'bus',
|
||||
title: '自然博物馆西公交站',
|
||||
subtitle: '龙坪路周边公交接驳点',
|
||||
description: '靠近坪山云巴自然博物馆西站,可作为公共交通换乘参考。',
|
||||
keyword: '自然博物馆西 公交站',
|
||||
region: ARRIVAL_SEARCH_REGION,
|
||||
group: 'realtime',
|
||||
realtimeKind: 'bus'
|
||||
latitude: 22.69193,
|
||||
longitude: 114.35821
|
||||
},
|
||||
{
|
||||
id: 'shabo-metro-bus-stop',
|
||||
type: 'bus',
|
||||
title: '沙壆地铁站公交站',
|
||||
subtitle: '地铁 16 号线周边公交接驳',
|
||||
description: '适合先到沙壆站后换乘公交或步行前往场馆。',
|
||||
keyword: '沙壆地铁站 公交站',
|
||||
region: ARRIVAL_SEARCH_REGION,
|
||||
latitude: 22.68676,
|
||||
longitude: 114.36441
|
||||
},
|
||||
{
|
||||
id: 'shabo-station-exit-d',
|
||||
type: 'metro',
|
||||
title: '沙壆站 D口',
|
||||
subtitle: '地铁 16 号线出口',
|
||||
description: '出站后请以第三方地图展示的步行路线为准。',
|
||||
keyword: '沙壆站D口',
|
||||
region: ARRIVAL_SEARCH_REGION,
|
||||
latitude: 22.68676,
|
||||
longitude: 114.36441
|
||||
},
|
||||
{
|
||||
id: 'natural-museum-west-station',
|
||||
type: 'metro',
|
||||
title: '自然博物馆西站',
|
||||
subtitle: '坪山云巴 1 号线',
|
||||
description: '云巴站点位于龙坪路与三洋湖工业一路交叉口以北。',
|
||||
keyword: '自然博物馆西站',
|
||||
region: ARRIVAL_SEARCH_REGION,
|
||||
latitude: 22.69193,
|
||||
longitude: 114.35821
|
||||
},
|
||||
{
|
||||
id: 'museum-parking',
|
||||
title: '深圳自然博物馆 停车场',
|
||||
type: 'parking',
|
||||
title: '深圳自然博物馆停车场',
|
||||
subtitle: '场馆停车点位',
|
||||
description: '停车开放规则、入口和余位请以现场及第三方地图为准。',
|
||||
keyword: '深圳自然博物馆 停车场',
|
||||
region: ARRIVAL_SEARCH_REGION,
|
||||
group: 'realtime',
|
||||
realtimeKind: 'parking'
|
||||
latitude: 22.69228,
|
||||
longitude: 114.36318
|
||||
},
|
||||
{
|
||||
id: 'pingshan-cultural-cluster-parking',
|
||||
title: '坪山文化聚落 停车场',
|
||||
type: 'parking',
|
||||
title: '坪山文化聚落停车场',
|
||||
subtitle: '周边文化设施停车参考',
|
||||
description: '适合作为周边停车备选,具体开放情况请以第三方地图为准。',
|
||||
keyword: '坪山文化聚落 停车场',
|
||||
region: ARRIVAL_SEARCH_REGION,
|
||||
group: 'realtime',
|
||||
realtimeKind: 'parking'
|
||||
latitude: 22.69618,
|
||||
longitude: 114.34652
|
||||
},
|
||||
{
|
||||
id: 'yanzi-lake-area-parking',
|
||||
title: '燕子湖片区 停车场',
|
||||
type: 'parking',
|
||||
title: '燕子湖片区停车场',
|
||||
subtitle: '片区停车参考',
|
||||
description: '请在第三方地图中确认入口、距离与可停情况。',
|
||||
keyword: '燕子湖片区 停车场',
|
||||
region: ARRIVAL_SEARCH_REGION,
|
||||
group: 'realtime',
|
||||
realtimeKind: 'parking'
|
||||
latitude: 22.69084,
|
||||
longitude: 114.36522
|
||||
}
|
||||
]
|
||||
|
||||
export const ARRIVAL_VENUE_TARGETS = ARRIVAL_SEARCH_TARGETS.filter((target) => target.group === 'venue')
|
||||
export const ARRIVAL_TRANSIT_TARGETS = ARRIVAL_SEARCH_TARGETS.filter((target) => target.group === 'transit')
|
||||
export const ARRIVAL_REALTIME_TARGETS = ARRIVAL_SEARCH_TARGETS.filter((target) => target.group === 'realtime')
|
||||
export const ARRIVAL_TARGETS_BY_TYPE: Record<ArrivalTargetType, ArrivalSearchTarget[]> = {
|
||||
bus: ARRIVAL_TARGETS.filter((target) => target.type === 'bus'),
|
||||
metro: ARRIVAL_TARGETS.filter((target) => target.type === 'metro'),
|
||||
parking: ARRIVAL_TARGETS.filter((target) => target.type === 'parking')
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
zoom-controls-top="calc(100vh - 292px)"
|
||||
:route-preview="activeRoutePreview"
|
||||
:show-route="Boolean(activeRoutePreview)"
|
||||
:outdoor-markers="arrivalOutdoorMarkers"
|
||||
:active-outdoor-marker-id="activeArrivalMarkerId"
|
||||
:outdoor-focus-offset-y="arrivalOutdoorFocusOffsetY"
|
||||
:disable-auto-exit="disableIndoorAutoExit"
|
||||
:auto-switch-threshold-low="0.58"
|
||||
:auto-switch-threshold-high="1.18"
|
||||
@@ -55,6 +58,7 @@
|
||||
@initial-model-ready="handleInitialModelReady"
|
||||
@initial-model-failed="handleInitialModelFailed"
|
||||
@tool-click="handleIndoorToolClick"
|
||||
@outdoor-marker-click="handleArrivalMarkerClick"
|
||||
>
|
||||
<template #overlay>
|
||||
<view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-tip">
|
||||
@@ -230,8 +234,14 @@
|
||||
ref="arrivalPanelRef"
|
||||
v-if="currentTab === 'guide'"
|
||||
:visible="showArrivalPanel"
|
||||
:default-collapsed="true"
|
||||
:active-type="activeArrivalType"
|
||||
:targets="activeArrivalTargets"
|
||||
:selected-target-id="selectedArrivalTargetId"
|
||||
:selected-target="selectedArrivalTarget"
|
||||
@update:active-type="handleArrivalTypeChange"
|
||||
@select-target="handleArrivalTargetSelect"
|
||||
@collapsed-change="handleArrivalPanelCollapsedChange"
|
||||
@layout-change="handleArrivalPanelLayoutChange"
|
||||
/>
|
||||
|
||||
<view v-else-if="currentTab === 'explain'" class="explain-page">
|
||||
@@ -332,6 +342,11 @@ import {
|
||||
import type {
|
||||
GuideRenderPoi
|
||||
} from '@/domain/guideModel'
|
||||
import {
|
||||
ARRIVAL_TARGETS_BY_TYPE,
|
||||
type ArrivalSearchTarget,
|
||||
type ArrivalTargetType
|
||||
} from '@/data/arrivalSearchTargets'
|
||||
import type {
|
||||
GuideRouteResult,
|
||||
GuideRouteTarget,
|
||||
@@ -563,6 +578,9 @@ const handleLaunchContinue = () => {
|
||||
const showArrivalPanel = ref(false)
|
||||
const showArrivalPanelCollapsed = ref(false)
|
||||
const arrivalPanelRef = ref<InstanceType<typeof ArrivalPanel> | null>(null)
|
||||
const activeArrivalType = ref<ArrivalTargetType>('bus')
|
||||
const selectedArrivalTargetId = ref('')
|
||||
const arrivalPanelHeight = ref(0)
|
||||
|
||||
const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl()
|
||||
const indoorModelSource = guideUseCase.getModelSource()
|
||||
@@ -637,6 +655,35 @@ const showGuideFloatingActions = computed(() => (
|
||||
&& (!showGuideHomeDock.value || !homeSearchExpanded.value)
|
||||
))
|
||||
|
||||
const activeArrivalTargets = computed(() => ARRIVAL_TARGETS_BY_TYPE[activeArrivalType.value])
|
||||
|
||||
const selectedArrivalTarget = computed(() => (
|
||||
activeArrivalTargets.value.find((target) => target.id === selectedArrivalTargetId.value)
|
||||
|| activeArrivalTargets.value[0]
|
||||
|| null
|
||||
))
|
||||
|
||||
const arrivalOutdoorMarkers = computed(() => {
|
||||
if (!showArrivalPanel.value || is3DMode.value) return []
|
||||
|
||||
return activeArrivalTargets.value.map((target) => ({
|
||||
id: target.id,
|
||||
title: target.title,
|
||||
subtitle: target.subtitle,
|
||||
latitude: target.latitude,
|
||||
longitude: target.longitude
|
||||
}))
|
||||
})
|
||||
|
||||
const activeArrivalMarkerId = computed(() => (
|
||||
showArrivalPanel.value && !is3DMode.value ? selectedArrivalTarget.value?.id || '' : ''
|
||||
))
|
||||
|
||||
const arrivalOutdoorFocusOffsetY = computed(() => {
|
||||
if (!showArrivalPanel.value || showArrivalPanelCollapsed.value || is3DMode.value) return 0
|
||||
return Math.min(260, Math.max(0, Math.round(arrivalPanelHeight.value / 2 + 24)))
|
||||
})
|
||||
|
||||
const guideQuickActiveAction = computed<'indoor' | 'arrival' | 'explain'>(() => {
|
||||
if (currentTab.value === 'explain') return 'explain'
|
||||
if (showArrivalPanel.value) return 'arrival'
|
||||
@@ -1295,15 +1342,46 @@ const handleMoreRouteGuide = () => {
|
||||
showIndoorHint(`当前楼层:${getGuideFloorLabel(renderedFloorId.value || activeGuideFloor.value) || '馆内单层'}`, 3200)
|
||||
}
|
||||
|
||||
const selectFirstArrivalTarget = () => {
|
||||
selectedArrivalTargetId.value = activeArrivalTargets.value[0]?.id || ''
|
||||
}
|
||||
|
||||
const resetArrivalPanelState = () => {
|
||||
activeArrivalType.value = 'bus'
|
||||
selectFirstArrivalTarget()
|
||||
}
|
||||
|
||||
const closeArrivalPanel = () => {
|
||||
showArrivalPanel.value = false
|
||||
showArrivalPanelCollapsed.value = false
|
||||
selectedArrivalTargetId.value = ''
|
||||
arrivalPanelHeight.value = 0
|
||||
}
|
||||
|
||||
const handleArrivalPanelCollapsedChange = (collapsed: boolean) => {
|
||||
showArrivalPanelCollapsed.value = collapsed
|
||||
}
|
||||
|
||||
const handleArrivalPanelLayoutChange = (layout: { height: number; collapsed: boolean }) => {
|
||||
arrivalPanelHeight.value = layout.height
|
||||
showArrivalPanelCollapsed.value = layout.collapsed
|
||||
}
|
||||
|
||||
const handleArrivalTypeChange = (type: ArrivalTargetType) => {
|
||||
activeArrivalType.value = type
|
||||
selectFirstArrivalTarget()
|
||||
}
|
||||
|
||||
const handleArrivalTargetSelect = (target: ArrivalSearchTarget) => {
|
||||
selectedArrivalTargetId.value = target.id
|
||||
}
|
||||
|
||||
const handleArrivalMarkerClick = (markerId: string) => {
|
||||
const target = activeArrivalTargets.value.find((item) => item.id === markerId)
|
||||
if (!target) return
|
||||
selectedArrivalTargetId.value = target.id
|
||||
}
|
||||
|
||||
const closeHomeSearchDock = () => {
|
||||
homeSearchPanelRef.value?.collapseHomePanel?.()
|
||||
homeSearchExpanded.value = false
|
||||
@@ -1316,8 +1394,9 @@ const handleMoreOutdoorNav = () => {
|
||||
return
|
||||
}
|
||||
|
||||
resetArrivalPanelState()
|
||||
showArrivalPanel.value = true
|
||||
showArrivalPanelCollapsed.value = true
|
||||
showArrivalPanelCollapsed.value = false
|
||||
showRoutePlanner.value = false
|
||||
is3DMode.value = false
|
||||
guideOutdoorState.value = 'home'
|
||||
|
||||
Reference in New Issue
Block a user