701 lines
14 KiB
Vue
701 lines
14 KiB
Vue
<template>
|
||
<view
|
||
v-if="visible && !isCollapsed"
|
||
class="arrival-scrim"
|
||
@tap="collapsePanel"
|
||
></view>
|
||
<view
|
||
ref="arrivalPanelRef"
|
||
v-if="visible"
|
||
class="arrival-panel"
|
||
:class="{ collapsed: isCollapsed }"
|
||
@tap.stop
|
||
>
|
||
<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">{{ selectedTarget?.title || activeTypeLabel }}</text>
|
||
</view>
|
||
<view class="arrival-collapsed-action">
|
||
<text class="arrival-collapsed-action-text">展开</text>
|
||
</view>
|
||
</view>
|
||
|
||
<template v-else>
|
||
<view class="arrival-handle" @tap="collapsePanel"></view>
|
||
|
||
<view class="arrival-header">
|
||
<view class="arrival-title-group">
|
||
<text class="arrival-kicker">来馆</text>
|
||
</view>
|
||
<view class="arrival-collapse" @tap="collapsePanel">
|
||
<text class="arrival-collapse-text">收起</text>
|
||
</view>
|
||
</view>
|
||
|
||
<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>
|
||
</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">{{ primaryActionText }}</text>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
|
||
<view
|
||
v-if="providerSheetVisible && selectedTarget"
|
||
class="provider-scrim"
|
||
@tap="closeProviderSheet"
|
||
></view>
|
||
<view
|
||
v-if="providerSheetVisible && selectedTarget"
|
||
class="provider-sheet"
|
||
@tap.stop
|
||
>
|
||
<view class="provider-header">
|
||
<view class="provider-title-group">
|
||
<text class="provider-kicker">选择地图</text>
|
||
<text class="provider-title">{{ selectedTarget.title }}</text>
|
||
</view>
|
||
<view class="provider-close" @tap="closeProviderSheet">
|
||
<text class="provider-close-text">×</text>
|
||
</view>
|
||
</view>
|
||
<view
|
||
v-for="provider in mapProviders"
|
||
:key="provider.provider"
|
||
class="provider-row"
|
||
@tap="handleProviderSelect(provider)"
|
||
>
|
||
<view class="provider-mark">
|
||
<text class="provider-mark-text">{{ provider.label.slice(0, 1) }}</text>
|
||
</view>
|
||
<text class="provider-label">{{ provider.label }}</text>
|
||
<text class="provider-action">打开</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||
import {
|
||
ARRIVAL_TARGET_TYPES,
|
||
type ArrivalSearchTarget,
|
||
type ArrivalTargetType
|
||
} from '@/data/arrivalSearchTargets'
|
||
import {
|
||
openThirdPartyMapSearch,
|
||
openWechatMiniProgramLocation,
|
||
THIRD_PARTY_MAP_PROVIDERS,
|
||
type ThirdPartyMapProviderOption
|
||
} from '@/services/ThirdPartyMapSearchService'
|
||
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
|
||
|
||
const props = withDefaults(defineProps<{
|
||
visible?: boolean
|
||
activeType: ArrivalTargetType
|
||
targets?: ArrivalSearchTarget[]
|
||
selectedTargetId?: string
|
||
selectedTarget?: ArrivalSearchTarget | null
|
||
}>(), {
|
||
visible: 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 typeOptions = computed(() => ARRIVAL_TARGET_TYPES)
|
||
const mapProviders = computed(() => THIRD_PARTY_MAP_PROVIDERS)
|
||
const shouldUseMiniProgramLocation = computed(() => isEmbeddedInWechatMiniProgram())
|
||
const primaryActionText = computed(() => (
|
||
shouldUseMiniProgramLocation.value ? '打开地图导航' : '第三方导航'
|
||
))
|
||
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) => {
|
||
closeProviderSheet()
|
||
emit('selectTarget', target)
|
||
}
|
||
|
||
const collapsePanel = () => {
|
||
isCollapsed.value = true
|
||
emit('collapsedChange', true)
|
||
}
|
||
|
||
const expandPanel = () => {
|
||
isCollapsed.value = false
|
||
emit('collapsedChange', false)
|
||
}
|
||
|
||
const closeProviderSheet = () => {
|
||
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
|
||
}
|
||
|
||
if (shouldUseMiniProgramLocation.value) {
|
||
void openWechatMiniProgramLocation({
|
||
latitude: props.selectedTarget.latitude,
|
||
longitude: props.selectedTarget.longitude,
|
||
name: props.selectedTarget.title,
|
||
address: props.selectedTarget.subtitle
|
||
})
|
||
return
|
||
}
|
||
|
||
providerSheetVisible.value = true
|
||
}
|
||
|
||
const handleProviderSelect = (provider: ThirdPartyMapProviderOption) => {
|
||
const target = props.selectedTarget
|
||
if (!target) return
|
||
|
||
openThirdPartyMapSearch(provider.provider, {
|
||
keyword: target.keyword,
|
||
region: target.region
|
||
})
|
||
}
|
||
|
||
watch(
|
||
() => props.visible,
|
||
(visible) => {
|
||
if (visible) {
|
||
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
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.arrival-scrim {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 2090;
|
||
background: rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.arrival-panel {
|
||
position: fixed;
|
||
left: 50%;
|
||
bottom: calc(env(safe-area-inset-bottom) + 16px);
|
||
z-index: 2100;
|
||
width: min(430px, calc(100vw - 24px));
|
||
max-height: min(58vh, 520px);
|
||
padding: 10px 14px 14px;
|
||
overflow-y: auto;
|
||
box-sizing: border-box;
|
||
background: #ffffff;
|
||
border: 1px solid rgba(31, 35, 41, 0.08);
|
||
border-radius: 8px;
|
||
box-shadow: 0 10px 34px rgba(31, 35, 41, 0.18);
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.arrival-panel.collapsed {
|
||
bottom: calc(env(safe-area-inset-bottom) + 92px);
|
||
max-height: none;
|
||
padding: 0;
|
||
overflow: visible;
|
||
}
|
||
|
||
.arrival-collapsed {
|
||
min-height: 58px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 10px 10px 14px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.arrival-collapsed-copy {
|
||
min-width: 0;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.arrival-collapsed-title {
|
||
font-size: 16px;
|
||
line-height: 22px;
|
||
font-weight: 700;
|
||
color: #262421;
|
||
}
|
||
|
||
.arrival-collapsed-summary {
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-size: 12px;
|
||
line-height: 17px;
|
||
color: #696962;
|
||
}
|
||
|
||
.arrival-collapsed-action {
|
||
height: 34px;
|
||
min-width: 56px;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 12px;
|
||
box-sizing: border-box;
|
||
background: #262421;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.arrival-collapsed-action-text {
|
||
font-size: 13px;
|
||
line-height: 18px;
|
||
font-weight: 600;
|
||
color: #e0e100;
|
||
}
|
||
|
||
.arrival-handle {
|
||
width: 38px;
|
||
height: 4px;
|
||
margin: 0 auto 12px;
|
||
background: #d7d9cf;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.arrival-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.arrival-title-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
|
||
.arrival-kicker {
|
||
font-size: 12px;
|
||
line-height: 16px;
|
||
font-weight: 600;
|
||
color: #696962;
|
||
}
|
||
|
||
.arrival-title {
|
||
font-size: 18px;
|
||
line-height: 25px;
|
||
font-weight: 700;
|
||
color: #262421;
|
||
}
|
||
|
||
.arrival-collapse {
|
||
flex-shrink: 0;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 10px;
|
||
box-sizing: border-box;
|
||
border: 1px solid #d7d9cf;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.arrival-collapse-text {
|
||
font-size: 12px;
|
||
line-height: 17px;
|
||
font-weight: 600;
|
||
color: #424754;
|
||
}
|
||
|
||
.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: 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;
|
||
}
|
||
|
||
.arrival-note {
|
||
font-size: 12px;
|
||
line-height: 17px;
|
||
color: #424754;
|
||
}
|
||
|
||
.arrival-primary {
|
||
height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-top: 12px;
|
||
background: #262421;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.arrival-primary.disabled {
|
||
opacity: 0.45;
|
||
}
|
||
|
||
.arrival-primary-text {
|
||
font-size: 15px;
|
||
line-height: 21px;
|
||
font-weight: 700;
|
||
color: #e0e100;
|
||
}
|
||
|
||
.provider-scrim {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 2190;
|
||
background: rgba(0, 0, 0, 0.28);
|
||
}
|
||
|
||
.provider-sheet {
|
||
position: fixed;
|
||
left: 50%;
|
||
bottom: calc(env(safe-area-inset-bottom) + 12px);
|
||
z-index: 2200;
|
||
width: min(430px, calc(100vw - 24px));
|
||
padding: 14px;
|
||
box-sizing: border-box;
|
||
background: #ffffff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 12px 36px rgba(31, 35, 41, 0.22);
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.provider-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.provider-title-group {
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
|
||
.provider-kicker {
|
||
font-size: 12px;
|
||
line-height: 16px;
|
||
font-weight: 600;
|
||
color: #696962;
|
||
}
|
||
|
||
.provider-title {
|
||
font-size: 16px;
|
||
line-height: 22px;
|
||
font-weight: 700;
|
||
color: #262421;
|
||
}
|
||
|
||
.provider-close {
|
||
width: 32px;
|
||
height: 32px;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f3f3f3;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.provider-close-text {
|
||
font-size: 22px;
|
||
line-height: 26px;
|
||
color: #424754;
|
||
}
|
||
|
||
.provider-row {
|
||
min-height: 52px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 9px 0;
|
||
border-top: 1px solid #eceee7;
|
||
}
|
||
|
||
.provider-mark {
|
||
width: 30px;
|
||
height: 30px;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #262421;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.provider-mark-text {
|
||
font-size: 13px;
|
||
line-height: 18px;
|
||
font-weight: 700;
|
||
color: #e0e100;
|
||
}
|
||
|
||
.provider-label {
|
||
flex: 1;
|
||
font-size: 15px;
|
||
line-height: 21px;
|
||
font-weight: 600;
|
||
color: #262421;
|
||
}
|
||
|
||
.provider-action {
|
||
flex-shrink: 0;
|
||
font-size: 12px;
|
||
line-height: 17px;
|
||
font-weight: 600;
|
||
color: #696962;
|
||
}
|
||
</style>
|